Skip to main content

computer_use/browser/
cdp.rs

1//! P10: Chrome DevTools Protocol (CDP) client.
2//!
3//! Attach-only. The user runs Chrome with `--remote-debugging-port=N`; we
4//! discover `N` from the pid's argv and connect. We never spawn a new Chrome
5//! instance — every Chrome life cycle belongs to the user.
6//!
7//! ## Sync bridge
8//!
9//! `DesktopBackend` stays synchronous. For each `browser` subcommand we build
10//! a fresh `tokio::runtime::Builder::new_current_thread()` runtime on the CLI
11//! thread, `block_on` the async CDP request, and drop the runtime on the way
12//! out. The handler task chromiumoxide returns is spawned on that runtime,
13//! so it is also dropped when the runtime is. Chrome is not affected.
14//!
15//! ## Errors
16//!
17//! Every failure funnels into one of:
18//!
19//! * `ComputerError::Unsupported("...")` — built without `--features cdp`.
20//! * `ComputerError::CdpUnavailable("...")` — Chrome not running with a debug
21//!   port, or the websocket died.
22//! * `ComputerError::BlockedUrl("...")` — the URL is in
23//!   `[browser].blocked_urls` in the policy; the request never reaches CDP.
24//! * `ComputerError::InvalidArguments("...")` — missing selector/url/script.
25//! * `ComputerError::ElementNotFound("...")` — selector `find_element` came
26//!   back empty (synthesized from `find_element` errors where applicable).
27
28#![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/// What the CLI passes to [`attach`] so it can find Chrome.
39#[derive(Debug, Clone, Default)]
40pub struct AttachOptions {
41    /// Either `ws://host:port`, `http(s)://host:port` (resolved via `/json/version`).
42    /// Wins over pid discovery when present.
43    pub debug_url: Option<String>,
44    /// Name, bundle id, or path. Used only to look up a pid for the
45    /// port-discovery fallback when `debug_url` is `None`.
46    pub app: Option<String>,
47    /// Pre-resolved pid for the browser. Wins over `app` when present.
48    pub app_pid: Option<i32>,
49    /// Default CDP request timeout.
50    pub command_timeout: Duration,
51}
52
53/// Argument set the action helpers receive (the `browser` subcommands route to it).
54#[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    /// Cached policy `[browser].blocked_urls`. The dispatcher passes these in
66    /// so the cdp helpers do not need to read the policy file themselves.
67    pub policy_blocked_urls: Vec<String>,
68    /// Policy subject the dispatcher resolved. Currently unused by the CDP
69    /// helpers (the URL check is enough), but recorded for future expansion.
70    pub policy_subject: Option<policy::Subject>,
71}
72
73/// JSON-friendly tab summary.
74#[derive(Debug, Clone, Serialize)]
75pub struct TabSummary {
76    /// CDP `targetId` (opaque string).
77    pub target_id: String,
78    /// Same value, kept under the older name `handle` for the CLI help text.
79    pub handle: String,
80    /// Best-effort page title (may be `None` for chrome:// or about:blank).
81    pub title: Option<String>,
82    /// Current URL.
83    pub url: String,
84    /// CDP `type` (`"page"`, `"iframe"`, `"service_worker"`, ...).
85    pub kind: String,
86}
87
88/// Stable error used when the binary is built without `--features cdp`.
89pub fn unsupported() -> ComputerError {
90    ComputerError::Unsupported(
91        "browser commands require the `cdp` feature (build with --features cdp)".into(),
92    )
93}
94
95/// Parse the argv text for `--remote-debugging-port=N` (or `--remote-debugging-port N`).
96///
97/// Returns the first match. Pure, no I/O — exercised by unit tests with one
98/// fixture per OS format. NUL-separated cmdline payloads (Linux, macOS) work
99/// because the splitter accepts both `\0` and ASCII whitespace.
100pub 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/// Map a `chromiumoxide` / CDP error to the closest stable `ComputerError`.
128///
129/// Chromiumoxide's `CdpError::Chrome(chromiumoxide_types::Error)` carries a CDP
130/// server message. When the server fails because the selector matched nothing
131/// (e.g. `DOM.querySelector` returns `nodeId: 0`, which then breaks
132/// `DOM.describeNode`), the message contains "no node" or "node with given
133/// id ... not found". That must surface as `element_not_found` (per gap 2).
134/// Anything that looks like a transport / websocket failure stays
135/// `cdp_unavailable`.
136#[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/// True if the given CDP server message means "the requested node does not
163/// exist in the DOM tree" — the textual sibling of `nodeId: 0` returned by
164/// `DOM.querySelector` and similar.
165#[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/// Resolve the page target we will operate on. Attach-only: when the caller
172/// did not pass `--target`, we look for the first existing `page` target. If
173/// no page target exists we return `cdp_unavailable("no page target")` and
174/// **never** create one (per gap 3).
175#[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/// Read the page URL after attach. Used to enforce `[browser].blocked_urls`
203/// against the *current* page (per gap 4) before running click / text /
204/// type / eval.
205#[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/// Attach, look up the page target, and enforce the policy block list against
214/// the current page URL. All mutating browser ops route through this helper.
215///
216/// The helper is *sync*: it builds its own runtime and `block_on`s the
217/// operation closure. The closure receives an attached `Browser` and a resolved
218/// `Page`; the URL check has already passed by the time it runs.
219#[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// -----------------------------------------------------------------------------
248// Port discovery (always available, regardless of feature).
249
250/// macOS argv source: `sysctl KERN_PROCARGS2`.
251///
252/// The kernel returns argc, then argv as NUL-separated C strings, ending with a
253/// trailing NUL. The actual argv starts at the first NUL byte (after argc).
254#[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    // CTL_KERN (1), KERN_PROCARGS2 (58)
270    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/// Linux argv source: `/proc/<pid>/cmdline`, NUL-separated UTF-8.
320#[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/// Windows argv source: best-effort via Win32. The project does not bundle
345/// Win32, so the error is structured: the caller can still pass `--cdp-url`.
346#[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
360/// Highest-level port discovery. Public so unit tests can stub it.
361pub 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// -----------------------------------------------------------------------------
371// Runtime + attach (only when `cdp` is on).
372
373#[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        // chromiumoxide will resolve `/json/version` for us.
400        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/// List every target the running Chrome exposes.
444///
445/// Implemented as one big async function driven by a `current_thread` runtime
446/// so the borrow checker can see `browser.fetch_targets()` (`&mut`) followed
447/// by `browser.get_page(...)` (`&`) on the same Browser.
448#[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/// Return the [`attach_with_url_check`] closure argument that converts an
501/// absent page target into `cdp_unavailable("no page target")`. Centralised so
502/// the message stays stable across ops.
503#[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/// Bring the named tab to the front.
513#[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/// Navigate the named tab (or the first page) to a URL.
561#[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/// Click the centre of the element matched by `selector`.
609///
610/// The CDP runtime already produces physical mouse events through
611/// `Input.dispatchMouseEvent` (tier `cdp`). The spec asked for the element box
612/// centre -> viewport -> screen transform (window.screenX/Y + outer/inner
613/// height + devicePixelRatio), but Chrome owns its own window and that path
614/// would couple this binary to Chrome's window bounds without a clean public
615/// API. The CDP click stays; the result names the tier explicitly so callers
616/// know what they got (P10 deviation, see ROADMAP.md).
617#[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/// Read `innerText` of the element matched by `selector`.
654///
655/// Spec: `browser text --selector S` returns the `innerText` of the first
656/// element the selector matches. A selector that matches nothing is
657/// `element_not_found`. The current page URL is checked against
658/// `[browser].blocked_urls` before the websocket is touched.
659#[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/// Type `text` into the element matched by `selector` (or the page body when
693/// no selector is given).
694///
695/// Classified like `Action::Type` for audit purposes: the text body is never
696/// stored, only the character count. The current page URL is checked against
697/// `[browser].blocked_urls` first.
698#[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                    // No selector: focus the body and type there. Element::type_str
735                    // requires an Element; the body is the closest focus target.
736                    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/// Evaluate `script` in the page context. Returns the value as JSON.
759#[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// -----------------------------------------------------------------------------
793// Unit tests. Pure logic, no Chrome needed.
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn parse_debug_port_handles_mac_sysctl_layout() {
801        // sysctl KERN_PROCARGS2 returns argc, then NUL-separated args ending in
802        // a NUL. We skip past argc, then the splitter sees NUL + whitespace.
803        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        // Just make sure the feature-gated functions are reachable.
851        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                // The message we produce must lead with the "selector matched
942                // no element" phrase so error recipients can identify the
943                // cause without parsing the embedded CDP body.
944                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        // Chromiumoxide wraps the CDP server response in `CdpError::Chrome`
957        // (a `chromiumoxide_types::Error`). We can't construct that without
958        // pulling in `chromiumoxide_types` as a dev-dep, but we can exercise
959        // the same matching logic through `ChromeMessage`, which carries the
960        // verbatim text the server returned. The function branches on the
961        // same substrings.
962        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        // Anything that is not a ChromeError/ChromeMessage/NotFound error
990        // (transport, websocket, IO) must remain cdp_unavailable.
991        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// -----------------------------------------------------------------------------
1013// Request-type construction test. Exercises the Action deserializer.
1014
1015#[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}