Skip to main content

browser_control/cli/
eval.rs

1//! `browser-control eval` — evaluate a JS expression in a page.
2//!
3//! Three routing paths, picked by what the caller supplies:
4//!
5//! 1. **`eval <browser>/<tab>`** — named-tab path. Resolves `<tab>` in the
6//!    `tabs` SQLite table via the engine-agnostic [`TabBackend`]; if
7//!    missing, returns `TabNotFound` so the agent can
8//!    `tab open <browser>/<tab>` first. Mutually exclusive with
9//!    `--target`. Works for both CDP and BiDi browsers.
10//! 2. **`eval <browser>` with no `--target`** — routes through the
11//!    daemon-style scratch tab with recover-once. The default, and the
12//!    architectural fix for the iLO failure mode: lock-free `eval`
13//!    never silently lands on a user's admin tab. Engine-agnostic.
14//! 3. **`eval <browser> --target <regex>`** — explicit selector against
15//!    a user tab matching the URL regex. The original behaviour, kept
16//!    for ad-hoc targeted use.
17//!
18//! The shared preamble and the named-tab arm live in [`crate::cli::route`];
19//! see that module for the routing-path overview. The bare-browser arm here is
20//! scratch-with-recover-once (the iLO fix), and the External-endpoint fallback
21//! is the legacy direct attach.
22
23use std::time::Duration;
24
25use anyhow::Result;
26use serde_json::Value;
27
28use crate::cli::env_resolver::Source;
29use crate::cli::route;
30use crate::cli::trace::CommandTrace;
31use crate::session::backend::open_backend;
32use crate::session::freshness;
33use crate::session::{with_scratch_recovery, PageSession};
34
35pub async fn run(
36    browser: Option<String>,
37    expression: String,
38    target: Option<String>,
39    json: bool,
40    await_promise: bool,
41    timeout_ms: u64,
42    max_age: String,
43) -> Result<()> {
44    let mut trace = CommandTrace::new("eval");
45    let result = run_inner(
46        browser,
47        expression,
48        target,
49        json,
50        await_promise,
51        timeout_ms,
52        max_age,
53        &mut trace,
54    )
55    .await;
56    trace.finish(result)
57}
58
59#[allow(clippy::too_many_arguments)]
60async fn run_inner(
61    browser: Option<String>,
62    expression: String,
63    target: Option<String>,
64    json: bool,
65    await_promise: bool,
66    timeout_ms: u64,
67    max_age: String,
68    trace: &mut CommandTrace,
69) -> Result<()> {
70    let timeout = Duration::from_millis(timeout_ms);
71    let max_age = freshness::parse_max_age(&max_age)?;
72
73    let r = route::preamble(browser, target.as_deref(), trace).await?;
74    let resolved = &r.resolved;
75
76    let value = match (r.tab_name.clone(), target) {
77        // Path 1: <browser>/<tab> — named tab, engine-agnostic, with
78        // recover-once around a tab that dies between resolve and op.
79        (Some(name), None) => {
80            trace.route("named-tab").tab_name(&name);
81            let expr = expression.clone();
82            route::run_named_tab(
83                &r,
84                &name,
85                "named tabs (`<browser>/<name>`) require a registered browser; \
86                 external endpoints can't carry tab names",
87                move |b, target_id| {
88                    let expr = expr.clone();
89                    async move {
90                        b.ensure_fresh(&target_id, max_age).await?;
91                        b.evaluate(&target_id, &expr, await_promise, timeout).await
92                    }
93                },
94            )
95            .await?
96        }
97        // Path 2: bare browser, no `--target` → scratch tab with
98        // recover-once. Engine-agnostic via TabBackend.
99        (None, None) => {
100            if matches!(resolved.source, Source::External) {
101                // External URL endpoints don't have a registered name to
102                // key the scratch row by. Fall back to the legacy direct
103                // attach so `eval ws://...` still works.
104                trace.route("direct");
105                let session =
106                    PageSession::attach(&resolved.endpoint, resolved.engine, None).await?;
107                session.ensure_fresh(max_age).await?;
108                let value = session
109                    .evaluate_with_timeout(&expression, await_promise, Some(timeout))
110                    .await;
111                session.close().await;
112                value?
113            } else {
114                trace.route("scratch");
115                let browser_name = match &resolved.source {
116                    Source::Registered { name } => name.clone(),
117                    _ => unreachable!("Source::External branch handled above"),
118                };
119                let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
120                let expr = expression.clone();
121                let value = with_scratch_recovery(
122                    &backend,
123                    &r.registry,
124                    &browser_name,
125                    move |b, target_id| {
126                        let expr = expr.clone();
127                        async move {
128                            b.ensure_fresh(&target_id, max_age).await?;
129                            b.evaluate(&target_id, &expr, await_promise, timeout).await
130                        }
131                    },
132                )
133                .await;
134                backend.shutdown().await;
135                value?
136            }
137        }
138        // Path 3: bare browser, --target regex → legacy selector.
139        (None, Some(regex)) => {
140            trace.route("target-regex");
141            let session =
142                PageSession::attach(&resolved.endpoint, resolved.engine, Some(&regex)).await?;
143            session.ensure_fresh(max_age).await?;
144            let value = session
145                .evaluate_with_timeout(&expression, await_promise, Some(timeout))
146                .await;
147            session.close().await;
148            value?
149        }
150        _ => unreachable!("mutex was checked above"),
151    };
152
153    println!("{}", format_output(&value, json));
154    Ok(())
155}
156
157fn format_output(v: &Value, json: bool) -> String {
158    if json {
159        serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
160    } else if let Some(s) = v.as_str() {
161        s.to_string()
162    } else {
163        serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use serde_json::json;
171
172    #[test]
173    fn eval_returns_string_unquoted_in_text_mode() {
174        let v = json!("hello");
175        assert_eq!(format_output(&v, false), "hello");
176    }
177
178    #[test]
179    fn eval_returns_number_as_json() {
180        let v = json!(42);
181        assert_eq!(format_output(&v, false), "42");
182    }
183
184    #[test]
185    fn eval_returns_json_envelope_when_json_flag() {
186        let v = json!({"a": 1});
187        let out = format_output(&v, true);
188        let parsed: Value = serde_json::from_str(&out).expect("valid JSON");
189        assert_eq!(parsed, v);
190        assert!(out.contains("\"a\""));
191    }
192
193    #[test]
194    fn eval_null_text_mode() {
195        let v = json!(null);
196        assert_eq!(format_output(&v, false), "null");
197    }
198
199    #[test]
200    fn eval_bool_text_mode() {
201        assert_eq!(format_output(&json!(true), false), "true");
202    }
203
204    use crate::detect::Engine;
205    use crate::session::PageSession;
206    use futures_util::{SinkExt, StreamExt};
207    use tokio_tungstenite::tungstenite::Message;
208
209    async fn spawn_cdp_mock(targets: Vec<Value>, eval_value: Value) -> String {
210        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
211        let addr = listener.local_addr().unwrap();
212        tokio::spawn(async move {
213            let (stream, _) = listener.accept().await.unwrap();
214            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
215            while let Some(Ok(Message::Text(t))) = ws.next().await {
216                let req: Value = serde_json::from_str(&t).unwrap();
217                let id = req["id"].as_u64().unwrap();
218                let method = req["method"].as_str().unwrap_or("");
219                let result = match method {
220                    "Target.getTargets" => json!({"targetInfos": targets.clone()}),
221                    "Target.attachToTarget" => json!({"sessionId": "S1"}),
222                    "Runtime.evaluate" => json!({"result": {"value": eval_value.clone()}}),
223                    _ => json!({}),
224                };
225                let resp = json!({"id": id, "result": result});
226                ws.send(Message::Text(resp.to_string())).await.unwrap();
227            }
228        });
229        format!("ws://{addr}")
230    }
231
232    #[tokio::test]
233    async fn eval_mock_returns_string() {
234        let url = spawn_cdp_mock(
235            vec![json!({"targetId":"a","type":"page","url":"https://example.com/"})],
236            json!("hello"),
237        )
238        .await;
239        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
240        let v = s.evaluate("'hello'", false).await.unwrap();
241        s.close().await;
242        assert_eq!(format_output(&v, false), "hello");
243    }
244
245    #[tokio::test]
246    async fn eval_mock_returns_object_json() {
247        let url = spawn_cdp_mock(
248            vec![json!({"targetId":"a","type":"page","url":"https://example.com/"})],
249            json!({"a": 1}),
250        )
251        .await;
252        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
253        let v = s.evaluate("({a:1})", false).await.unwrap();
254        s.close().await;
255        let out = format_output(&v, true);
256        let parsed: Value = serde_json::from_str(&out).unwrap();
257        assert_eq!(parsed, json!({"a": 1}));
258    }
259}