1#![allow(unsafe_code)]
29
30use std::time::Duration;
31
32use serde::Serialize;
33use serde_json::Value;
34
35use crate::error::ComputerError;
36use crate::policy;
37
38#[derive(Debug, Clone, Default)]
40pub struct AttachOptions {
41 pub debug_url: Option<String>,
44 pub app: Option<String>,
47 pub app_pid: Option<i32>,
49 pub command_timeout: Duration,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct ActionOptions {
56 pub debug_url: Option<String>,
57 pub app: Option<String>,
58 pub app_pid: Option<i32>,
59 pub target_id: Option<String>,
60 pub url: Option<String>,
61 pub selector: Option<String>,
62 pub text: Option<String>,
63 pub script: Option<String>,
64 pub command_timeout: Duration,
65 pub policy_blocked_urls: Vec<String>,
68 pub policy_subject: Option<policy::Subject>,
71}
72
73#[derive(Debug, Clone, Serialize)]
75pub struct TabSummary {
76 pub target_id: String,
78 pub handle: String,
80 pub title: Option<String>,
82 pub url: String,
84 pub kind: String,
86}
87
88pub fn unsupported() -> ComputerError {
90 ComputerError::Unsupported(
91 "browser commands require the `cdp` feature (build with --features cdp)".into(),
92 )
93}
94
95pub fn parse_debug_port(args: &str) -> Option<u16> {
101 let tokens: Vec<&str> = args
102 .split(|ch: char| ch.is_whitespace() || ch == '\0')
103 .filter(|s| !s.is_empty())
104 .collect();
105 for (idx, token) in tokens.iter().enumerate() {
106 if let Some(rest) = token.strip_prefix("--remote-debugging-port=") {
107 if let Ok(port) = rest.trim().parse::<u16>() {
108 return Some(port);
109 }
110 continue;
111 }
112 if *token == "--remote-debugging-port" {
113 if let Some(next) = tokens.get(idx + 1) {
114 if let Ok(port) = next.trim().parse::<u16>() {
115 return Some(port);
116 }
117 }
118 }
119 }
120 None
121}
122
123fn cdp_unavailable(message: String) -> ComputerError {
124 ComputerError::CdpUnavailable(message)
125}
126
127#[cfg(feature = "cdp")]
137fn map_cdp_error(error: chromiumoxide::error::CdpError) -> ComputerError {
138 use chromiumoxide::error::CdpError as E;
139 match error {
140 E::Chrome(message) => {
141 if matches_node_not_found(&message.message) {
142 ComputerError::ElementNotFound(format!(
143 "selector matched no element: {}",
144 message.message
145 ))
146 } else {
147 cdp_unavailable(format!("cdp: {}", message.message))
148 }
149 }
150 E::ChromeMessage(text) => {
151 if matches_node_not_found(&text) {
152 ComputerError::ElementNotFound(format!("selector matched no element: {text}"))
153 } else {
154 cdp_unavailable(format!("cdp: {text}"))
155 }
156 }
157 E::NotFound => ComputerError::ElementNotFound("selector matched no element".into()),
158 other => cdp_unavailable(format!("cdp: {other}")),
159 }
160}
161
162#[cfg(feature = "cdp")]
166fn matches_node_not_found(text: &str) -> bool {
167 let lower = text.to_ascii_lowercase();
168 lower.contains("no node") || lower.contains("node with given id") && lower.contains("not found")
169}
170
171#[cfg(feature = "cdp")]
176async fn resolve_page(
177 browser: &mut chromiumoxide::Browser,
178 target_id: Option<&str>,
179) -> Result<chromiumoxide::Page, ComputerError> {
180 if let Some(target_id_text) = target_id.map(str::trim).filter(|s| !s.is_empty()) {
181 return browser
182 .get_page(target_id_for(target_id_text))
183 .await
184 .map_err(|error| {
185 cdp_unavailable(format!("target {target_id_text} not found: {error}"))
186 });
187 }
188 let targets = browser.fetch_targets().await.map_err(map_cdp_error)?;
189 let first = targets.into_iter().find(|target| target.r#type == "page");
190 match first {
191 Some(target) => {
192 let tid = String::from(target.target_id.clone());
193 browser
194 .get_page(target.target_id.clone())
195 .await
196 .map_err(|error| cdp_unavailable(format!("get_page({tid}): {error}")))
197 }
198 None => Err(no_page_target_error()),
199 }
200}
201
202#[cfg(feature = "cdp")]
206async fn current_page_url(page: &chromiumoxide::Page) -> String {
207 match page.url().await {
208 Ok(Some(text)) => text,
209 _ => String::new(),
210 }
211}
212
213#[cfg(feature = "cdp")]
220fn attach_with_url_check<F, Fut, T>(
221 attach_opts: AttachOptions,
222 target_id: Option<String>,
223 blocked_urls: Vec<String>,
224 operation: F,
225) -> Result<T, ComputerError>
226where
227 F: FnOnce(chromiumoxide::Browser, chromiumoxide::Page) -> Fut,
228 Fut: std::future::Future<Output = Result<T, ComputerError>>,
229{
230 let command_timeout = attach_opts.command_timeout;
231 let ws_url = pick_websocket_url(&attach_opts)?;
232 let browser = connect(&ws_url, command_timeout)?;
233 let rt = new_runtime(command_timeout)?;
234 rt.block_on(async move {
235 let mut browser = browser;
236 let page = resolve_page(&mut browser, target_id.as_deref()).await?;
237 let active = current_page_url(&page).await;
238 if let Some(pattern) = policy::url_blocked(&active, &blocked_urls) {
239 return Err(ComputerError::BlockedUrl(format!(
240 "current page matched {pattern}"
241 )));
242 }
243 operation(browser, page).await
244 })
245}
246
247#[cfg(target_os = "macos")]
255fn read_command_line_args(pid: i32) -> Result<String, ComputerError> {
256 use std::ffi::{c_int, c_void};
257
258 extern "C" {
259 fn sysctl(
260 name: *const c_int,
261 namelen: usize,
262 oldp: *mut c_void,
263 oldlenp: *mut usize,
264 newp: *const c_void,
265 newlen: usize,
266 ) -> c_int;
267 }
268
269 let mib: [c_int; 3] = [1, 58, pid as c_int];
271 let mut size: usize = 0;
272 let rc = unsafe {
273 sysctl(
274 mib.as_ptr() as *const _,
275 mib.len(),
276 std::ptr::null_mut(),
277 &mut size,
278 std::ptr::null_mut(),
279 0,
280 )
281 };
282 if rc != 0 {
283 return Err(cdp_unavailable(format!(
284 "sysctl size for pid {pid}: {}",
285 std::io::Error::last_os_error()
286 )));
287 }
288 let mut buf = vec![0u8; size];
289 let rc = unsafe {
290 sysctl(
291 mib.as_ptr() as *const _,
292 mib.len(),
293 buf.as_mut_ptr() as *mut c_void,
294 &mut size,
295 std::ptr::null_mut(),
296 0,
297 )
298 };
299 if rc != 0 {
300 return Err(cdp_unavailable(format!(
301 "sysctl read for pid {pid}: {}",
302 std::io::Error::last_os_error()
303 )));
304 }
305 buf.truncate(size);
306 let start = buf
307 .iter()
308 .position(|byte| *byte == 0)
309 .map(|index| index + 1)
310 .unwrap_or(0);
311 let end = buf
312 .iter()
313 .rposition(|byte| *byte != 0)
314 .map(|index| index + 1)
315 .unwrap_or(start);
316 Ok(String::from_utf8_lossy(&buf[start..end]).into_owned())
317}
318
319#[cfg(target_os = "linux")]
321fn read_command_line_args(pid: i32) -> Result<String, ComputerError> {
322 use std::io::Read;
323
324 let path = format!("/proc/{pid}/cmdline");
325 let mut file = std::fs::File::open(&path).map_err(|error| {
326 cdp_unavailable(format!("open {path}: {error} (process may have exited)"))
327 })?;
328 let mut bytes = Vec::new();
329 file.read_to_end(&mut bytes)
330 .map_err(|error| cdp_unavailable(format!("read {path}: {error}")))?;
331 let mut out = String::new();
332 for part in bytes.split(|byte| *byte == 0) {
333 if part.is_empty() {
334 continue;
335 }
336 if !out.is_empty() {
337 out.push(' ');
338 }
339 out.push_str(&String::from_utf8_lossy(part));
340 }
341 Ok(out)
342}
343
344#[cfg(target_os = "windows")]
347fn read_command_line_args(pid: i32) -> Result<String, ComputerError> {
348 Err(cdp_unavailable(format!(
349 "Windows command-line lookup for pid {pid} is not implemented; pass --cdp-url ws://127.0.0.1:<port> explicitly"
350 )))
351}
352
353#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
354fn read_command_line_args(pid: i32) -> Result<String, ComputerError> {
355 Err(cdp_unavailable(format!(
356 "command-line lookup is not implemented for this OS; pass --cdp-url ws://127.0.0.1:<port> explicitly"
357 )))
358}
359
360pub fn discover_debug_port(pid: i32) -> Result<u16, ComputerError> {
362 let args = read_command_line_args(pid)?;
363 parse_debug_port(&args).ok_or_else(|| {
364 cdp_unavailable(format!(
365 "pid {pid} command line has no --remote-debugging-port=N"
366 ))
367 })
368}
369
370#[cfg(feature = "cdp")]
374fn new_runtime(_timeout: Duration) -> Result<tokio::runtime::Runtime, ComputerError> {
375 tokio::runtime::Builder::new_current_thread()
376 .enable_all()
377 .build()
378 .map_err(|error| cdp_unavailable(format!("tokio runtime: {error}")))
379}
380
381#[cfg(feature = "cdp")]
382fn normalize_url(input: &str) -> Result<String, ComputerError> {
383 if let Some(rest) = input
384 .strip_prefix("ws://")
385 .or_else(|| input.strip_prefix("wss://"))
386 {
387 if rest.is_empty() {
388 return Err(cdp_unavailable(format!("cdp url has no host: {input}")));
389 }
390 return Ok(input.to_string());
391 }
392 if let Some(rest) = input
393 .strip_prefix("http://")
394 .or_else(|| input.strip_prefix("https://"))
395 {
396 if rest.is_empty() {
397 return Err(cdp_unavailable(format!("cdp url has no host: {input}")));
398 }
399 return Ok(input.trim_end_matches('/').to_string());
401 }
402 Err(cdp_unavailable(format!(
403 "unsupported cdp url scheme: {input} (expected ws://, wss://, https://, or http://)"
404 )))
405}
406
407#[cfg(feature = "cdp")]
408fn pick_websocket_url(opts: &AttachOptions) -> Result<String, ComputerError> {
409 if let Some(url) = opts
410 .debug_url
411 .as_deref()
412 .map(str::trim)
413 .filter(|s| !s.is_empty())
414 {
415 return normalize_url(url);
416 }
417 let pid = opts.app_pid.ok_or_else(|| {
418 cdp_unavailable("cdp attach requires --cdp-url or --app resolving to a running pid".into())
419 })?;
420 let port = discover_debug_port(pid)?;
421 Ok(format!("ws://127.0.0.1:{port}"))
422}
423
424#[cfg(feature = "cdp")]
425fn connect(url: &str, timeout: Duration) -> Result<chromiumoxide::Browser, ComputerError> {
426 let rt = new_runtime(timeout)?;
427 let connect = chromiumoxide::Browser::connect(url.to_string());
428 let (browser, mut handler) = rt
429 .block_on(connect)
430 .map_err(|error| cdp_unavailable(format!("connect to {url}: {error}")))?;
431 rt.spawn(async move {
432 use futures::StreamExt;
433 while let Some(_event) = handler.next().await {}
434 });
435 Ok(browser)
436}
437
438#[cfg(feature = "cdp")]
439fn target_id_for(text: &str) -> chromiumoxide::cdp::browser_protocol::target::TargetId {
440 chromiumoxide::cdp::browser_protocol::target::TargetId::new(text.to_string())
441}
442
443#[cfg(feature = "cdp")]
449pub fn list_tabs(opts: &ActionOptions) -> Result<Vec<TabSummary>, ComputerError> {
450 let attach_opts = AttachOptions {
451 debug_url: opts.debug_url.clone(),
452 app: opts.app.clone(),
453 app_pid: opts.app_pid,
454 command_timeout: opts.command_timeout,
455 };
456 let url = pick_websocket_url(&attach_opts)?;
457 let browser = connect(&url, opts.command_timeout)?;
458 let rt = new_runtime(opts.command_timeout)?;
459 let summaries = rt.block_on(async move {
460 let mut browser = browser;
461 let targets = browser
462 .fetch_targets()
463 .await
464 .map_err(|error| cdp_unavailable(format!("fetch_targets: {error}")))?;
465 let mut out = Vec::with_capacity(targets.len());
466 for target in targets {
467 let target_id = target.target_id.clone();
468 let kind = target.r#type.clone();
469 let fallback_url = target.url.clone();
470 let page_url = match browser.get_page(target_id.clone()).await {
471 Ok(handle) => match handle.url().await {
472 Ok(Some(text)) => text,
473 _ => fallback_url.clone(),
474 },
475 Err(_) => fallback_url,
476 };
477 let title = match browser.get_page(target_id.clone()).await {
478 Ok(handle) => handle.get_title().await.ok().flatten(),
479 Err(_) => None,
480 };
481 let target_id_string: String = target_id.into();
482 out.push(TabSummary {
483 target_id: target_id_string.clone(),
484 handle: target_id_string,
485 title,
486 url: page_url,
487 kind,
488 });
489 }
490 Result::<Vec<TabSummary>, ComputerError>::Ok(out)
491 })?;
492 Ok(summaries)
493}
494
495#[cfg(not(feature = "cdp"))]
496pub fn list_tabs(_opts: &ActionOptions) -> Result<Vec<TabSummary>, ComputerError> {
497 Err(unsupported())
498}
499
500#[cfg(feature = "cdp")]
504fn no_page_target_error() -> ComputerError {
505 cdp_unavailable(
506 "no page target found in the attached browser; computer-use is attach-only \
507 and will not create new pages. Open a tab in Chrome, then retry."
508 .into(),
509 )
510}
511
512#[cfg(feature = "cdp")]
514pub fn activate_tab(opts: &ActionOptions) -> Result<String, ComputerError> {
515 let target_id = opts.target_id.clone().ok_or_else(|| {
516 ComputerError::InvalidArguments("browser activate requires --target".into())
517 })?;
518 let attach_opts = AttachOptions {
519 debug_url: opts.debug_url.clone(),
520 app: opts.app.clone(),
521 app_pid: opts.app_pid,
522 command_timeout: opts.command_timeout,
523 };
524 let url = pick_websocket_url(&attach_opts)?;
525 let browser = connect(&url, opts.command_timeout)?;
526 let blocked_urls = opts.policy_blocked_urls.clone();
527 let rt = new_runtime(opts.command_timeout)?;
528 let (active_url, target_id_for_err) = rt.block_on(async move {
529 let browser = browser;
530 let target_id_owned = target_id.clone();
531 let page = browser
532 .get_page(target_id_for(&target_id_owned))
533 .await
534 .map_err(|error| {
535 cdp_unavailable(format!("target {target_id_owned} not found: {error}"))
536 })?;
537 page.bring_to_front()
538 .await
539 .map_err(|error| cdp_unavailable(format!("bringToFront: {error}")))?;
540 let active = match page.url().await {
541 Ok(Some(text)) => text,
542 Ok(None) => String::new(),
543 Err(_) => String::new(),
544 };
545 Ok::<(String, String), ComputerError>((active, target_id_owned))
546 })?;
547 if let Some(pattern) = policy::url_blocked(&active_url, &blocked_urls) {
548 return Err(ComputerError::BlockedUrl(format!(
549 "current tab {target_id_for_err} matched {pattern}"
550 )));
551 }
552 Ok(active_url)
553}
554
555#[cfg(not(feature = "cdp"))]
556pub fn activate_tab(_opts: &ActionOptions) -> Result<String, ComputerError> {
557 Err(unsupported())
558}
559
560#[cfg(feature = "cdp")]
562pub fn navigate(opts: &ActionOptions) -> Result<String, ComputerError> {
563 let url = opts
564 .url
565 .clone()
566 .ok_or_else(|| ComputerError::InvalidArguments("browser navigate requires --url".into()))?;
567 if let Some(pattern) = policy::url_blocked(&url, &opts.policy_blocked_urls) {
568 return Err(ComputerError::BlockedUrl(format!("matched {pattern}")));
569 }
570 let attach_opts = AttachOptions {
571 debug_url: opts.debug_url.clone(),
572 app: opts.app.clone(),
573 app_pid: opts.app_pid,
574 command_timeout: opts.command_timeout,
575 };
576 let ws_url = pick_websocket_url(&attach_opts)?;
577 let browser = connect(&ws_url, opts.command_timeout)?;
578 let target_id = opts.target_id.clone();
579 let blocked_urls = opts.policy_blocked_urls.clone();
580 let url_for_send = url.clone();
581 let rt = new_runtime(opts.command_timeout)?;
582 let active_url = rt.block_on(async move {
583 let mut browser = browser;
584 let page = resolve_page(&mut browser, target_id.as_deref()).await?;
585 page.goto(url_for_send.clone())
586 .await
587 .map_err(|error| cdp_unavailable(format!("Page.navigate: {error}")))?;
588 let active = match page.url().await {
589 Ok(Some(text)) => text,
590 Ok(None) => String::new(),
591 Err(_) => String::new(),
592 };
593 Ok::<String, ComputerError>(active)
594 })?;
595 if let Some(pattern) = policy::url_blocked(&active_url, &blocked_urls) {
596 return Err(ComputerError::BlockedUrl(format!(
597 "redirect landed on blocked url matched {pattern}"
598 )));
599 }
600 Ok(active_url)
601}
602
603#[cfg(not(feature = "cdp"))]
604pub fn navigate(_opts: &ActionOptions) -> Result<String, ComputerError> {
605 Err(unsupported())
606}
607
608#[cfg(feature = "cdp")]
618pub fn click(opts: &ActionOptions) -> Result<(), ComputerError> {
619 let selector = opts.selector.clone().ok_or_else(|| {
620 ComputerError::InvalidArguments("browser click requires --selector".into())
621 })?;
622 let attach_opts = AttachOptions {
623 debug_url: opts.debug_url.clone(),
624 app: opts.app.clone(),
625 app_pid: opts.app_pid,
626 command_timeout: opts.command_timeout,
627 };
628 let target_id = opts.target_id.clone();
629 let blocked_urls = opts.policy_blocked_urls.clone();
630 attach_with_url_check(
631 attach_opts,
632 target_id,
633 blocked_urls,
634 move |_browser, page| async move {
635 let element = page
636 .find_element(selector.clone())
637 .await
638 .map_err(map_cdp_error)?;
639 element
640 .click()
641 .await
642 .map_err(|error| cdp_unavailable(format!("element click: {error}")))?;
643 Ok::<(), ComputerError>(())
644 },
645 )
646}
647
648#[cfg(not(feature = "cdp"))]
649pub fn click(_opts: &ActionOptions) -> Result<(), ComputerError> {
650 Err(unsupported())
651}
652
653#[cfg(feature = "cdp")]
660pub fn read_inner_text(opts: &ActionOptions) -> Result<String, ComputerError> {
661 let selector = opts.selector.clone().ok_or_else(|| {
662 ComputerError::InvalidArguments("browser text requires --selector".into())
663 })?;
664 let attach_opts = AttachOptions {
665 debug_url: opts.debug_url.clone(),
666 app: opts.app.clone(),
667 app_pid: opts.app_pid,
668 command_timeout: opts.command_timeout,
669 };
670 let target_id = opts.target_id.clone();
671 let blocked_urls = opts.policy_blocked_urls.clone();
672 attach_with_url_check(
673 attach_opts,
674 target_id,
675 blocked_urls,
676 move |_browser, page| async move {
677 let element = page
678 .find_element(selector.clone())
679 .await
680 .map_err(map_cdp_error)?;
681 let text = element.inner_text().await.map_err(map_cdp_error)?;
682 Ok::<String, ComputerError>(text.unwrap_or_default())
683 },
684 )
685}
686
687#[cfg(not(feature = "cdp"))]
688pub fn read_inner_text(_opts: &ActionOptions) -> Result<String, ComputerError> {
689 Err(unsupported())
690}
691
692#[cfg(feature = "cdp")]
699pub fn type_text(opts: &ActionOptions) -> Result<(), ComputerError> {
700 let text = opts
701 .text
702 .clone()
703 .ok_or_else(|| ComputerError::InvalidArguments("browser type requires --text".into()))?;
704 let selector = opts.selector.clone();
705 let attach_opts = AttachOptions {
706 debug_url: opts.debug_url.clone(),
707 app: opts.app.clone(),
708 app_pid: opts.app_pid,
709 command_timeout: opts.command_timeout,
710 };
711 let target_id = opts.target_id.clone();
712 let blocked_urls = opts.policy_blocked_urls.clone();
713 attach_with_url_check(
714 attach_opts,
715 target_id,
716 blocked_urls,
717 move |_browser, page| async move {
718 match selector.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
719 Some(sel) => {
720 let element = page
721 .find_element(sel.to_string())
722 .await
723 .map_err(map_cdp_error)?;
724 element
725 .click()
726 .await
727 .map_err(|error| cdp_unavailable(format!("element click: {error}")))?;
728 element
729 .type_str(text.clone())
730 .await
731 .map_err(|error| cdp_unavailable(format!("element.type_str: {error}")))?;
732 }
733 None => {
734 let body = page
737 .find_element("body".to_string())
738 .await
739 .map_err(map_cdp_error)?;
740 body.click()
741 .await
742 .map_err(|error| cdp_unavailable(format!("body click: {error}")))?;
743 body.type_str(text.clone())
744 .await
745 .map_err(|error| cdp_unavailable(format!("body.type_str: {error}")))?;
746 }
747 }
748 Ok::<(), ComputerError>(())
749 },
750 )
751}
752
753#[cfg(not(feature = "cdp"))]
754pub fn type_text(_opts: &ActionOptions) -> Result<(), ComputerError> {
755 Err(unsupported())
756}
757
758#[cfg(feature = "cdp")]
760pub fn eval(opts: &ActionOptions) -> Result<Value, ComputerError> {
761 let script = opts
762 .script
763 .clone()
764 .ok_or_else(|| ComputerError::InvalidArguments("browser eval requires --eval".into()))?;
765 let attach_opts = AttachOptions {
766 debug_url: opts.debug_url.clone(),
767 app: opts.app.clone(),
768 app_pid: opts.app_pid,
769 command_timeout: opts.command_timeout,
770 };
771 let target_id = opts.target_id.clone();
772 let blocked_urls = opts.policy_blocked_urls.clone();
773 attach_with_url_check(
774 attach_opts,
775 target_id,
776 blocked_urls,
777 move |_browser, page| async move {
778 let result = page
779 .evaluate(script.clone())
780 .await
781 .map_err(|error| cdp_unavailable(format!("Page.evaluate: {error}")))?;
782 Ok::<chromiumoxide::js::EvaluationResult, ComputerError>(result)
783 },
784 )
785 .map(|result| result.value().cloned().unwrap_or(Value::Null))
786}
787
788#[cfg(not(feature = "cdp"))]
789pub fn eval(_opts: &ActionOptions) -> Result<Value, ComputerError> {
790 Err(unsupported())
791}
792#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
800 fn parse_debug_port_handles_mac_sysctl_layout() {
801 let argv = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\
804 \0--remote-debugging-port=9222\
805 \0--enable-features=NetworkService\
806 \0";
807 assert_eq!(parse_debug_port(argv), Some(9222));
808 }
809
810 #[test]
811 fn parse_debug_port_handles_linux_proc_cmdline_layout() {
812 let argv = "/opt/google/chrome/chrome\0--type=\
813 \0--remote-debugging-port=0\0--enable-features=Foo\0";
814 assert_eq!(parse_debug_port(argv), Some(0));
815 }
816
817 #[test]
818 fn parse_debug_port_handles_linux_proc_cmdline_with_space_separator() {
819 let argv = "/opt/google/chrome/chrome\0--remote-debugging-port\0 9222\0--bar";
820 assert_eq!(parse_debug_port(argv), Some(9222));
821 }
822
823 #[test]
824 fn parse_debug_port_handles_windows_command_line_layout() {
825 let argv = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe \
826 --remote-debugging-port=9222 --enable-features=Foo";
827 assert_eq!(parse_debug_port(argv), Some(9222));
828 }
829
830 #[test]
831 fn parse_debug_port_returns_none_when_flag_missing() {
832 assert!(parse_debug_port("--type= --enable-features=Foo").is_none());
833 assert!(parse_debug_port("").is_none());
834 }
835
836 #[test]
837 fn parse_debug_port_ignores_invalid_numbers() {
838 assert!(parse_debug_port("--remote-debugging-port=not-a-number").is_none());
839 }
840
841 #[test]
842 fn unsupported_returns_a_stable_code() {
843 let error = unsupported();
844 assert_eq!(error.code(), "unsupported");
845 }
846
847 #[test]
848 #[cfg(feature = "cdp")]
849 fn cdp_feature_compiles_and_exposes_helpers() {
850 let _opts = ActionOptions::default();
852 let _url = normalize_url("ws://127.0.0.1:9222");
853 let _list = list_tabs;
854 let _click = click;
855 let _eval = eval;
856 }
857
858 #[test]
859 #[cfg(not(feature = "cdp"))]
860 fn list_tabs_returns_unsupported_when_feature_off() {
861 let opts = ActionOptions::default();
862 let result = list_tabs(&opts);
863 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
864 }
865
866 #[test]
867 #[cfg(not(feature = "cdp"))]
868 fn navigate_returns_unsupported_when_feature_off() {
869 let opts = ActionOptions::default();
870 let result = navigate(&opts);
871 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
872 }
873
874 #[test]
875 #[cfg(not(feature = "cdp"))]
876 fn click_returns_unsupported_when_feature_off() {
877 let opts = ActionOptions::default();
878 let result = click(&opts);
879 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
880 }
881
882 #[test]
883 #[cfg(not(feature = "cdp"))]
884 fn type_text_returns_unsupported_when_feature_off() {
885 let opts = ActionOptions::default();
886 let result = type_text(&opts);
887 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
888 }
889
890 #[test]
891 #[cfg(not(feature = "cdp"))]
892 fn eval_returns_unsupported_when_feature_off() {
893 let opts = ActionOptions::default();
894 let result = eval(&opts);
895 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
896 }
897
898 #[test]
899 #[cfg(not(feature = "cdp"))]
900 fn activate_tab_returns_unsupported_when_feature_off() {
901 let opts = ActionOptions::default();
902 let result = activate_tab(&opts);
903 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
904 }
905
906 #[test]
907 #[cfg(not(feature = "cdp"))]
908 fn read_inner_text_returns_unsupported_when_feature_off() {
909 let opts = ActionOptions::default();
910 let result = read_inner_text(&opts);
911 assert!(matches!(result, Err(ComputerError::Unsupported(_))));
912 }
913
914 #[cfg(feature = "cdp")]
915 #[test]
916 fn no_page_target_error_is_cdp_unavailable_with_stable_message() {
917 let error = no_page_target_error();
918 assert!(matches!(error, ComputerError::CdpUnavailable(_)));
919 let text = match error {
920 ComputerError::CdpUnavailable(text) => text,
921 _ => unreachable!("checked above"),
922 };
923 assert!(
924 text.contains("no page target"),
925 "expected stable 'no page target' phrasing in {text}"
926 );
927 assert!(
928 text.contains("attach-only"),
929 "expected attach-only phrasing in {text}"
930 );
931 }
932
933 #[cfg(feature = "cdp")]
934 #[test]
935 fn map_cdp_error_chrome_message_no_node_becomes_element_not_found() {
936 use chromiumoxide::error::CdpError;
937 let error = CdpError::ChromeMessage("No node with given id found".into());
938 let mapped = map_cdp_error(error);
939 match mapped {
940 ComputerError::ElementNotFound(msg) => {
941 assert!(
945 msg.starts_with("selector matched no element"),
946 "expected leading phrase, got {msg}"
947 );
948 }
949 other => panic!("expected ElementNotFound, got {other:?}"),
950 }
951 }
952
953 #[cfg(feature = "cdp")]
954 #[test]
955 fn map_cdp_error_chrome_message_node_not_found_phrase_matches() {
956 use chromiumoxide::error::CdpError;
963 let error = CdpError::ChromeMessage("Node with given id was not found.".into());
964 let mapped = map_cdp_error(error);
965 assert!(matches!(mapped, ComputerError::ElementNotFound(_)));
966 }
967
968 #[cfg(feature = "cdp")]
969 #[test]
970 fn map_cdp_error_unrelated_chrome_message_stays_cdp_unavailable() {
971 use chromiumoxide::error::CdpError;
972 let error = CdpError::ChromeMessage("Protocol error: some method not allowed".into());
973 let mapped = map_cdp_error(error);
974 match mapped {
975 ComputerError::CdpUnavailable(msg) => {
976 assert!(
977 msg.contains("some method not allowed"),
978 "expected cdp_unavailable to keep the original message, got {msg}"
979 );
980 }
981 other => panic!("expected CdpUnavailable, got {other:?}"),
982 }
983 }
984
985 #[cfg(feature = "cdp")]
986 #[test]
987 fn map_cdp_error_io_error_stays_cdp_unavailable() {
988 use chromiumoxide::error::CdpError;
989 let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
992 let error = CdpError::Io(io);
993 let mapped = map_cdp_error(error);
994 assert!(matches!(mapped, ComputerError::CdpUnavailable(_)));
995 }
996
997 #[cfg(feature = "cdp")]
998 #[test]
999 fn map_cdp_error_not_found_becomes_element_not_found() {
1000 use chromiumoxide::error::CdpError;
1001 let error = CdpError::NotFound;
1002 let mapped = map_cdp_error(error);
1003 match mapped {
1004 ComputerError::ElementNotFound(msg) => {
1005 assert!(msg.contains("no element"));
1006 }
1007 other => panic!("expected ElementNotFound, got {other:?}"),
1008 }
1009 }
1010}
1011
1012#[cfg(test)]
1016mod request_construction {
1017 use serde_json::json;
1018
1019 #[test]
1020 fn browser_actions_deserialize_from_request_objects() {
1021 let tabs = json!({
1022 "action": "browser_tabs",
1023 });
1024 let parsed: crate::protocol::Action = serde_json::from_value(tabs).unwrap();
1025 assert!(matches!(parsed, crate::protocol::Action::BrowserTabs {}));
1026
1027 let activate = json!({
1028 "action": "browser_activate",
1029 "target_id": "ABC123",
1030 });
1031 let parsed: crate::protocol::Action = serde_json::from_value(activate).unwrap();
1032 match parsed {
1033 crate::protocol::Action::BrowserActivate { target_id } => {
1034 assert_eq!(target_id.as_deref(), Some("ABC123"));
1035 }
1036 other => panic!("expected BrowserActivate, got {other:?}"),
1037 }
1038
1039 let navigate = json!({
1040 "action": "browser_navigate",
1041 "url": "https://example.com",
1042 "target_id": "ABC123",
1043 });
1044 let parsed: crate::protocol::Action = serde_json::from_value(navigate).unwrap();
1045 match parsed {
1046 crate::protocol::Action::BrowserNavigate { url, target_id } => {
1047 assert_eq!(url, "https://example.com");
1048 assert_eq!(target_id.as_deref(), Some("ABC123"));
1049 }
1050 other => panic!("expected BrowserNavigate, got {other:?}"),
1051 }
1052
1053 let click = json!({
1054 "action": "browser_click",
1055 "selector": "button#go",
1056 });
1057 let parsed: crate::protocol::Action = serde_json::from_value(click).unwrap();
1058 match parsed {
1059 crate::protocol::Action::BrowserClick {
1060 selector,
1061 target_id,
1062 } => {
1063 assert_eq!(selector, "button#go");
1064 assert!(target_id.is_none());
1065 }
1066 other => panic!("expected BrowserClick, got {other:?}"),
1067 }
1068
1069 let text = json!({
1070 "action": "browser_text",
1071 "selector": "input#name",
1072 });
1073 let parsed: crate::protocol::Action = serde_json::from_value(text).unwrap();
1074 match parsed {
1075 crate::protocol::Action::BrowserText {
1076 selector,
1077 target_id,
1078 } => {
1079 assert_eq!(selector, "input#name");
1080 assert!(target_id.is_none());
1081 }
1082 other => panic!("expected BrowserText, got {other:?}"),
1083 }
1084
1085 let type_action = json!({
1086 "action": "browser_type",
1087 "text": "hello",
1088 "selector": "input#name",
1089 });
1090 let parsed: crate::protocol::Action = serde_json::from_value(type_action).unwrap();
1091 match parsed {
1092 crate::protocol::Action::BrowserType {
1093 text,
1094 selector,
1095 target_id,
1096 } => {
1097 assert_eq!(text, "hello");
1098 assert_eq!(selector.as_deref(), Some("input#name"));
1099 assert!(target_id.is_none());
1100 }
1101 other => panic!("expected BrowserType, got {other:?}"),
1102 }
1103
1104 let eval_action = json!({
1105 "action": "browser_eval",
1106 "script": "1+1",
1107 });
1108 let parsed: crate::protocol::Action = serde_json::from_value(eval_action).unwrap();
1109 match parsed {
1110 crate::protocol::Action::BrowserEval { script, target_id } => {
1111 assert_eq!(script, "1+1");
1112 assert!(target_id.is_none());
1113 }
1114 other => panic!("expected BrowserEval, got {other:?}"),
1115 }
1116 }
1117}