Skip to main content

browser_automation_cli/browser/
mod.rs

1//! One-shot browser session: launch-only + actions + reap (PR3–PR6).
2//!
3//! PROHIBITED on this path: ensure_daemon, connect_cdp, multi-process session.
4//! Refs live only inside this process; multi-step scripts share one session via `run`.
5//!
6//! Method-level docs are expanded over time; clap and skill surfaces document agent usage.
7#![allow(missing_docs)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use serde_json::{json, Value};
13use tokio::sync::broadcast;
14
15use crate::error::{CliError, ErrorKind};
16use crate::lifecycle::Lifecycle;
17use crate::native::browser::{BrowserManager, WaitUntil};
18use crate::native::cdp::chrome::LaunchOptions;
19use crate::native::cdp::types::CdpEvent;
20use crate::native::cookies;
21use crate::native::element::{self, RefMap};
22use crate::native::interaction;
23use crate::native::network;
24use crate::native::snapshot::{self, SnapshotOptions};
25
26/// Capture toggles for process-local console/network buffers.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct CaptureOpts {
29    pub console: bool,
30    pub network: bool,
31}
32
33/// Drop Chrome-internal schemes from capture-network (agent-ready envelope).
34fn is_internal_browser_url(url: &str) -> bool {
35    url.starts_with("chrome:")
36        || url.starts_with("chrome-extension:")
37        || url.starts_with("devtools:")
38}
39
40/// Drop non-document noise from capture-network (internal + data/blob embeds).
41fn is_noise_network_url(url: &str) -> bool {
42    is_internal_browser_url(url) || url.starts_with("data:") || url.starts_with("blob:")
43}
44
45/// Headless Chrome session owned by a single CLI invocation (or one `run` script).
46pub struct OneShotSession {
47    manager: BrowserManager,
48    ref_map: RefMap,
49    iframe_sessions: HashMap<String, String>,
50    chrome_pid: Option<u32>,
51    capture: CaptureOpts,
52    event_rx: broadcast::Receiver<CdpEvent>,
53    console_log: Vec<Value>,
54    network_log: Vec<Value>,
55    perf_active: bool,
56    screencast_active: bool,
57    heap_chunks: Vec<String>,
58    trace_chunks: Vec<String>,
59    /// Last written trace path from `perf stop` (for offline insight).
60    last_trace_path: Option<PathBuf>,
61    /// In-memory NDJSON of last trace (cleared after stop unless kept for insight).
62    last_trace_body: Option<String>,
63    /// PNG base64 frames from Page.screencastFrame.
64    screencast_frames: Vec<String>,
65    /// Output directory for screencast frames (set on start).
66    screencast_dir: Option<PathBuf>,
67    /// Pending screencast frame sessionIds awaiting ack.
68    screencast_ack_ids: Vec<i64>,
69    /// True while a JS dialog is open (alert/confirm/prompt).
70    dialog_open: bool,
71    /// HeapProfiler.reportHeapSnapshotProgress finished=true observed.
72    heap_snapshot_finished: bool,
73    /// Tracing.tracingComplete observed after perf stop.
74    tracing_complete: bool,
75}
76
77impl OneShotSession {
78    /// Launch local Chrome only (no connect, no daemon).
79    pub async fn launch_headless() -> Result<Self, CliError> {
80        Self::launch_headless_with_capture(CaptureOpts::default()).await
81    }
82
83    pub async fn launch_headless_with_capture(capture: CaptureOpts) -> Result<Self, CliError> {
84        let options = LaunchOptions {
85            headless: true,
86            hide_scrollbars: true,
87            ..LaunchOptions::default()
88        };
89        let manager = BrowserManager::launch(options, Some("chrome"))
90            .await
91            .map_err(|e| {
92                CliError::with_suggestion(
93                    ErrorKind::Unavailable,
94                    format!("Chrome launch failed: {e}"),
95                    "Install system Chrome/Chromium or set executable path; re-run doctor",
96                )
97            })?;
98        let chrome_pid = manager.chrome_pid();
99        let event_rx = manager.client.subscribe();
100
101        let mut session = Self {
102            manager,
103            ref_map: RefMap::new(),
104            iframe_sessions: HashMap::new(),
105            chrome_pid,
106            capture,
107            event_rx,
108            console_log: Vec::new(),
109            network_log: Vec::new(),
110            perf_active: false,
111            screencast_active: false,
112            heap_chunks: Vec::new(),
113            trace_chunks: Vec::new(),
114            last_trace_path: None,
115            last_trace_body: None,
116            screencast_frames: Vec::new(),
117            screencast_dir: None,
118            screencast_ack_ids: Vec::new(),
119            dialog_open: false,
120            heap_snapshot_finished: false,
121            tracing_complete: false,
122        };
123        session.enable_capture_domains().await?;
124        Ok(session)
125    }
126
127    /// Launch with Chrome extensions loaded (`--load-extension`).
128    pub async fn launch_with_extensions(
129        capture: CaptureOpts,
130        extensions: Vec<String>,
131    ) -> Result<Self, CliError> {
132        if extensions.is_empty() {
133            return Self::launch_headless_with_capture(capture).await;
134        }
135        for p in &extensions {
136            let path = Path::new(p);
137            if !path.exists() {
138                return Err(CliError::with_suggestion(
139                    ErrorKind::NoInput,
140                    format!("extension path not found: {p}"),
141                    "Pass an unpacked extension directory (contains manifest.json)",
142                ));
143            }
144        }
145        // Extensions require a non-headless Chrome product mode (chromiumoxide with_head).
146        // build_chrome_args also omits --headless=new when extensions are present.
147        let options = LaunchOptions {
148            headless: false,
149            hide_scrollbars: true,
150            extensions: Some(extensions),
151            ..LaunchOptions::default()
152        };
153        let manager = BrowserManager::launch(options, Some("chrome"))
154            .await
155            .map_err(|e| {
156                CliError::with_suggestion(
157                    ErrorKind::Unavailable,
158                    format!("Chrome launch with extensions failed: {e}"),
159                    "Use an unpacked extension dir; ensure Xvfb is available on Linux headed launches",
160                )
161            })?;
162        let chrome_pid = manager.chrome_pid();
163        let event_rx = manager.client.subscribe();
164        let mut session = Self {
165            manager,
166            ref_map: RefMap::new(),
167            iframe_sessions: HashMap::new(),
168            chrome_pid,
169            capture,
170            event_rx,
171            console_log: Vec::new(),
172            network_log: Vec::new(),
173            perf_active: false,
174            screencast_active: false,
175            heap_chunks: Vec::new(),
176            trace_chunks: Vec::new(),
177            last_trace_path: None,
178            last_trace_body: None,
179            screencast_frames: Vec::new(),
180            screencast_dir: None,
181            screencast_ack_ids: Vec::new(),
182            dialog_open: false,
183            heap_snapshot_finished: false,
184            tracing_complete: false,
185        };
186        session.enable_capture_domains().await?;
187        Ok(session)
188    }
189
190    pub fn chrome_pid(&self) -> Option<u32> {
191        self.chrome_pid
192    }
193
194    pub fn capture(&self) -> CaptureOpts {
195        self.capture
196    }
197
198    async fn enable_capture_domains(&mut self) -> Result<(), CliError> {
199        let session_id = self
200            .manager
201            .active_session_id()
202            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
203            .to_string();
204
205        // Always enable Page domain for dialogs/screencast and attach page-session listeners
206        // (heap chunks, screencast frames, JS dialogs are target-scoped).
207        let _ = self
208            .manager
209            .client
210            .send_command_no_params("Page.enable", Some(&session_id))
211            .await;
212        let _ = self.manager.client.attach_page_session_forwarders().await;
213
214        if self.capture.console {
215            self.manager
216                .client
217                .send_command_no_params("Runtime.enable", Some(&session_id))
218                .await
219                .map_err(|e| CliError::new(ErrorKind::Protocol, format!("Runtime.enable: {e}")))?;
220            // Page-level console listeners (context7): complements browser-level forwarder.
221            let _ = self.manager.client.attach_page_console_forwarders().await;
222        }
223        if self.capture.network {
224            self.manager
225                .client
226                .send_command_no_params("Network.enable", Some(&session_id))
227                .await
228                .map_err(|e| CliError::new(ErrorKind::Protocol, format!("Network.enable: {e}")))?;
229            // Also enable at browser scope (no session) when available.
230            let _ = self
231                .manager
232                .client
233                .send_command_no_params("Network.enable", None)
234                .await;
235            let _ = self.manager.client.attach_page_network_forwarders().await;
236        }
237        Ok(())
238    }
239
240    /// Merge console/network buffers into a result JSON when capture flags are on.
241    pub fn with_capture_fields(&mut self, mut data: Value) -> Value {
242        self.drain_events();
243        if let Some(obj) = data.as_object_mut() {
244            if self.capture.console {
245                obj.insert("console".to_string(), json!(self.console_log.clone()));
246                obj.insert("console_count".to_string(), json!(self.console_log.len()));
247            }
248            if self.capture.network {
249                obj.insert("network".to_string(), json!(self.network_log.clone()));
250                obj.insert("network_count".to_string(), json!(self.network_log.len()));
251            }
252        }
253        data
254    }
255
256    /// Drain pending CDP events into local buffers (non-blocking).
257    pub fn drain_events(&mut self) {
258        loop {
259            match self.event_rx.try_recv() {
260                Ok(evt) => self.ingest_event(evt),
261                Err(broadcast::error::TryRecvError::Empty) => break,
262                Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
263                Err(broadcast::error::TryRecvError::Closed) => break,
264            }
265        }
266    }
267
268    /// Drain events and ack screencast frames (required or Chrome stops sending).
269    pub async fn pump_events(&mut self) {
270        self.drain_events();
271        let acks: Vec<i64> = self.screencast_ack_ids.drain(..).collect();
272        if acks.is_empty() {
273            return;
274        }
275        let session_id = self.manager.active_session_id().ok().map(|s| s.to_string());
276        for sid in acks {
277            let _ = self
278                .manager
279                .client
280                .send_command(
281                    "Page.screencastFrameAck",
282                    Some(json!({ "sessionId": sid })),
283                    session_id.as_deref(),
284                )
285                .await;
286        }
287    }
288
289    fn ingest_event(&mut self, evt: CdpEvent) {
290        match evt.method.as_str() {
291            "Runtime.consoleAPICalled" if self.capture.console => {
292                let level = evt
293                    .params
294                    .get("type")
295                    .and_then(|v| v.as_str())
296                    .unwrap_or("log")
297                    .to_string();
298                let raw_args: Vec<Value> = evt
299                    .params
300                    .get("args")
301                    .and_then(|v| v.as_array())
302                    .cloned()
303                    .unwrap_or_default();
304                let text = network::format_console_args(&raw_args);
305                self.console_log.push(json!({
306                    "type": level,
307                    "text": text,
308                    "args": raw_args,
309                }));
310            }
311            "Network.requestWillBeSent" if self.capture.network => {
312                let request = evt.params.get("request").cloned().unwrap_or(Value::Null);
313                let method = request
314                    .get("method")
315                    .and_then(|v| v.as_str())
316                    .unwrap_or("GET");
317                let url = request.get("url").and_then(|v| v.as_str()).unwrap_or("");
318                if is_noise_network_url(url) {
319                    return;
320                }
321                let request_id = evt
322                    .params
323                    .get("requestId")
324                    .and_then(|v| v.as_str())
325                    .unwrap_or("");
326                self.network_log.push(json!({
327                    "requestId": request_id,
328                    "method": method,
329                    "url": url,
330                }));
331            }
332            "HeapProfiler.addHeapSnapshotChunk" => {
333                if let Some(chunk) = evt.params.get("chunk").and_then(|v| v.as_str()) {
334                    self.heap_chunks.push(chunk.to_string());
335                }
336            }
337            "HeapProfiler.reportHeapSnapshotProgress" => {
338                if evt
339                    .params
340                    .get("finished")
341                    .and_then(|v| v.as_bool())
342                    .unwrap_or(false)
343                {
344                    self.heap_snapshot_finished = true;
345                }
346            }
347            "Tracing.dataCollected" => {
348                if let Some(value) = evt.params.get("value") {
349                    // CDP sends an array of events; store as one NDJSON line (or expand).
350                    if let Some(arr) = value.as_array() {
351                        for item in arr {
352                            self.trace_chunks
353                                .push(serde_json::to_string(item).unwrap_or_default());
354                        }
355                    } else {
356                        self.trace_chunks
357                            .push(serde_json::to_string(value).unwrap_or_default());
358                    }
359                }
360            }
361            "Tracing.tracingComplete" => {
362                self.tracing_complete = true;
363            }
364            "Page.screencastFrame" => {
365                if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) {
366                    // Cap buffer to avoid unbounded memory in long screencasts.
367                    if self.screencast_frames.len() < 600 {
368                        self.screencast_frames.push(data.to_string());
369                    }
370                }
371                if let Some(sid) = evt.params.get("sessionId").and_then(|v| v.as_i64()) {
372                    self.screencast_ack_ids.push(sid);
373                }
374            }
375            "Page.javascriptDialogOpening" => {
376                self.dialog_open = true;
377            }
378            "Page.javascriptDialogClosed" => {
379                self.dialog_open = false;
380            }
381            _ => {}
382        }
383    }
384
385    /// Navigate and wait for load (same process). Honors robots when policy is Honor.
386    pub async fn goto(
387        &mut self,
388        url: &str,
389        robots: crate::robots::RobotsPolicy,
390    ) -> Result<Value, CliError> {
391        crate::robots::enforce_robots(url, robots, "browser-automation-cli").await?;
392        self.ref_map.clear();
393        self.manager
394            .navigate(url, WaitUntil::Load)
395            .await
396            .map_err(|e| {
397                CliError::with_suggestion(
398                    ErrorKind::Browser,
399                    format!("Navigation failed: {e}"),
400                    "Check URL scheme and network; try about:blank for smoke",
401                )
402            })?;
403        // Give console/network a brief window after load.
404        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
405        self.drain_events();
406        let page_url = self
407            .manager
408            .get_url()
409            .await
410            .unwrap_or_else(|_| url.to_string());
411        let title = self.manager.get_title().await.unwrap_or_default();
412        let data = json!({
413            "url": page_url,
414            "title": title,
415            "robots_policy": robots.as_str(),
416        });
417        Ok(self.with_capture_fields(data))
418    }
419
420    /// Minimal scrape: navigate, return source_url + body text + robots_policy.
421    pub async fn scrape(
422        &mut self,
423        url: &str,
424        robots: crate::robots::RobotsPolicy,
425    ) -> Result<Value, CliError> {
426        let nav = self.goto(url, robots).await?;
427        let text_val = self
428            .eval(
429                "String((document.body && document.body.innerText) || '')",
430                None,
431                Some("accept"),
432                None,
433            )
434            .await
435            .unwrap_or_else(|_| json!({"result": ""}));
436        let text_s = match text_val.get("result") {
437            Some(Value::String(s)) => s.clone(),
438            Some(other) => other.to_string(),
439            None => String::new(),
440        };
441        let text_s = if text_s.is_empty() {
442            String::new()
443        } else {
444            text_s
445        };
446        Ok(json!({
447            "source_url": nav.get("url").cloned().unwrap_or(Value::String(url.to_string())),
448            "title": nav.get("title").cloned().unwrap_or(Value::String(String::new())),
449            "robots_policy": robots.as_str(),
450            "text": text_s,
451        }))
452    }
453
454    /// Accessibility tree with agent-facing `@eN` refs.
455    pub async fn view(&mut self, verbose: bool) -> Result<Value, CliError> {
456        self.drain_events();
457        let session_id = self
458            .manager
459            .active_session_id()
460            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
461            .to_string();
462
463        let options = SnapshotOptions {
464            interactive: false,
465            compact: !verbose,
466            ..SnapshotOptions::default()
467        };
468
469        self.ref_map.clear();
470        let tree = snapshot::take_snapshot(
471            &self.manager.client,
472            &session_id,
473            &options,
474            &mut self.ref_map,
475            None,
476            &self.iframe_sessions,
477        )
478        .await
479        .map_err(|e| {
480            CliError::with_suggestion(
481                ErrorKind::Browser,
482                format!("view/snapshot failed: {e}"),
483                "Ensure the page finished loading; try goto then view in the same run",
484            )
485        })?;
486
487        let tree_at = tree_to_at_refs(&tree);
488        let url = self.manager.get_url().await.unwrap_or_default();
489        let title = self.manager.get_title().await.unwrap_or_default();
490
491        let entries = self.ref_map.entries_sorted();
492        let ref_count = entries.len();
493        let refs: serde_json::Map<String, Value> = entries
494            .into_iter()
495            .map(|(ref_id, entry)| {
496                let key = format!("@{ref_id}");
497                (
498                    key,
499                    json!({
500                        "role": entry.role,
501                        "name": entry.name,
502                        "id": ref_id,
503                    }),
504                )
505            })
506            .collect();
507
508        Ok(json!({
509            "tree": tree_at,
510            "url": url,
511            "title": title,
512            "refs": refs,
513            "ref_count": ref_count,
514        }))
515    }
516
517    /// Optionally attach a slim accessibility snapshot to a JSON result.
518    pub(crate) async fn attach_snapshot_if(
519        &mut self,
520        include: bool,
521        mut data: Value,
522    ) -> Result<Value, CliError> {
523        if !include {
524            return Ok(data);
525        }
526        let snap = self.view(false).await?;
527        if let Some(obj) = data.as_object_mut() {
528            obj.insert("snapshot".to_string(), snap);
529            obj.insert("include_snapshot".to_string(), json!(true));
530        }
531        Ok(data)
532    }
533
534    pub async fn press(
535        &mut self,
536        target: &str,
537        dblclick: bool,
538        include_snapshot: bool,
539    ) -> Result<Value, CliError> {
540        self.drain_events();
541        let session_id = self
542            .manager
543            .active_session_id()
544            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
545            .to_string();
546
547        let result = if dblclick {
548            interaction::dblclick(
549                &self.manager.client,
550                &session_id,
551                &self.ref_map,
552                target,
553                &self.iframe_sessions,
554            )
555            .await
556        } else {
557            interaction::click(
558                &self.manager.client,
559                &session_id,
560                &self.ref_map,
561                target,
562                "left",
563                1,
564                &self.iframe_sessions,
565            )
566            .await
567        }
568        .map_err(|e| {
569            CliError::with_suggestion(
570                ErrorKind::Browser,
571                format!("press failed: {e}"),
572                "Use a CSS selector or @eN from view in the same process (run script)",
573            )
574        })?;
575
576        self.drain_events();
577        let data = json!({
578            "pressed": target,
579            "dblclick": dblclick,
580            "dialog_opened": result.dialog_opened,
581        });
582        self.attach_snapshot_if(include_snapshot, data).await
583    }
584
585    pub async fn write(
586        &mut self,
587        target: &str,
588        value: &str,
589        include_snapshot: bool,
590    ) -> Result<Value, CliError> {
591        self.drain_events();
592        let session_id = self
593            .manager
594            .active_session_id()
595            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
596            .to_string();
597
598        interaction::fill_smart(
599            &self.manager.client,
600            &session_id,
601            &self.ref_map,
602            target,
603            value,
604            &self.iframe_sessions,
605        )
606        .await
607        .map_err(|e| {
608            CliError::with_suggestion(
609                ErrorKind::Browser,
610                format!("write failed: {e}"),
611                "Use a CSS selector or @eN from view; select/checkbox/radio use fill_smart semantics",
612            )
613        })?;
614
615        self.drain_events();
616        let data = json!({
617            "written": target,
618            "value_len": value.len(),
619            "fill_mode": "smart",
620        });
621        self.attach_snapshot_if(include_snapshot, data).await
622    }
623
624    /// Click at absolute page coordinates (requires experimental vision flag at CLI).
625    pub async fn click_at(
626        &mut self,
627        x: f64,
628        y: f64,
629        dblclick: bool,
630        include_snapshot: bool,
631    ) -> Result<Value, CliError> {
632        self.drain_events();
633        let session_id = self
634            .manager
635            .active_session_id()
636            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
637            .to_string();
638        let result = interaction::click_at(&self.manager.client, &session_id, x, y, dblclick)
639            .await
640            .map_err(|e| {
641                CliError::with_suggestion(
642                    ErrorKind::Browser,
643                    format!("click-at failed: {e}"),
644                    "Coordinates are page CSS pixels; enable --experimental-vision",
645                )
646            })?;
647        self.drain_events();
648        let data = json!({
649            "clicked_at": { "x": x, "y": y },
650            "dblclick": dblclick,
651            "dialog_opened": result.dialog_opened,
652        });
653        self.attach_snapshot_if(include_snapshot, data).await
654    }
655
656    pub async fn keys(&mut self, key: &str, include_snapshot: bool) -> Result<Value, CliError> {
657        let session_id = self
658            .manager
659            .active_session_id()
660            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
661            .to_string();
662
663        interaction::press_key(&self.manager.client, &session_id, key)
664            .await
665            .map_err(|e| {
666                CliError::with_suggestion(
667                    ErrorKind::Browser,
668                    format!("keys failed: {e}"),
669                    "Pass a CDP key name such as Enter, Tab, Escape, or ArrowDown",
670                )
671            })?;
672
673        self.drain_events();
674        let data = json!({ "key": key });
675        self.attach_snapshot_if(include_snapshot, data).await
676    }
677
678    pub async fn type_text(
679        &mut self,
680        target: Option<&str>,
681        text: &str,
682        clear: bool,
683        submit: Option<&str>,
684        focus_only: bool,
685    ) -> Result<Value, CliError> {
686        let session_id = self
687            .manager
688            .active_session_id()
689            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
690            .to_string();
691
692        let typed_target = if focus_only || target.is_none() {
693            // tool-ref type_text: type into currently focused element
694            if clear {
695                // Select-all then type (best-effort clear of focused field)
696                let _ =
697                    interaction::press_key(&self.manager.client, &session_id, "Control+a").await;
698                let _ =
699                    interaction::press_key(&self.manager.client, &session_id, "Backspace").await;
700            }
701            interaction::type_text_into_active_context(
702                &self.manager.client,
703                &session_id,
704                text,
705                None,
706            )
707            .await
708            .map_err(|e| {
709                CliError::with_suggestion(
710                    ErrorKind::Browser,
711                    format!("type (focus-only) failed: {e}"),
712                    "Focus an input first or pass a CSS/@eN target",
713                )
714            })?;
715            target.unwrap_or("(focused)").to_string()
716        } else {
717            let t = match target {
718                Some(s) => s,
719                None => {
720                    return Err(CliError::new(
721                        ErrorKind::Usage,
722                        "type requires --target or --focus-only",
723                    ));
724                }
725            };
726            interaction::type_text(
727                &self.manager.client,
728                &session_id,
729                &self.ref_map,
730                t,
731                text,
732                clear,
733                None,
734                &self.iframe_sessions,
735            )
736            .await
737            .map_err(|e| {
738                CliError::with_suggestion(
739                    ErrorKind::Browser,
740                    format!("type failed: {e}"),
741                    "Use a CSS selector or @eN from view in the same process",
742                )
743            })?;
744            t.to_string()
745        };
746
747        if let Some(key) = submit {
748            interaction::press_key(&self.manager.client, &session_id, key)
749                .await
750                .map_err(|e| {
751                    CliError::with_suggestion(
752                        ErrorKind::Browser,
753                        format!("type --submit key failed: {e}"),
754                        "Pass a CDP key such as Enter",
755                    )
756                })?;
757        }
758
759        self.drain_events();
760        Ok(json!({
761            "typed": typed_target,
762            "text_len": text.len(),
763            "cleared": clear,
764            "submit": submit,
765            "focus_only": focus_only || target.is_none(),
766        }))
767    }
768
769    pub async fn eval(
770        &mut self,
771        expression: &str,
772        args_json: Option<&str>,
773        dialog_action: Option<&str>,
774        file_path: Option<&Path>,
775    ) -> Result<Value, CliError> {
776        use crate::native::cdp::types::{EvaluateParams, EvaluateResult};
777
778        // dialogAction: accept | dismiss | prompt text (default accept)
779        let action = dialog_action.unwrap_or("accept");
780        let _ = action; // applied via auto-accept path below; dismiss handled when needed
781        let session_id = self
782            .manager
783            .active_session_id()
784            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
785            .to_string();
786
787        // Build expression: call bare functions once; never re-invoke IIFEs.
788        // Bug: wrapping `(() => 1)()` as `((() => 1)())()` yields "is not a function".
789        let expr = normalize_eval_expression(expression, args_json)?;
790
791        let client = std::sync::Arc::clone(&self.manager.client);
792        let expr_owned = expr.clone();
793        let mut eval_fut = Box::pin(async move {
794            let result: EvaluateResult = client
795                .send_command_typed(
796                    "Runtime.evaluate",
797                    &EvaluateParams {
798                        expression: expr_owned,
799                        return_by_value: Some(true),
800                        await_promise: Some(true),
801                    },
802                    Some(&session_id),
803                )
804                .await?;
805            if let Some(ref details) = result.exception_details {
806                let msg = details
807                    .exception
808                    .as_ref()
809                    .and_then(|e| e.description.as_deref())
810                    .unwrap_or(&details.text);
811                return Err(format!("Evaluation error: {msg}"));
812            }
813            Ok(result.result.value.unwrap_or(Value::Null))
814        });
815        let v = loop {
816            tokio::select! {
817                res = &mut eval_fut => {
818                    break res.map_err(|e| {
819                        CliError::with_suggestion(
820                            ErrorKind::Browser,
821                            format!("eval failed: {e}"),
822                            "Check the JS expression; use return-by-value expressions",
823                        )
824                    })?;
825                }
826                _ = tokio::time::sleep(std::time::Duration::from_millis(40)) => {
827                    self.drain_events();
828                    if self.dialog_open {
829                        let accept = !action.eq_ignore_ascii_case("dismiss");
830                        let prompt = if action.eq_ignore_ascii_case("accept")
831                            || action.eq_ignore_ascii_case("dismiss")
832                        {
833                            None
834                        } else {
835                            Some(action)
836                        };
837                        let _ = self.manager.handle_dialog(accept, prompt).await;
838                        self.dialog_open = false;
839                    }
840                }
841            }
842        };
843        // Allow consoleAPICalled events to land after evaluate.
844        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
845        self.drain_events();
846        let data = self.with_capture_fields(json!({ "result": v }));
847        if let Some(path) = file_path {
848            let body = serde_json::to_vec_pretty(&data).map_err(|e| {
849                CliError::new(ErrorKind::Io, format!("eval serialize for file: {e}"))
850            })?;
851            std::fs::write(path, body).map_err(|e| {
852                CliError::new(ErrorKind::Io, format!("eval write {}: {e}", path.display()))
853            })?;
854        }
855        Ok(data)
856    }
857
858    pub async fn wait_ms(&mut self, ms: u64) -> Result<Value, CliError> {
859        // Pump in slices so screencast FrameAck keeps frames flowing during waits.
860        let mut remaining = ms;
861        while remaining > 0 {
862            let slice = remaining.min(50);
863            tokio::time::sleep(std::time::Duration::from_millis(slice)).await;
864            remaining = remaining.saturating_sub(slice);
865            if self.screencast_active {
866                self.pump_events().await;
867            } else {
868                self.drain_events();
869            }
870        }
871        Ok(json!({ "waited_ms": ms }))
872    }
873
874    pub async fn grab(
875        &mut self,
876        path: Option<&Path>,
877        format: &str,
878        full_page: bool,
879        quality: Option<i32>,
880        element: Option<&str>,
881    ) -> Result<Value, CliError> {
882        use crate::native::screenshot::{take_screenshot, ScreenshotOptions};
883
884        let session_id = self
885            .manager
886            .active_session_id()
887            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
888            .to_string();
889
890        let out_path = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
891            let stamp = std::time::SystemTime::now()
892                .duration_since(std::time::UNIX_EPOCH)
893                .map(|d| d.as_millis())
894                .unwrap_or(0);
895            std::path::PathBuf::from(format!("grab-{stamp}.{format}"))
896        });
897
898        let options = ScreenshotOptions {
899            path: Some(out_path.to_string_lossy().into_owned()),
900            format: format.to_string(),
901            full_page,
902            quality,
903            selector: element.map(|s| s.to_string()),
904            ..ScreenshotOptions::default()
905        };
906
907        let result = take_screenshot(
908            &self.manager.client,
909            &session_id,
910            &self.ref_map,
911            &options,
912            &self.iframe_sessions,
913        )
914        .await
915        .map_err(|e| {
916            CliError::with_suggestion(
917                ErrorKind::Browser,
918                format!("grab failed: {e}"),
919                "Ensure page is loaded; check write permissions for path",
920            )
921        })?;
922
923        let path_str = if result.path.is_empty() {
924            out_path.to_string_lossy().into_owned()
925        } else {
926            result.path
927        };
928        let path_buf = std::path::PathBuf::from(&path_str);
929        let written = path_buf.exists();
930        let magic_ok = written && verify_image_magic(&path_buf, format);
931        let byte_size = std::fs::metadata(&path_buf).map(|m| m.len()).unwrap_or(0);
932
933        Ok(json!({
934            "path": path_str,
935            "format": format,
936            "written": written,
937            "magic_ok": magic_ok,
938            "byte_size": byte_size,
939            "full_page": full_page,
940            "quality": quality,
941            "element": element,
942        }))
943    }
944
945    // --- Camada B ---
946
947    pub async fn extract(&mut self, target: &str, attr: Option<&str>) -> Result<Value, CliError> {
948        self.drain_events();
949        let session_id = self
950            .manager
951            .active_session_id()
952            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
953            .to_string();
954
955        if let Some(name) = attr {
956            let v = element::get_element_attribute(
957                &self.manager.client,
958                &session_id,
959                &self.ref_map,
960                target,
961                name,
962                &self.iframe_sessions,
963            )
964            .await
965            .map_err(|e| {
966                CliError::with_suggestion(
967                    ErrorKind::Browser,
968                    format!("extract attr failed: {e}"),
969                    "Use --ref @eN from view in the same run",
970                )
971            })?;
972            Ok(json!({ "target": target, "attr": name, "value": v }))
973        } else {
974            let text = element::get_element_text(
975                &self.manager.client,
976                &session_id,
977                &self.ref_map,
978                target,
979                &self.iframe_sessions,
980            )
981            .await
982            .map_err(|e| {
983                CliError::with_suggestion(
984                    ErrorKind::Browser,
985                    format!("extract text failed: {e}"),
986                    "Use --ref @eN from view in the same run",
987                )
988            })?;
989            Ok(json!({ "target": target, "text": text }))
990        }
991    }
992
993    pub async fn attr(&mut self, target: &str, name: &str) -> Result<Value, CliError> {
994        self.extract(target, Some(name)).await
995    }
996
997    /// PRD §7 `text`: extract visible text from a target.
998    pub async fn text(&mut self, target: &str) -> Result<Value, CliError> {
999        self.extract(target, None).await
1000    }
1001
1002    /// PRD §7 `scroll`: scroll window or element by delta pixels.
1003    pub async fn scroll(
1004        &mut self,
1005        target: Option<&str>,
1006        delta_x: f64,
1007        delta_y: f64,
1008    ) -> Result<Value, CliError> {
1009        self.drain_events();
1010        let session_id = self
1011            .manager
1012            .active_session_id()
1013            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1014            .to_string();
1015        interaction::scroll(
1016            &self.manager.client,
1017            &session_id,
1018            &self.ref_map,
1019            target,
1020            delta_x,
1021            delta_y,
1022            &self.iframe_sessions,
1023        )
1024        .await
1025        .map_err(|e| {
1026            CliError::with_suggestion(
1027                ErrorKind::Browser,
1028                format!("scroll failed: {e}"),
1029                "Pass --target @eN from view, or omit target for window scroll",
1030            )
1031        })?;
1032        Ok(json!({
1033            "ok": true,
1034            "target": target,
1035            "delta_x": delta_x,
1036            "delta_y": delta_y,
1037        }))
1038    }
1039
1040    pub async fn cookie_list(&mut self, url: Option<&str>) -> Result<Value, CliError> {
1041        self.drain_events();
1042        let session_id = self
1043            .manager
1044            .active_session_id()
1045            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1046            .to_string();
1047        let cookies = if let Some(u) = url {
1048            cookies::get_cookies(&self.manager.client, &session_id, Some(vec![u.to_string()])).await
1049        } else {
1050            cookies::get_all_cookies(&self.manager.client, &session_id).await
1051        }
1052        .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie list failed: {e}")))?;
1053        Ok(json!({
1054            "cookies": cookies,
1055            "count": cookies.len(),
1056            "url_filter": url,
1057        }))
1058    }
1059
1060    pub async fn cookie_set(&mut self, cookies_json: &str) -> Result<Value, CliError> {
1061        self.drain_events();
1062        let session_id = self
1063            .manager
1064            .active_session_id()
1065            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1066            .to_string();
1067        let parsed: Value = serde_json::from_str(cookies_json).map_err(|e| {
1068            CliError::with_suggestion(
1069                ErrorKind::Usage,
1070                format!("cookie set JSON invalid: {e}"),
1071                r#"Use --json '[{"name":"a","value":"b","url":"https://example.com"}]'"#,
1072            )
1073        })?;
1074        let arr = parsed.as_array().ok_or_else(|| {
1075            CliError::with_suggestion(
1076                ErrorKind::Usage,
1077                "cookie set requires a JSON array",
1078                r#"Use --json '[{"name":"a","value":"b","url":"https://example.com"}]'"#,
1079            )
1080        })?;
1081        let current_url = self.manager.get_url().await.ok();
1082        cookies::set_cookies(
1083            &self.manager.client,
1084            &session_id,
1085            arr.clone(),
1086            current_url.as_deref(),
1087        )
1088        .await
1089        .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie set failed: {e}")))?;
1090        Ok(json!({ "ok": true, "set_count": arr.len() }))
1091    }
1092
1093    pub async fn cookie_clear(&mut self) -> Result<Value, CliError> {
1094        self.drain_events();
1095        let session_id = self
1096            .manager
1097            .active_session_id()
1098            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1099            .to_string();
1100        cookies::clear_cookies(&self.manager.client, &session_id)
1101            .await
1102            .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie clear failed: {e}")))?;
1103        Ok(json!({ "ok": true, "cleared": true }))
1104    }
1105
1106    pub async fn page_info(&mut self) -> Result<Value, CliError> {
1107        self.drain_events();
1108        let url = self.manager.get_url().await.unwrap_or_default();
1109        let title = self.manager.get_title().await.unwrap_or_default();
1110        Ok(json!({ "url": url, "title": title }))
1111    }
1112
1113    pub async fn assert_url(&mut self, value: &str, contains: bool) -> Result<Value, CliError> {
1114        self.drain_events();
1115        let url = self.manager.get_url().await.unwrap_or_default();
1116        let ok = if contains {
1117            url.contains(value)
1118        } else {
1119            url == value
1120        };
1121        if !ok {
1122            return Err(CliError::with_suggestion(
1123                ErrorKind::Data,
1124                format!(
1125                    "assert url failed: got={url:?} expected contains={contains} value={value:?}"
1126                ),
1127                "Navigate first with goto in the same run",
1128            ));
1129        }
1130        Ok(json!({ "assert": "url", "ok": true, "url": url, "value": value, "contains": contains }))
1131    }
1132
1133    pub async fn assert_text(
1134        &mut self,
1135        value: &str,
1136        target: Option<&str>,
1137    ) -> Result<Value, CliError> {
1138        self.drain_events();
1139        let haystack = if let Some(t) = target {
1140            let session_id = self
1141                .manager
1142                .active_session_id()
1143                .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1144                .to_string();
1145            element::get_element_text(
1146                &self.manager.client,
1147                &session_id,
1148                &self.ref_map,
1149                t,
1150                &self.iframe_sessions,
1151            )
1152            .await
1153            .map_err(|e| CliError::new(ErrorKind::Browser, format!("assert text: {e}")))?
1154        } else {
1155            let v = self
1156                .manager
1157                .evaluate("document.body ? document.body.innerText : ''", None)
1158                .await
1159                .map_err(|e| CliError::new(ErrorKind::Browser, format!("assert text: {e}")))?;
1160            v.as_str().unwrap_or("").to_string()
1161        };
1162
1163        if !haystack.contains(value) {
1164            return Err(CliError::with_suggestion(
1165                ErrorKind::Data,
1166                format!("assert text failed: value not found: {value:?}"),
1167                "Check view/extract in the same run; text match is substring",
1168            ));
1169        }
1170        Ok(json!({ "assert": "text", "ok": true, "value": value, "target": target }))
1171    }
1172
1173    pub async fn assert_console(&mut self, level: &str, max: u64) -> Result<Value, CliError> {
1174        if !self.capture.console {
1175            return Err(CliError::with_suggestion(
1176                ErrorKind::Usage,
1177                "assert console requires --capture-console on the same invocation",
1178                "browser-automation-cli --capture-console run --script audit.jsonl",
1179            ));
1180        }
1181        self.drain_events();
1182        let level_l = level.to_ascii_lowercase();
1183        let count = self
1184            .console_log
1185            .iter()
1186            .filter(|m| {
1187                m.get("type")
1188                    .and_then(|v| v.as_str())
1189                    .map(|t| t.eq_ignore_ascii_case(&level_l))
1190                    .unwrap_or(false)
1191            })
1192            .count() as u64;
1193        if count > max {
1194            return Err(CliError::with_suggestion(
1195                ErrorKind::Data,
1196                format!("assert console failed: level={level} count={count} max={max}"),
1197                "Fix page console noise or raise --max",
1198            ));
1199        }
1200        Ok(json!({
1201            "assert": "console",
1202            "ok": true,
1203            "level": level,
1204            "count": count,
1205            "max": max,
1206        }))
1207    }
1208
1209    pub fn console_list(
1210        &mut self,
1211        page_idx: Option<usize>,
1212        page_size: Option<usize>,
1213        types: Option<&str>,
1214        include_preserved: bool,
1215        service_worker_id: Option<&str>,
1216    ) -> Result<Value, CliError> {
1217        if !self.capture.console {
1218            return Err(CliError::with_suggestion(
1219                ErrorKind::Usage,
1220                "console list requires --capture-console",
1221                "Pass --capture-console before run/console",
1222            ));
1223        }
1224        self.drain_events();
1225        let mut messages: Vec<Value> = self.console_log.clone();
1226        let _ = include_preserved; // one-shot: log is process-local; flag accepted for tool-ref parity
1227        if let Some(types_csv) = types {
1228            let wanted: Vec<String> = types_csv
1229                .split(',')
1230                .map(|s| s.trim().to_ascii_lowercase())
1231                .filter(|s| !s.is_empty())
1232                .collect();
1233            if !wanted.is_empty() {
1234                messages.retain(|m| {
1235                    let level = m
1236                        .get("level")
1237                        .or_else(|| m.get("type"))
1238                        .and_then(|v| v.as_str())
1239                        .unwrap_or("")
1240                        .to_ascii_lowercase();
1241                    wanted.iter().any(|w| level.contains(w))
1242                });
1243            }
1244        }
1245        if let Some(sw) = service_worker_id {
1246            messages.retain(|m| {
1247                m.get("service_worker_id")
1248                    .and_then(|v| v.as_str())
1249                    .map(|s| s == sw)
1250                    .unwrap_or(false)
1251            });
1252        }
1253        let total = messages.len();
1254        let page = page_idx.unwrap_or(0);
1255        let size = page_size.unwrap_or(total.max(1));
1256        let start = page.saturating_mul(size).min(total);
1257        let end = (start + size).min(total);
1258        let page_msgs = messages[start..end].to_vec();
1259        Ok(json!({
1260            "messages": page_msgs,
1261            "count": page_msgs.len(),
1262            "total": total,
1263            "page_idx": page,
1264            "page_size": size,
1265        }))
1266    }
1267
1268    pub fn console_get(&mut self, id: usize) -> Result<Value, CliError> {
1269        // Full unpaginated list for get-by-id
1270        let list = self.console_list(None, None, None, true, None)?;
1271        let total = list.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1272        // Prefer original buffer for stable ids
1273        self.drain_events();
1274        self.console_log
1275            .get(id)
1276            .cloned()
1277            .map(|m| json!({ "id": id, "message": m }))
1278            .ok_or_else(|| {
1279                CliError::with_suggestion(
1280                    ErrorKind::Data,
1281                    format!("console message id {id} not found (count={total})"),
1282                    "Use console list to inspect ids (0-based index)",
1283                )
1284            })
1285    }
1286
1287    pub fn console_clear(&mut self) -> Result<Value, CliError> {
1288        if !self.capture.console {
1289            return Err(CliError::with_suggestion(
1290                ErrorKind::Usage,
1291                "console clear requires --capture-console",
1292                "Pass --capture-console before run/console",
1293            ));
1294        }
1295        self.drain_events();
1296        let n = self.console_log.len();
1297        self.console_log.clear();
1298        Ok(json!({ "cleared": n }))
1299    }
1300
1301    pub fn console_dump(&mut self, path: &Path) -> Result<Value, CliError> {
1302        let data = self.console_list(None, None, None, true, None)?;
1303        // Dump full buffer, not just first page
1304        let messages = self.console_log.clone();
1305        let _ = data;
1306        let mut body = String::new();
1307        for m in &messages {
1308            body.push_str(&serde_json::to_string(m).unwrap_or_default());
1309            body.push('\n');
1310        }
1311        if let Some(parent) = path.parent() {
1312            if !parent.as_os_str().is_empty() {
1313                std::fs::create_dir_all(parent).map_err(|e| {
1314                    CliError::new(ErrorKind::Io, format!("console dump mkdir: {e}"))
1315                })?;
1316            }
1317        }
1318        std::fs::write(path, body.as_bytes())
1319            .map_err(|e| CliError::new(ErrorKind::Io, format!("console dump write: {e}")))?;
1320        Ok(json!({
1321            "path": path.to_string_lossy(),
1322            "count": messages.len(),
1323        }))
1324    }
1325
1326    pub fn net_list(
1327        &mut self,
1328        page_idx: Option<usize>,
1329        page_size: Option<usize>,
1330        resource_types: Option<&str>,
1331        include_preserved: bool,
1332    ) -> Result<Value, CliError> {
1333        if !self.capture.network {
1334            return Err(CliError::with_suggestion(
1335                ErrorKind::Usage,
1336                "net list requires --capture-network",
1337                "Pass --capture-network before run/net",
1338            ));
1339        }
1340        self.drain_events();
1341        let mut requests: Vec<Value> = self.network_log.clone();
1342        let _ = include_preserved; // process-local buffer; accepted for tool-ref parity
1343        if let Some(types_csv) = resource_types {
1344            let wanted: Vec<String> = types_csv
1345                .split(',')
1346                .map(|s| s.trim().to_ascii_lowercase())
1347                .filter(|s| !s.is_empty())
1348                .collect();
1349            if !wanted.is_empty() {
1350                requests.retain(|r| {
1351                    let rt = r
1352                        .get("resource_type")
1353                        .or_else(|| r.get("type"))
1354                        .or_else(|| r.get("resourceType"))
1355                        .and_then(|v| v.as_str())
1356                        .unwrap_or("")
1357                        .to_ascii_lowercase();
1358                    wanted.iter().any(|w| rt.contains(w))
1359                });
1360            }
1361        }
1362        let total = requests.len();
1363        let page = page_idx.unwrap_or(0);
1364        let size = page_size.unwrap_or(total.max(1));
1365        let start = page.saturating_mul(size).min(total);
1366        let end = (start + size).min(total);
1367        let page_reqs = requests[start..end].to_vec();
1368        Ok(json!({
1369            "requests": page_reqs,
1370            "count": page_reqs.len(),
1371            "total": total,
1372            "page_idx": page,
1373            "page_size": size,
1374        }))
1375    }
1376
1377    /// Resolve a network entry by 0-based index or CDP `requestId` string.
1378    pub fn net_get(
1379        &mut self,
1380        id: &str,
1381        request_path: Option<&Path>,
1382        response_path: Option<&Path>,
1383    ) -> Result<Value, CliError> {
1384        let _ = self.net_list(None, None, None, true)?;
1385        let requests = self.network_log.clone();
1386        let (index, req) = if let Ok(idx) = id.parse::<usize>() {
1387            let req = requests.get(idx).cloned().ok_or_else(|| {
1388                CliError::with_suggestion(
1389                    ErrorKind::Data,
1390                    format!(
1391                        "network request index {idx} not found (count={})",
1392                        requests.len()
1393                    ),
1394                    "Use net list; pass 0-based index or requestId string",
1395                )
1396            })?;
1397            (idx, req)
1398        } else {
1399            let (idx, req) = requests
1400                .iter()
1401                .enumerate()
1402                .find(|(_, r)| {
1403                    r.get("requestId")
1404                        .and_then(|v| v.as_str())
1405                        .map(|rid| rid == id)
1406                        .unwrap_or(false)
1407                })
1408                .map(|(i, r)| (i, r.clone()))
1409                .ok_or_else(|| {
1410                    CliError::with_suggestion(
1411                        ErrorKind::Data,
1412                        format!(
1413                            "network requestId {id} not found (count={})",
1414                            requests.len()
1415                        ),
1416                        "Use net list; pass 0-based index or exact requestId",
1417                    )
1418                })?;
1419            (idx, req)
1420        };
1421        let request_id = req
1422            .get("requestId")
1423            .and_then(|v| v.as_str())
1424            .unwrap_or("")
1425            .to_string();
1426        if let Some(p) = request_path {
1427            if let Some(parent) = p.parent() {
1428                if !parent.as_os_str().is_empty() {
1429                    std::fs::create_dir_all(parent).map_err(|e| {
1430                        CliError::new(ErrorKind::Io, format!("net get request-path mkdir: {e}"))
1431                    })?;
1432                }
1433            }
1434            let body = serde_json::to_vec_pretty(&req)
1435                .map_err(|e| CliError::new(ErrorKind::Io, format!("net get serialize: {e}")))?;
1436            std::fs::write(p, body)
1437                .map_err(|e| CliError::new(ErrorKind::Io, format!("net get request-path: {e}")))?;
1438        }
1439        if let Some(p) = response_path {
1440            if let Some(parent) = p.parent() {
1441                if !parent.as_os_str().is_empty() {
1442                    std::fs::create_dir_all(parent).map_err(|e| {
1443                        CliError::new(ErrorKind::Io, format!("net get response-path mkdir: {e}"))
1444                    })?;
1445                }
1446            }
1447            let body = serde_json::to_vec_pretty(&req)
1448                .map_err(|e| CliError::new(ErrorKind::Io, format!("net get serialize: {e}")))?;
1449            std::fs::write(p, body)
1450                .map_err(|e| CliError::new(ErrorKind::Io, format!("net get response-path: {e}")))?;
1451        }
1452        Ok(json!({
1453            "id": index,
1454            "requestId": request_id,
1455            "request": req,
1456            "request_path": request_path.map(|p| p.to_string_lossy().to_string()),
1457            "response_path": response_path.map(|p| p.to_string_lossy().to_string()),
1458        }))
1459    }
1460
1461    pub async fn dialog(
1462        &mut self,
1463        accept: bool,
1464        prompt_text: Option<&str>,
1465    ) -> Result<Value, CliError> {
1466        self.manager
1467            .handle_dialog(accept, prompt_text)
1468            .await
1469            .map_err(|e| {
1470                CliError::with_suggestion(
1471                    ErrorKind::Browser,
1472                    format!("dialog failed: {e}"),
1473                    "Dialog must be open; use after press that triggers alert/confirm/prompt",
1474                )
1475            })?;
1476        Ok(json!({
1477            "dialog": if accept { "accept" } else { "dismiss" },
1478            "prompt_text": prompt_text,
1479        }))
1480    }
1481
1482    pub async fn hover(&mut self, target: &str, include_snapshot: bool) -> Result<Value, CliError> {
1483        self.drain_events();
1484        let session_id = self
1485            .manager
1486            .active_session_id()
1487            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1488            .to_string();
1489        interaction::hover(
1490            &self.manager.client,
1491            &session_id,
1492            &self.ref_map,
1493            target,
1494            &self.iframe_sessions,
1495        )
1496        .await
1497        .map_err(|e| {
1498            CliError::with_suggestion(
1499                ErrorKind::Browser,
1500                format!("hover failed: {e}"),
1501                "Use a CSS selector or @eN from view in the same process (run script)",
1502            )
1503        })?;
1504        self.drain_events();
1505        let data = json!({ "hovered": target });
1506        self.attach_snapshot_if(include_snapshot, data).await
1507    }
1508
1509    pub async fn drag(
1510        &mut self,
1511        from: &str,
1512        to: &str,
1513        include_snapshot: bool,
1514    ) -> Result<Value, CliError> {
1515        self.drain_events();
1516        let session_id = self
1517            .manager
1518            .active_session_id()
1519            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1520            .to_string();
1521        interaction::drag(
1522            &self.manager.client,
1523            &session_id,
1524            &self.ref_map,
1525            from,
1526            to,
1527            &self.iframe_sessions,
1528        )
1529        .await
1530        .map_err(|e| {
1531            CliError::with_suggestion(
1532                ErrorKind::Browser,
1533                format!("drag failed: {e}"),
1534                "Use two CSS selectors or @eN refs in the same frame",
1535            )
1536        })?;
1537        self.drain_events();
1538        let data = json!({ "dragged_from": from, "dragged_to": to });
1539        self.attach_snapshot_if(include_snapshot, data).await
1540    }
1541
1542    pub async fn fill_form(
1543        &mut self,
1544        fields: &[(String, String)],
1545        include_snapshot: bool,
1546    ) -> Result<Value, CliError> {
1547        let mut filled = Vec::new();
1548        for (target, value) in fields {
1549            self.write(target, value, false).await?;
1550            filled.push(json!({ "target": target, "value_len": value.len() }));
1551        }
1552        let data = json!({ "filled": filled, "count": filled.len() });
1553        self.attach_snapshot_if(include_snapshot, data).await
1554    }
1555
1556    pub async fn upload(
1557        &mut self,
1558        target: &str,
1559        path: &Path,
1560        include_snapshot: bool,
1561    ) -> Result<Value, CliError> {
1562        self.drain_events();
1563        if !path.is_file() {
1564            return Err(CliError::with_suggestion(
1565                ErrorKind::Usage,
1566                format!("upload path is not a regular file: {}", path.display()),
1567                "Pass a single regular file path (not a directory)",
1568            ));
1569        }
1570        let abs = path
1571            .canonicalize()
1572            .map_err(|e| CliError::new(ErrorKind::Io, format!("upload canonicalize: {e}")))?;
1573        self.manager
1574            .upload_files(
1575                target,
1576                &[abs.to_string_lossy().to_string()],
1577                &self.ref_map,
1578                &self.iframe_sessions,
1579            )
1580            .await
1581            .map_err(|e| {
1582                CliError::with_suggestion(
1583                    ErrorKind::Browser,
1584                    format!("upload failed: {e}"),
1585                    "Target must be a file input; use CSS selector or @eN",
1586                )
1587            })?;
1588        self.drain_events();
1589        let data = json!({
1590            "uploaded": target,
1591            "path": abs.to_string_lossy(),
1592        });
1593        self.attach_snapshot_if(include_snapshot, data).await
1594    }
1595
1596    pub async fn back(&mut self) -> Result<Value, CliError> {
1597        self.history_nav("back").await
1598    }
1599
1600    pub async fn forward(&mut self) -> Result<Value, CliError> {
1601        self.history_nav("forward").await
1602    }
1603
1604    pub async fn reload(&mut self, ignore_cache: bool) -> Result<Value, CliError> {
1605        self.drain_events();
1606        let script = if ignore_cache {
1607            "location.reload(true); 'ok'"
1608        } else {
1609            "location.reload(); 'ok'"
1610        };
1611        self.manager
1612            .evaluate(script, None)
1613            .await
1614            .map_err(|e| CliError::new(ErrorKind::Browser, format!("reload failed: {e}")))?;
1615        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1616        self.drain_events();
1617        let url = self.manager.get_url().await.unwrap_or_default();
1618        let title = self.manager.get_title().await.unwrap_or_default();
1619        Ok(json!({
1620            "reloaded": true,
1621            "ignore_cache": ignore_cache,
1622            "url": url,
1623            "title": title,
1624        }))
1625    }
1626
1627    async fn history_nav(&mut self, direction: &str) -> Result<Value, CliError> {
1628        self.drain_events();
1629        let script = match direction {
1630            "back" => "history.back(); 'ok'",
1631            "forward" => "history.forward(); 'ok'",
1632            _ => "null",
1633        };
1634        self.manager
1635            .evaluate(script, None)
1636            .await
1637            .map_err(|e| CliError::new(ErrorKind::Browser, format!("{direction} failed: {e}")))?;
1638        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1639        self.drain_events();
1640        let url = self.manager.get_url().await.unwrap_or_default();
1641        let title = self.manager.get_title().await.unwrap_or_default();
1642        Ok(json!({
1643            "navigation": direction,
1644            "url": url,
1645            "title": title,
1646        }))
1647    }
1648
1649    pub async fn page_list(&mut self) -> Result<Value, CliError> {
1650        self.drain_events();
1651        let pages: Vec<Value> = self
1652            .manager
1653            .pages_list()
1654            .into_iter()
1655            .map(|p| {
1656                json!({
1657                    "tab_id": p.tab_id,
1658                    "label": p.label,
1659                    "url": p.url,
1660                    "title": p.title,
1661                    "target_type": p.target_type,
1662                })
1663            })
1664            .collect();
1665        let active = self.manager.active_tab_id();
1666        Ok(json!({
1667            "pages": pages,
1668            "count": pages.len(),
1669            "active_tab_id": active,
1670        }))
1671    }
1672
1673    pub async fn page_new(
1674        &mut self,
1675        url: Option<&str>,
1676        background: bool,
1677        isolated_context: bool,
1678    ) -> Result<Value, CliError> {
1679        self.drain_events();
1680        let mut isolation_note = None;
1681        let mut isolation_limitation = None;
1682        let ctx_id = if isolated_context {
1683            match self.manager.create_browser_context().await {
1684                Ok(id) => {
1685                    isolation_note = Some(
1686                        "isolated BrowserContext created for cookie/storage isolation within this one-shot process"
1687                            .to_string(),
1688                    );
1689                    Some(id)
1690                }
1691                Err(e) => {
1692                    // Some Chromium builds reject Browser.createBrowserContext (-32601).
1693                    // Fall back to shared context with an explicit limitation for agents.
1694                    isolation_limitation =
1695                        Some("isolated_context_unsupported_on_this_browser".to_string());
1696                    isolation_note = Some(format!(
1697                        "isolatedContext requested but Browser.createBrowserContext unavailable ({e}); tab uses shared browser context"
1698                    ));
1699                    None
1700                }
1701            }
1702        } else {
1703            None
1704        };
1705        let mut data = self
1706            .manager
1707            .tab_new_in_context(url, None, ctx_id)
1708            .await
1709            .map_err(|e| CliError::new(ErrorKind::Browser, format!("page new failed: {e}")))?;
1710        // tool-ref background: do not switch focus when true (best-effort)
1711        if !background {
1712            if let Some(idx) = data.get("index").and_then(|v| v.as_u64()) {
1713                let _ = self.manager.tab_switch(idx as usize).await;
1714            }
1715        }
1716        if let Some(obj) = data.as_object_mut() {
1717            obj.insert("background".into(), json!(background));
1718            obj.insert("isolated_context".into(), json!(isolated_context));
1719            if let Some(n) = isolation_note {
1720                obj.insert("note".into(), json!(n));
1721            }
1722            if let Some(lim) = isolation_limitation {
1723                obj.insert("limitation".into(), json!(lim));
1724            }
1725        }
1726        self.drain_events();
1727        Ok(data)
1728    }
1729
1730    pub async fn page_select(
1731        &mut self,
1732        index: usize,
1733        bring_to_front: bool,
1734    ) -> Result<Value, CliError> {
1735        self.drain_events();
1736        let mut data =
1737            self.manager.tab_switch(index).await.map_err(|e| {
1738                CliError::new(ErrorKind::Browser, format!("page select failed: {e}"))
1739            })?;
1740        if bring_to_front {
1741            if let Ok(session_id) = self.manager.active_session_id() {
1742                let _ = self
1743                    .manager
1744                    .client
1745                    .send_command("Page.bringToFront", None, Some(session_id))
1746                    .await;
1747            }
1748        }
1749        if let Some(obj) = data.as_object_mut() {
1750            obj.insert("bring_to_front".into(), json!(bring_to_front));
1751        }
1752        self.ref_map.clear();
1753        self.drain_events();
1754        Ok(data)
1755    }
1756
1757    pub async fn page_close(&mut self, index: Option<usize>) -> Result<Value, CliError> {
1758        self.drain_events();
1759        let data =
1760            self.manager.tab_close(index).await.map_err(|e| {
1761                CliError::new(ErrorKind::Browser, format!("page close failed: {e}"))
1762            })?;
1763        self.ref_map.clear();
1764        self.drain_events();
1765        Ok(data)
1766    }
1767
1768    pub async fn wait_for(
1769        &mut self,
1770        ms: Option<u64>,
1771        text: Option<&str>,
1772        selector: Option<&str>,
1773        state: Option<&str>,
1774        include_snapshot: bool,
1775    ) -> Result<Value, CliError> {
1776        let mut waited = Vec::new();
1777
1778        if let Some(st) = state {
1779            let until = WaitUntil::parse_token(st);
1780            let session_id = self
1781                .manager
1782                .active_session_id()
1783                .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1784                .to_string();
1785            self.manager
1786                .wait_for_lifecycle_external(until, &session_id)
1787                .await
1788                .map_err(|e| {
1789                    CliError::with_suggestion(
1790                        ErrorKind::Timeout,
1791                        format!("wait state {st} failed: {e}"),
1792                        "Use --state load|domcontentloaded|networkidle|none",
1793                    )
1794                })?;
1795            waited.push(json!({"kind": "state", "state": st}));
1796        }
1797
1798        if let Some(m) = ms {
1799            if m > 0 && text.is_none() && selector.is_none() && state.is_none() {
1800                let data = self.wait_ms(m).await?;
1801                return self.attach_snapshot_if(include_snapshot, data).await;
1802            }
1803            if m > 0 && text.is_none() && selector.is_none() && state.is_some() {
1804                // pure ms after state already done above
1805                if m > 0 {
1806                    let _ = self.wait_ms(m).await?;
1807                    waited.push(json!({"kind": "ms", "ms": m}));
1808                }
1809                let data = json!({ "waited": waited, "ok": true });
1810                return self.attach_snapshot_if(include_snapshot, data).await;
1811            }
1812        }
1813
1814        if text.is_none() && selector.is_none() && state.is_some() {
1815            let data = json!({ "waited": waited, "ok": true });
1816            return self.attach_snapshot_if(include_snapshot, data).await;
1817        }
1818
1819        if text.is_none() && selector.is_none() && state.is_none() {
1820            let data = self.wait_ms(ms.unwrap_or(0)).await?;
1821            return self.attach_snapshot_if(include_snapshot, data).await;
1822        }
1823
1824        let deadline = std::time::Instant::now()
1825            + std::time::Duration::from_millis(ms.unwrap_or(10_000).max(1));
1826        loop {
1827            self.drain_events();
1828            if let Some(sel) = selector {
1829                let session_id = self
1830                    .manager
1831                    .active_session_id()
1832                    .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1833                    .to_string();
1834                if element::get_element_count(&self.manager.client, &session_id, sel)
1835                    .await
1836                    .unwrap_or(0)
1837                    > 0
1838                {
1839                    waited.push(json!({"kind": "selector", "selector": sel}));
1840                    let data = json!({ "waited": waited, "ok": true });
1841                    return self.attach_snapshot_if(include_snapshot, data).await;
1842                }
1843            }
1844            if let Some(t) = text {
1845                let body = self
1846                    .manager
1847                    .evaluate("document.body ? document.body.innerText : ''", None)
1848                    .await
1849                    .unwrap_or(json!(""));
1850                let hay = body.as_str().unwrap_or("");
1851                if hay.contains(t) {
1852                    waited.push(json!({"kind": "text", "text": t}));
1853                    let data = json!({ "waited": waited, "ok": true });
1854                    return self.attach_snapshot_if(include_snapshot, data).await;
1855                }
1856            }
1857            if std::time::Instant::now() >= deadline {
1858                return Err(CliError::with_suggestion(
1859                    ErrorKind::Timeout,
1860                    "wait condition not met before deadline",
1861                    "Increase --ms, set --state, or ensure page content is present",
1862                ));
1863            }
1864            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1865        }
1866    }
1867
1868    #[allow(clippy::too_many_arguments)]
1869    pub async fn emulate(
1870        &mut self,
1871        user_agent: Option<&str>,
1872        locale: Option<&str>,
1873        timezone: Option<&str>,
1874        offline: bool,
1875        latitude: Option<f64>,
1876        longitude: Option<f64>,
1877        media: Option<&str>,
1878        network_conditions: Option<&str>,
1879        cpu_throttling_rate: Option<f64>,
1880        color_scheme: Option<&str>,
1881        extra_headers_json: Option<&str>,
1882        viewport: Option<&str>,
1883    ) -> Result<Value, CliError> {
1884        self.drain_events();
1885        let session_id = self
1886            .manager
1887            .active_session_id()
1888            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1889            .to_string();
1890        if let Some(ua) = user_agent {
1891            if ua.is_empty() {
1892                // clear override with empty UA not portable; skip
1893            } else {
1894                self.manager
1895                    .set_user_agent(ua)
1896                    .await
1897                    .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate ua: {e}")))?;
1898            }
1899        }
1900        if let Some(loc) = locale {
1901            self.manager
1902                .set_locale(loc)
1903                .await
1904                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate locale: {e}")))?;
1905        }
1906        if let Some(tz) = timezone {
1907            self.manager
1908                .set_timezone(tz)
1909                .await
1910                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate timezone: {e}")))?;
1911        }
1912
1913        let mut applied_network = None;
1914        if let Some(name) = network_conditions {
1915            let preset = crate::constants::network_preset_by_name(name).ok_or_else(|| {
1916                CliError::with_suggestion(
1917                    ErrorKind::Usage,
1918                    format!("unknown network conditions: {name}"),
1919                    format!(
1920                        "Use one of: {}",
1921                        crate::constants::network_preset_names().join(", ")
1922                    ),
1923                )
1924            })?;
1925            network::set_network_conditions(
1926                &self.manager.client,
1927                &session_id,
1928                preset.offline,
1929                preset.latency_ms,
1930                preset.download_throughput,
1931                preset.upload_throughput,
1932            )
1933            .await
1934            .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate network: {e}")))?;
1935            applied_network = Some(preset.name);
1936        } else if offline {
1937            network::set_offline(&self.manager.client, &session_id, true)
1938                .await
1939                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate offline: {e}")))?;
1940            applied_network = Some("Offline");
1941        }
1942
1943        if let Some(rate) = cpu_throttling_rate {
1944            let rate = rate.clamp(1.0, 20.0);
1945            network::set_cpu_throttling_rate(&self.manager.client, &session_id, rate)
1946                .await
1947                .map_err(|e| {
1948                    CliError::new(ErrorKind::Browser, format!("emulate cpu throttle: {e}"))
1949                })?;
1950        }
1951
1952        if let (Some(lat), Some(lon)) = (latitude, longitude) {
1953            self.manager
1954                .set_geolocation(lat, lon, Some(1.0))
1955                .await
1956                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate geo: {e}")))?;
1957        }
1958
1959        if let Some(scheme) = color_scheme {
1960            let value = match scheme.to_ascii_lowercase().as_str() {
1961                "dark" => "dark",
1962                "light" => "light",
1963                "auto" => "",
1964                other => {
1965                    return Err(CliError::with_suggestion(
1966                        ErrorKind::Usage,
1967                        format!("invalid color-scheme: {other}"),
1968                        "Use dark, light, or auto",
1969                    ));
1970                }
1971            };
1972            self.manager
1973                .set_emulated_media(
1974                    media,
1975                    Some(vec![("prefers-color-scheme".into(), value.into())]),
1976                )
1977                .await
1978                .map_err(|e| {
1979                    CliError::new(ErrorKind::Browser, format!("emulate color-scheme: {e}"))
1980                })?;
1981        } else if let Some(m) = media {
1982            self.manager
1983                .set_emulated_media(Some(m), None)
1984                .await
1985                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate media: {e}")))?;
1986        }
1987
1988        if let Some(headers_raw) = extra_headers_json {
1989            let map: HashMap<String, String> = if headers_raw.trim().is_empty() {
1990                HashMap::new()
1991            } else {
1992                serde_json::from_str(headers_raw).map_err(|e| {
1993                    CliError::with_suggestion(
1994                        ErrorKind::Usage,
1995                        format!("invalid extra-headers JSON: {e}"),
1996                        r#"Pass object JSON e.g. {"X-Custom":"1"}"#,
1997                    )
1998                })?
1999            };
2000            network::set_extra_headers(&self.manager.client, &session_id, &map)
2001                .await
2002                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate headers: {e}")))?;
2003        }
2004
2005        let mut applied_viewport = None;
2006        if let Some(vp) = viewport {
2007            let spec = crate::constants::parse_viewport_spec(vp).map_err(|e| {
2008                CliError::with_suggestion(
2009                    ErrorKind::Usage,
2010                    e,
2011                    "Format: WxHxDPR[,mobile][,touch][,landscape]",
2012                )
2013            })?;
2014            self.manager
2015                .set_viewport(
2016                    spec.width,
2017                    spec.height,
2018                    spec.device_scale_factor,
2019                    spec.mobile,
2020                )
2021                .await
2022                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate viewport: {e}")))?;
2023            applied_viewport = Some(json!({
2024                "width": spec.width,
2025                "height": spec.height,
2026                "device_scale_factor": spec.device_scale_factor,
2027                "mobile": spec.mobile,
2028                "has_touch": spec.has_touch,
2029                "is_landscape": spec.is_landscape,
2030            }));
2031        }
2032
2033        Ok(json!({
2034            "emulated": true,
2035            "user_agent": user_agent,
2036            "locale": locale,
2037            "timezone": timezone,
2038            "offline": offline || applied_network == Some("Offline"),
2039            "latitude": latitude,
2040            "longitude": longitude,
2041            "media": media,
2042            "network_conditions": applied_network,
2043            "cpu_throttling_rate": cpu_throttling_rate,
2044            "color_scheme": color_scheme,
2045            "extra_headers": extra_headers_json.is_some(),
2046            "viewport": applied_viewport,
2047        }))
2048    }
2049
2050    pub async fn resize(
2051        &mut self,
2052        width: i32,
2053        height: i32,
2054        scale: f64,
2055        mobile: bool,
2056    ) -> Result<Value, CliError> {
2057        self.drain_events();
2058        self.manager
2059            .set_viewport(width, height, scale, mobile)
2060            .await
2061            .map_err(|e| CliError::new(ErrorKind::Browser, format!("resize failed: {e}")))?;
2062        Ok(json!({
2063            "width": width,
2064            "height": height,
2065            "scale": scale,
2066            "mobile": mobile,
2067        }))
2068    }
2069
2070    pub async fn perf_start(
2071        &mut self,
2072        path: Option<&Path>,
2073        reload: bool,
2074        auto_stop: bool,
2075    ) -> Result<Value, CliError> {
2076        self.drain_events();
2077        let session_id = self
2078            .manager
2079            .active_session_id()
2080            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2081            .to_string();
2082        self.trace_chunks.clear();
2083        self.manager
2084            .client
2085            .send_command(
2086                "Tracing.start",
2087                Some(json!({
2088                    "categories": "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-devtools.timeline",
2089                    "transferMode": "ReportEvents",
2090                })),
2091                None,
2092            )
2093            .await
2094            .map_err(|e| CliError::new(ErrorKind::Browser, format!("perf start: {e}")))?;
2095        let _ = self
2096            .manager
2097            .client
2098            .send_command_no_params("Performance.enable", Some(&session_id))
2099            .await;
2100        self.perf_active = true;
2101        if reload {
2102            let _ = self.reload(false).await?;
2103        }
2104        let mut out = json!({
2105            "perf": "start",
2106            "path": path.map(|p| p.to_string_lossy().to_string()),
2107            "reload": reload,
2108            "auto_stop": auto_stop,
2109        });
2110        if auto_stop {
2111            // tool-ref autoStop: stop after load/reload settles
2112            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2113            let stop = self.perf_stop(path).await?;
2114            if let Some(obj) = out.as_object_mut() {
2115                obj.insert("auto_stopped".into(), json!(true));
2116                obj.insert("stop".into(), stop);
2117            }
2118        }
2119        Ok(out)
2120    }
2121
2122    pub async fn perf_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2123        self.pump_events().await;
2124        self.tracing_complete = false;
2125        if self.perf_active {
2126            let _ = self
2127                .manager
2128                .client
2129                .send_command("Tracing.end", None, None)
2130                .await;
2131            self.perf_active = false;
2132        }
2133        // Wait for dataCollected + tracingComplete (up to ~5s).
2134        for _ in 0..100 {
2135            self.pump_events().await;
2136            if self.tracing_complete && !self.trace_chunks.is_empty() {
2137                for _ in 0..5 {
2138                    self.pump_events().await;
2139                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2140                }
2141                break;
2142            }
2143            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2144        }
2145        let body = self.trace_chunks.join("\n");
2146        let chunks = self.trace_chunks.len();
2147        self.last_trace_body = Some(body.clone());
2148        let mut out_path = path.map(|p| p.to_path_buf());
2149        if out_path.is_none() {
2150            // Default artifact so insight can always read a file after stop.
2151            let stamp = std::time::SystemTime::now()
2152                .duration_since(std::time::UNIX_EPOCH)
2153                .map(|d| d.as_millis())
2154                .unwrap_or(0);
2155            out_path = Some(PathBuf::from(format!("trace-{stamp}.ndjson")));
2156        }
2157        if let Some(ref p) = out_path {
2158            if let Some(parent) = p.parent() {
2159                if !parent.as_os_str().is_empty() {
2160                    std::fs::create_dir_all(parent).map_err(|e| {
2161                        CliError::new(ErrorKind::Io, format!("perf stop mkdir: {e}"))
2162                    })?;
2163                }
2164            }
2165            std::fs::write(p, body.as_bytes())
2166                .map_err(|e| CliError::new(ErrorKind::Io, format!("perf stop write: {e}")))?;
2167            self.last_trace_path = Some(p.clone());
2168        }
2169        self.trace_chunks.clear();
2170        self.tracing_complete = false;
2171        // Synthetic insight sets for tool-ref performance_analyze_insight flow
2172        let set_id = format!(
2173            "set-{}",
2174            std::time::SystemTime::now()
2175                .duration_since(std::time::UNIX_EPOCH)
2176                .map(|d| d.as_millis())
2177                .unwrap_or(0)
2178        );
2179        Ok(json!({
2180            "perf": "stop",
2181            "path": out_path.map(|p| p.to_string_lossy().to_string()),
2182            "events": chunks,
2183            "available_insight_sets": [{
2184                "insight_set_id": set_id,
2185                "insights": [
2186                    "DocumentLatency",
2187                    "LCPBreakdown",
2188                    "CLSCulprits",
2189                    "INPBreakdown",
2190                    "RenderBlocking",
2191                    "ThirdParties"
2192                ]
2193            }],
2194        }))
2195    }
2196
2197    pub async fn perf_insight(
2198        &mut self,
2199        name: Option<&str>,
2200        insight_set_id: Option<&str>,
2201    ) -> Result<Value, CliError> {
2202        self.pump_events().await;
2203        let session_id = self
2204            .manager
2205            .active_session_id()
2206            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2207            .to_string();
2208        let live_metrics = self
2209            .manager
2210            .client
2211            .send_command("Performance.getMetrics", None, Some(&session_id))
2212            .await
2213            .ok();
2214
2215        let offline = if let Some(ref p) = self.last_trace_path {
2216            crate::native::perf_insight::analyze_file(p, name).ok()
2217        } else if let Some(ref body) = self.last_trace_body {
2218            crate::native::perf_insight::analyze_text(body, name, None).ok()
2219        } else {
2220            None
2221        };
2222
2223        Ok(json!({
2224            "perf": "insight",
2225            "name": name,
2226            "insight_name": name,
2227            "insight_set_id": insight_set_id,
2228            "live_metrics": live_metrics,
2229            "trace_insight": offline,
2230            "trace_path": self.last_trace_path.as_ref().map(|p| p.to_string_lossy().to_string()),
2231        }))
2232    }
2233
2234    /// Offline insight from a previously written trace path (no browser required).
2235    pub fn perf_insight_file(path: &Path, name: Option<&str>) -> Result<Value, CliError> {
2236        crate::native::perf_insight::analyze_file(path, name).map_err(|e| {
2237            CliError::with_suggestion(ErrorKind::Io, e, "Pass a path produced by perf stop --path")
2238        })
2239    }
2240
2241    pub async fn screencast_start(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2242        self.pump_events().await;
2243        let session_id = self
2244            .manager
2245            .active_session_id()
2246            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2247            .to_string();
2248        self.screencast_frames.clear();
2249        self.screencast_ack_ids.clear();
2250        let dir = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2251            let stamp = std::time::SystemTime::now()
2252                .duration_since(std::time::UNIX_EPOCH)
2253                .map(|d| d.as_millis())
2254                .unwrap_or(0);
2255            PathBuf::from(format!("screencast-{stamp}"))
2256        });
2257        std::fs::create_dir_all(&dir)
2258            .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast dir: {e}")))?;
2259        self.screencast_dir = Some(dir.clone());
2260        // Page domain must be enabled for screencast frames.
2261        let _ = self
2262            .manager
2263            .client
2264            .send_command_no_params("Page.enable", Some(&session_id))
2265            .await;
2266        self.manager
2267            .client
2268            .send_command(
2269                "Page.startScreencast",
2270                Some(json!({
2271                    "format": "png",
2272                    "quality": 60,
2273                    "maxWidth": 1280,
2274                    "maxHeight": 720,
2275                    "everyNthFrame": 1,
2276                })),
2277                Some(&session_id),
2278            )
2279            .await
2280            .map_err(|e| CliError::new(ErrorKind::Browser, format!("screencast start: {e}")))?;
2281        self.screencast_active = true;
2282        // Pump a few frames immediately so FrameAck unblocks the pipeline.
2283        for _ in 0..15 {
2284            self.pump_events().await;
2285            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2286        }
2287        Ok(json!({
2288            "screencast": "start",
2289            "dir": dir.to_string_lossy(),
2290            "note": "Frames buffered in process; stop writes PNG files + manifest.json",
2291            "frames_buffered": self.screencast_frames.len(),
2292        }))
2293    }
2294
2295    pub async fn screencast_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2296        for _ in 0..40 {
2297            self.pump_events().await;
2298            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2299        }
2300        let session_id = self
2301            .manager
2302            .active_session_id()
2303            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2304            .to_string();
2305        if self.screencast_active {
2306            let _ = self
2307                .manager
2308                .client
2309                .send_command("Page.stopScreencast", None, Some(&session_id))
2310                .await;
2311            self.screencast_active = false;
2312        }
2313        self.pump_events().await;
2314
2315        let dir = self.screencast_dir.clone().unwrap_or_else(|| {
2316            PathBuf::from(format!(
2317                "screencast-{}",
2318                std::time::SystemTime::now()
2319                    .duration_since(std::time::UNIX_EPOCH)
2320                    .map(|d| d.as_millis())
2321                    .unwrap_or(0)
2322            ))
2323        });
2324        std::fs::create_dir_all(&dir)
2325            .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast stop mkdir: {e}")))?;
2326
2327        use base64::Engine;
2328        let engine = base64::engine::general_purpose::STANDARD;
2329        let mut written = 0u64;
2330        let mut paths: Vec<String> = Vec::new();
2331        for (i, b64) in self.screencast_frames.iter().enumerate() {
2332            let bytes = match engine.decode(b64) {
2333                Ok(b) => b,
2334                Err(_) => continue,
2335            };
2336            let name = format!("frame-{:05}.png", i + 1);
2337            let out = dir.join(&name);
2338            if std::fs::write(&out, &bytes).is_ok() {
2339                written += 1;
2340                paths.push(out.to_string_lossy().into_owned());
2341            }
2342        }
2343        let video_path = path.map(|p| p.to_path_buf()).or_else(|| {
2344            // If start path looked like a video file, encode there
2345            self.screencast_dir.as_ref().and_then(|d| {
2346                let s = d.to_string_lossy();
2347                if s.ends_with(".webm") || s.ends_with(".mp4") {
2348                    Some(d.clone())
2349                } else {
2350                    None
2351                }
2352            })
2353        });
2354        let mut video_out: Option<String> = None;
2355        let mut encode_note: Option<String> = None;
2356        if let Some(ref vp) = video_path {
2357            let ext = vp
2358                .extension()
2359                .and_then(|e| e.to_str())
2360                .unwrap_or("mp4")
2361                .to_ascii_lowercase();
2362            let is_video = ext == "webm" || ext == "mp4";
2363            if is_video && written > 0 {
2364                if let Some(parent) = vp.parent() {
2365                    if !parent.as_os_str().is_empty() {
2366                        let _ = std::fs::create_dir_all(parent);
2367                    }
2368                }
2369                let pattern = dir.join("frame-%05d.png");
2370                let vcodec = if ext == "webm" {
2371                    "libvpx-vp9"
2372                } else {
2373                    "libx264"
2374                };
2375                let mut cmd = std::process::Command::new("ffmpeg");
2376                cmd.arg("-y")
2377                    .arg("-framerate")
2378                    .arg("10")
2379                    .arg("-i")
2380                    .arg(&pattern)
2381                    .arg("-c:v")
2382                    .arg(vcodec)
2383                    .arg("-pix_fmt")
2384                    .arg("yuv420p")
2385                    .arg(vp);
2386                match cmd.output() {
2387                    Ok(out) if out.status.success() => {
2388                        video_out = Some(vp.to_string_lossy().into_owned());
2389                        encode_note = Some("encoded via ffmpeg".into());
2390                    }
2391                    Ok(out) => {
2392                        encode_note = Some(format!(
2393                            "ffmpeg failed: {}",
2394                            String::from_utf8_lossy(&out.stderr)
2395                        ));
2396                    }
2397                    Err(e) => {
2398                        encode_note =
2399                            Some(format!("ffmpeg not available: {e}; PNG frames kept in dir"));
2400                    }
2401                }
2402            }
2403        }
2404        let manifest = json!({
2405            "format": "png",
2406            "frame_count": written,
2407            "frames": paths,
2408            "video": video_out,
2409            "encode_note": encode_note,
2410            "ffmpeg_hint": format!(
2411                "ffmpeg -y -framerate 10 -i {}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p {}.mp4",
2412                dir.display(),
2413                dir.display()
2414            ),
2415        });
2416        let manifest_path = dir.join("manifest.json");
2417        let _ = std::fs::write(
2418            &manifest_path,
2419            serde_json::to_vec_pretty(&manifest).unwrap_or_default(),
2420        );
2421        self.screencast_frames.clear();
2422        self.screencast_ack_ids.clear();
2423        Ok(json!({
2424            "screencast": "stop",
2425            "dir": dir.to_string_lossy(),
2426            "frame_count": written,
2427            "manifest": manifest_path.to_string_lossy(),
2428            "video": video_out,
2429            "encode_note": encode_note,
2430        }))
2431    }
2432
2433    pub async fn heap_take(&mut self, path: &Path) -> Result<Value, CliError> {
2434        self.drain_events();
2435        let session_id = self
2436            .manager
2437            .active_session_id()
2438            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2439            .to_string();
2440        self.heap_chunks.clear();
2441        self.heap_snapshot_finished = false;
2442        let _ = self
2443            .manager
2444            .client
2445            .send_command_no_params("HeapProfiler.enable", Some(&session_id))
2446            .await;
2447        self.manager
2448            .client
2449            .send_command(
2450                "HeapProfiler.takeHeapSnapshot",
2451                Some(json!({ "reportProgress": true })),
2452                Some(&session_id),
2453            )
2454            .await
2455            .map_err(|e| CliError::new(ErrorKind::Browser, format!("heap take: {e}")))?;
2456        // Wait for chunks + progress finished (up to ~10s).
2457        for _ in 0..200 {
2458            self.drain_events();
2459            if self.heap_snapshot_finished && !self.heap_chunks.is_empty() {
2460                // Drain a few more ticks for trailing chunks.
2461                for _ in 0..10 {
2462                    self.drain_events();
2463                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2464                }
2465                break;
2466            }
2467            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2468        }
2469        // Final drain
2470        for _ in 0..20 {
2471            self.drain_events();
2472            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2473        }
2474        if let Some(parent) = path.parent() {
2475            if !parent.as_os_str().is_empty() {
2476                std::fs::create_dir_all(parent)
2477                    .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take mkdir: {e}")))?;
2478            }
2479        }
2480        let body = self.heap_chunks.join("");
2481        let bytes = body.len();
2482        if bytes == 0 {
2483            return Err(CliError::with_suggestion(
2484                ErrorKind::Browser,
2485                "heap take produced empty snapshot (no HeapProfiler chunks received)",
2486                "Ensure Chrome supports HeapProfiler; re-run doctor; check event forwarders",
2487            ));
2488        }
2489        std::fs::write(path, body.as_bytes())
2490            .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take write: {e}")))?;
2491        self.heap_chunks.clear();
2492        self.heap_snapshot_finished = false;
2493        Ok(json!({
2494            "heap": "take",
2495            "path": path.to_string_lossy(),
2496            "bytes": bytes,
2497        }))
2498    }
2499
2500    pub fn heap_file_summary(path: &Path) -> Result<Value, CliError> {
2501        crate::native::heap_snapshot::summarize(path).map_err(|e| {
2502            CliError::with_suggestion(
2503                ErrorKind::Io,
2504                e,
2505                "Pass a path produced by heap take (.heapsnapshot JSON)",
2506            )
2507        })
2508    }
2509
2510    pub fn heap_close(path: &Path) -> Result<Value, CliError> {
2511        crate::native::heap_snapshot::close_snapshot(path).map_err(|e| {
2512            CliError::with_suggestion(
2513                ErrorKind::Io,
2514                e,
2515                "Pass a path produced by heap take (.heapsnapshot JSON)",
2516            )
2517        })
2518    }
2519
2520    pub fn heap_compare(base: &Path, current: &Path) -> Result<Value, CliError> {
2521        crate::native::heap_snapshot::compare(base, current).map_err(|e| {
2522            CliError::with_suggestion(ErrorKind::Io, e, "Pass two paths produced by heap take")
2523        })
2524    }
2525
2526    pub fn heap_details(path: &Path) -> Result<Value, CliError> {
2527        crate::native::heap_snapshot::details(path).map_err(|e| {
2528            CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2529        })
2530    }
2531
2532    pub fn heap_dup_strings(path: &Path) -> Result<Value, CliError> {
2533        crate::native::heap_snapshot::duplicate_strings(path).map_err(|e| {
2534            CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2535        })
2536    }
2537
2538    pub fn heap_class_nodes(path: &Path, id: u64) -> Result<Value, CliError> {
2539        crate::native::heap_snapshot::class_nodes(path, id).map_err(|e| {
2540            CliError::with_suggestion(
2541                ErrorKind::Io,
2542                e,
2543                "Pass a valid .heapsnapshot path and class id",
2544            )
2545        })
2546    }
2547
2548    pub fn heap_node_op(path: &Path, node: u64, op: &str) -> Result<Value, CliError> {
2549        crate::native::heap_snapshot::node_op(path, node, op).map_err(|e| {
2550            CliError::with_suggestion(
2551                ErrorKind::Io,
2552                e,
2553                "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2554            )
2555        })
2556    }
2557
2558    /// Offline object details for one node id (distance, retained size, detachedness).
2559    pub fn heap_object_details(path: &Path, node: u64) -> Result<Value, CliError> {
2560        crate::native::heap_snapshot::object_details(path, node).map_err(|e| {
2561            CliError::with_suggestion(
2562                ErrorKind::Io,
2563                e,
2564                "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2565            )
2566        })
2567    }
2568
2569    pub async fn extension_list(&mut self) -> Result<Value, CliError> {
2570        self.pump_events().await;
2571        let targets = self
2572            .manager
2573            .client
2574            .send_command("Target.getTargets", None, None)
2575            .await
2576            .map_err(|e| CliError::new(ErrorKind::Browser, format!("extension list: {e}")))?;
2577        let list = targets
2578            .get("targetInfos")
2579            .and_then(|v| v.as_array())
2580            .cloned()
2581            .unwrap_or_default();
2582        let extensions: Vec<Value> = list
2583            .into_iter()
2584            .filter(|t| {
2585                t.get("url")
2586                    .and_then(|u| u.as_str())
2587                    .map(|u| u.starts_with("chrome-extension://"))
2588                    .unwrap_or(false)
2589                    || t.get("type").and_then(|x| x.as_str()) == Some("service_worker")
2590            })
2591            .map(|t| {
2592                let url = t.get("url").and_then(|u| u.as_str()).unwrap_or("");
2593                let id = url
2594                    .strip_prefix("chrome-extension://")
2595                    .and_then(|rest| rest.split('/').next())
2596                    .unwrap_or("")
2597                    .to_string();
2598                json!({
2599                    "id": id,
2600                    "url": url,
2601                    "type": t.get("type"),
2602                    "title": t.get("title"),
2603                    "targetId": t.get("targetId"),
2604                })
2605            })
2606            .collect();
2607        Ok(json!({ "extensions": extensions, "count": extensions.len() }))
2608    }
2609
2610    /// Reload extension service worker target by id prefix (one-shot CDP).
2611    pub async fn extension_reload(&mut self, id: &str) -> Result<Value, CliError> {
2612        self.pump_events().await;
2613        let listed = self.extension_list().await?;
2614        let targets = listed
2615            .get("extensions")
2616            .and_then(|v| v.as_array())
2617            .cloned()
2618            .unwrap_or_default();
2619        let match_t = targets.iter().find(|t| {
2620            t.get("id")
2621                .and_then(|v| v.as_str())
2622                .map(|s| s == id || s.starts_with(id) || id.contains(s))
2623                .unwrap_or(false)
2624        });
2625        let Some(t) = match_t else {
2626            return Err(CliError::with_suggestion(
2627                ErrorKind::NoInput,
2628                format!("extension id not found: {id}"),
2629                "Run extension list after extension install <unpacked-dir>",
2630            ));
2631        };
2632        let target_id = t
2633            .get("targetId")
2634            .and_then(|v| v.as_str())
2635            .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2636            .to_string();
2637        // Close then rely on Chrome to re-spawn the extension SW on next attach.
2638        let _ = self
2639            .manager
2640            .client
2641            .send_command(
2642                "Target.closeTarget",
2643                Some(json!({ "targetId": target_id })),
2644                None,
2645            )
2646            .await;
2647        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2648        let again = self.extension_list().await?;
2649        Ok(json!({
2650            "reloaded": id,
2651            "closed_target": target_id,
2652            "after": again,
2653            "one_shot": true,
2654            "ok": true,
2655            "note": "one-shot SW restart via Target.closeTarget; install path is --load-extension on the same invocation",
2656        }))
2657    }
2658
2659    pub async fn extension_trigger(&mut self, id: &str) -> Result<Value, CliError> {
2660        self.pump_events().await;
2661        let listed = self.extension_list().await?;
2662        let targets = listed
2663            .get("extensions")
2664            .and_then(|v| v.as_array())
2665            .cloned()
2666            .unwrap_or_default();
2667        let match_t = targets.iter().find(|t| {
2668            t.get("id")
2669                .and_then(|v| v.as_str())
2670                .map(|s| s == id || s.starts_with(id))
2671                .unwrap_or(false)
2672                && t.get("type").and_then(|v| v.as_str()) == Some("service_worker")
2673        });
2674        let Some(t) = match_t else {
2675            return Err(CliError::with_suggestion(
2676                ErrorKind::NoInput,
2677                format!("extension service_worker not found for id: {id}"),
2678                "Use extension list; trigger requires a service_worker target",
2679            ));
2680        };
2681        let target_id = t
2682            .get("targetId")
2683            .and_then(|v| v.as_str())
2684            .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2685            .to_string();
2686        // Attach and try chrome.runtime / action APIs when available.
2687        let attach = self
2688            .manager
2689            .client
2690            .send_command(
2691                "Target.attachToTarget",
2692                Some(json!({ "targetId": target_id, "flatten": true })),
2693                None,
2694            )
2695            .await
2696            .map_err(|e| CliError::new(ErrorKind::Browser, format!("attach extension SW: {e}")))?;
2697        let session = attach
2698            .get("sessionId")
2699            .and_then(|v| v.as_str())
2700            .map(|s| s.to_string());
2701        let eval = self
2702            .manager
2703            .client
2704            .send_command(
2705                "Runtime.evaluate",
2706                Some(json!({
2707                    "expression": "(() => { try { if (chrome && chrome.runtime) { return { ok: true, id: chrome.runtime.id }; } return { ok: false, reason: 'no chrome.runtime' }; } catch (e) { return { ok: false, reason: String(e) }; } })()",
2708                    "returnByValue": true,
2709                    "awaitPromise": true,
2710                })),
2711                session.as_deref(),
2712            )
2713            .await;
2714        Ok(json!({
2715            "triggered": id,
2716            "targetId": target_id,
2717            "evaluate": eval.unwrap_or(Value::Null),
2718            "one_shot": true,
2719            "ok": true,
2720            "note": "best-effort SW Runtime.evaluate in the same process; popup UI may need headed Chrome",
2721        }))
2722    }
2723
2724    /// Discover third-party developer tools via `devtoolstooldiscovery` CustomEvent.
2725    pub async fn devtools3p_list(&mut self) -> Result<Value, CliError> {
2726        self.pump_events().await;
2727        let expr = r#"(() => {
2728          return new Promise((resolve) => {
2729            if (!window.__dtmcp) window.__dtmcp = {};
2730            window.__dtmcp.toolGroups = [];
2731            const groups = [];
2732            const event = new CustomEvent('devtoolstooldiscovery');
2733            event.respondWith = (toolGroup) => {
2734              if (!toolGroup || typeof toolGroup.name !== 'string' || !Array.isArray(toolGroup.tools)) {
2735                return;
2736              }
2737              const tools = [];
2738              for (const tool of toolGroup.tools) {
2739                if (!tool || typeof tool.name !== 'string') continue;
2740                tools.push({
2741                  name: tool.name,
2742                  description: typeof tool.description === 'string' ? tool.description : '',
2743                  inputSchema: tool.inputSchema || {},
2744                });
2745              }
2746              const g = {
2747                name: toolGroup.name,
2748                description: typeof toolGroup.description === 'string' ? toolGroup.description : '',
2749                tools,
2750              };
2751              groups.push(g);
2752              window.__dtmcp.toolGroups.push({
2753                name: g.name,
2754                description: g.description,
2755                tools: toolGroup.tools,
2756              });
2757              if (!window.__dtmcp.executeTool) {
2758                window.__dtmcp.executeTool = async (toolName, args) => {
2759                  for (const group of (window.__dtmcp.toolGroups || [])) {
2760                    const t = (group.tools || []).find((x) => x.name === toolName);
2761                    if (t && typeof t.execute === 'function') {
2762                      return await t.execute(args || {});
2763                    }
2764                  }
2765                  throw new Error('Tool ' + toolName + ' not found');
2766                };
2767              }
2768            };
2769            window.dispatchEvent(event);
2770            setTimeout(() => resolve(groups), 0);
2771          });
2772        })()"#;
2773        let result = self.eval(expr, None, Some("accept"), None).await?;
2774        let groups = result
2775            .get("result")
2776            .cloned()
2777            .or_else(|| result.get("value").cloned())
2778            .unwrap_or(result);
2779        let tools_flat: Vec<Value> = groups
2780            .as_array()
2781            .map(|arr| {
2782                arr.iter()
2783                    .flat_map(|g| {
2784                        g.get("tools")
2785                            .and_then(|t| t.as_array())
2786                            .cloned()
2787                            .unwrap_or_default()
2788                    })
2789                    .collect()
2790            })
2791            .unwrap_or_default();
2792        Ok(json!({
2793            "groups": groups,
2794            "tools": tools_flat,
2795            "count": tools_flat.len(),
2796            "available": true,
2797        }))
2798    }
2799
2800    pub async fn devtools3p_exec(
2801        &mut self,
2802        name: &str,
2803        params_json: Option<&str>,
2804    ) -> Result<Value, CliError> {
2805        let _ = self.devtools3p_list().await?;
2806        let params = params_json.unwrap_or("{}");
2807        // Validate JSON object
2808        let parsed: Value = serde_json::from_str(params).map_err(|e| {
2809            CliError::with_suggestion(
2810                ErrorKind::Usage,
2811                format!("invalid params JSON: {e}"),
2812                r#"Pass --params '{"key":"value"}'"#,
2813            )
2814        })?;
2815        if !parsed.is_object() {
2816            return Err(CliError::with_suggestion(
2817                ErrorKind::Usage,
2818                "params must be a JSON object",
2819                r#"Pass --params '{"key":"value"}'"#,
2820            ));
2821        }
2822        let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2823        let params_js = parsed.to_string();
2824        let expr = format!(
2825            r#"(async () => {{
2826              if (!window.__dtmcp || typeof window.__dtmcp.executeTool !== 'function') {{
2827                throw new Error('No third-party tools discovered on page');
2828              }}
2829              const out = await window.__dtmcp.executeTool({name_js}, {params_js});
2830              try {{ return JSON.parse(JSON.stringify(out)); }} catch (_) {{ return String(out); }}
2831            }})()"#
2832        );
2833        let result = self.eval(&expr, None, Some("accept"), None).await?;
2834        if result.get("exceptionDetails").is_some() {
2835            return Err(CliError::with_suggestion(
2836                ErrorKind::NoInput,
2837                format!("devtools3p exec {name} failed"),
2838                "List tools with browser-automation-cli --category-third-party devtools3p list --url <page>",
2839            ));
2840        }
2841        let value = result
2842            .get("result")
2843            .cloned()
2844            .or_else(|| result.get("value").cloned())
2845            .unwrap_or(result);
2846        Ok(json!({
2847            "name": name,
2848            "result": value,
2849            "ok": true,
2850        }))
2851    }
2852
2853    /// List WebMCP / declarative tool forms on the page (Chrome 149+ features).
2854    pub async fn webmcp_list(&mut self) -> Result<Value, CliError> {
2855        self.pump_events().await;
2856        let expr = r#"(() => {
2857          const tools = [];
2858          // Declarative form-based tools (test harness / early WebMCP)
2859          document.querySelectorAll('form[toolname]').forEach((form) => {
2860            tools.push({
2861              name: form.getAttribute('toolname') || '',
2862              description: form.getAttribute('tooldescription') || '',
2863              source: 'form',
2864            });
2865          });
2866          // Future navigator surface (best-effort)
2867          try {
2868            if (navigator.modelContext && typeof navigator.modelContext.listTools === 'function') {
2869              // sync list not always available; ignore
2870            }
2871          } catch (_) {}
2872          if (window.__webmcpTools && Array.isArray(window.__webmcpTools)) {
2873            for (const t of window.__webmcpTools) {
2874              if (t && t.name) tools.push({ name: t.name, description: t.description || '', source: 'window' });
2875            }
2876          }
2877          return tools;
2878        })()"#;
2879        let result = self.eval(expr, None, Some("accept"), None).await?;
2880        let tools = result
2881            .get("result")
2882            .cloned()
2883            .or_else(|| result.get("value").cloned())
2884            .unwrap_or(result);
2885        let count = tools.as_array().map(|a| a.len()).unwrap_or(0);
2886        Ok(json!({
2887            "tools": tools,
2888            "count": count,
2889            "available": true,
2890            "note": "Requires Chrome with WebMCP/DevToolsWebMCPSupport for full surface; form[toolname] always listed",
2891        }))
2892    }
2893
2894    pub async fn webmcp_exec(
2895        &mut self,
2896        name: &str,
2897        input_json: Option<&str>,
2898    ) -> Result<Value, CliError> {
2899        let input = input_json.unwrap_or("{}");
2900        let parsed: Value = serde_json::from_str(input).map_err(|e| {
2901            CliError::with_suggestion(
2902                ErrorKind::Usage,
2903                format!("invalid input JSON: {e}"),
2904                r#"Pass --input '{"key":"value"}'"#,
2905            )
2906        })?;
2907        let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2908        let input_js = parsed.to_string();
2909        let expr = format!(
2910            r#"(async () => {{
2911              const toolName = {name_js};
2912              const input = {input_js};
2913              // Form-based tools
2914              const form = document.querySelector('form[toolname="' + CSS.escape(toolName) + '"]')
2915                || document.querySelector('form[toolname="' + toolName + '"]');
2916              if (form) {{
2917                return await new Promise((resolve, reject) => {{
2918                  const handler = (event) => {{
2919                    event.preventDefault();
2920                    try {{
2921                      if (typeof event.respondWith === 'function') {{
2922                        // page may set respondWith on submit
2923                      }}
2924                    }} catch (_) {{}}
2925                  }};
2926                  form.addEventListener('submit', handler, {{ once: true }});
2927                  // Prefer page-defined onsubmit
2928                  if (typeof form.onsubmit === 'function') {{
2929                    const fake = {{
2930                      preventDefault() {{}},
2931                      respondWith(v) {{ resolve({{ status: 'Completed', output: v }}); }},
2932                    }};
2933                    try {{
2934                      form.onsubmit(fake);
2935                      setTimeout(() => resolve({{ status: 'Completed', output: null }}), 0);
2936                    }} catch (e) {{
2937                      reject(e);
2938                    }}
2939                    return;
2940                  }}
2941                  form.requestSubmit ? form.requestSubmit() : form.submit();
2942                  setTimeout(() => resolve({{ status: 'Completed', output: null, note: 'form submitted' }}), 50);
2943                }});
2944              }}
2945              if (window.__webmcpTools) {{
2946                const t = window.__webmcpTools.find((x) => x.name === toolName);
2947                if (t && typeof t.execute === 'function') {{
2948                  const out = await t.execute(input);
2949                  return {{ status: 'Completed', output: out }};
2950                }}
2951              }}
2952              throw new Error('Tool ' + toolName + ' not found');
2953            }})()"#
2954        );
2955        let result = self.eval(&expr, None, Some("accept"), None).await?;
2956        if result.get("exceptionDetails").is_some() {
2957            let msg = result
2958                .pointer("/exceptionDetails/exception/description")
2959                .or_else(|| result.pointer("/exceptionDetails/text"))
2960                .and_then(|v| v.as_str())
2961                .unwrap_or("tool not found");
2962            return Err(CliError::with_suggestion(
2963                ErrorKind::NoInput,
2964                format!("webmcp exec {name}: {msg}"),
2965                "List tools first; page must expose form[toolname] or __webmcpTools",
2966            ));
2967        }
2968        let value = result
2969            .get("result")
2970            .cloned()
2971            .or_else(|| result.get("value").cloned())
2972            .unwrap_or(result);
2973        Ok(json!({
2974            "name": name,
2975            "result": value,
2976            "ok": true,
2977        }))
2978    }
2979
2980    /// Close CDP + wait/kill child (FINALIZE core).
2981    pub async fn shutdown(mut self) -> Result<(), CliError> {
2982        self.console_log.clear();
2983        self.network_log.clear();
2984        self.heap_chunks.clear();
2985        self.trace_chunks.clear();
2986        self.screencast_frames.clear();
2987        self.screencast_ack_ids.clear();
2988        self.screencast_dir = None;
2989        self.last_trace_body = None;
2990        self.ref_map.clear();
2991        self.manager.close().await.map_err(|e| {
2992            CliError::with_suggestion(
2993                ErrorKind::Browser,
2994                format!("Browser close failed: {e}"),
2995                "Process reaped by chromiumoxide finalize or Lightpanda process Drop",
2996            )
2997        })
2998    }
2999}
3000
3001fn verify_image_magic(path: &Path, format: &str) -> bool {
3002    let Ok(bytes) = std::fs::read(path) else {
3003        return false;
3004    };
3005    match format {
3006        "png" => bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
3007        "jpeg" | "jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]),
3008        "webp" => {
3009            bytes.len() >= 12
3010                && bytes[0..4] == [0x52, 0x49, 0x46, 0x46]
3011                && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
3012        }
3013        _ => !bytes.is_empty(),
3014    }
3015}
3016
3017/// Rewrite native `[ref=eN]` markers to agent-facing `[@eN]`.
3018pub fn tree_to_at_refs(tree: &str) -> String {
3019    let mut out = String::with_capacity(tree.len());
3020    let bytes = tree.as_bytes();
3021    let mut i = 0;
3022    while i < bytes.len() {
3023        if bytes[i..].starts_with(b"ref=") && i + 4 < bytes.len() && bytes[i + 4] == b'e' {
3024            out.push('@');
3025            i += 4;
3026            while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
3027                out.push(bytes[i] as char);
3028                i += 1;
3029            }
3030            continue;
3031        }
3032        out.push(bytes[i] as char);
3033        i += 1;
3034    }
3035    out
3036}
3037
3038/// Normalize JS for `Runtime.evaluate`.
3039///
3040/// - With `--args`, always call as `({expr})(arg0,…)`.
3041/// - Bare function / arrow: call once as `({expr})()`.
3042/// - Already-invoked IIFE ending in `)()`: leave as-is (never double-call).
3043/// - Plain expressions: leave as-is.
3044fn normalize_eval_expression(
3045    expression: &str,
3046    args_json: Option<&str>,
3047) -> Result<String, CliError> {
3048    if let Some(args_raw) = args_json {
3049        let uids: Vec<String> = serde_json::from_str(args_raw).map_err(|e| {
3050            CliError::with_suggestion(
3051                ErrorKind::Usage,
3052                format!("eval --args must be a JSON array of uids: {e}"),
3053                r#"Example: --args '["@e1","@e2"]'"#,
3054            )
3055        })?;
3056        let args_js: Vec<String> = uids
3057            .iter()
3058            .map(|u| {
3059                let cleaned = u.trim().trim_start_matches('@');
3060                format!("\"{cleaned}\"")
3061            })
3062            .collect();
3063        let joined = args_js.join(",");
3064        return Ok(format!("({expression})({joined})"));
3065    }
3066
3067    let trimmed = expression.trim();
3068    // Strip a single trailing semicolon for IIFE detection only.
3069    let for_detect = trimmed.trim_end_matches(';').trim_end();
3070    // Already invoked: `(...)()` or `(async ...)()` — re-wrapping yields "is not a function".
3071    if for_detect.ends_with(")()") {
3072        return Ok(expression.to_string());
3073    }
3074
3075    let head = trimmed.trim_start();
3076    let is_bare_callable = head.starts_with("function")
3077        || head.starts_with("async function")
3078        || (head.starts_with("async") && trimmed.contains("=>"))
3079        || (head.starts_with('(') && trimmed.contains("=>"));
3080
3081    if is_bare_callable {
3082        // Bare function / arrow needs a single call site.
3083        return Ok(format!("({expression})()"));
3084    }
3085
3086    Ok(expression.to_string())
3087}
3088
3089fn mark_launched(life: &Lifecycle, pid: Option<u32>) {
3090    if let Ok(mut ledger) = life.ledger.lock() {
3091        ledger.chrome_launched = true;
3092        ledger.chrome_pid = pid;
3093    }
3094}
3095
3096fn mark_closed(life: &Lifecycle) {
3097    if let Ok(mut ledger) = life.ledger.lock() {
3098        ledger.chrome_launched = false;
3099        ledger.chrome_pid = None;
3100        ledger.profile_dir = None;
3101    }
3102}
3103
3104async fn launch_marked(life: &Lifecycle, capture: CaptureOpts) -> Result<OneShotSession, CliError> {
3105    let session = OneShotSession::launch_headless_with_capture(capture).await?;
3106    mark_launched(life, session.chrome_pid());
3107    Ok(session)
3108}
3109
3110#[cfg(test)]
3111mod eval_normalize_tests {
3112    use super::normalize_eval_expression;
3113
3114    #[test]
3115    fn leaves_invoked_iife_alone() {
3116        let e = "(() => { return 9; })()";
3117        assert_eq!(normalize_eval_expression(e, None).unwrap(), e);
3118        let e2 = "(async () => 1)()";
3119        assert_eq!(normalize_eval_expression(e2, None).unwrap(), e2);
3120        let e3 = "(function(){ return 2; })()";
3121        assert_eq!(normalize_eval_expression(e3, None).unwrap(), e3);
3122    }
3123
3124    #[test]
3125    fn wraps_bare_arrow_once() {
3126        assert_eq!(
3127            normalize_eval_expression("() => 7", None).unwrap(),
3128            "(() => 7)()"
3129        );
3130        assert_eq!(
3131            normalize_eval_expression("async () => 3", None).unwrap(),
3132            "(async () => 3)()"
3133        );
3134    }
3135
3136    #[test]
3137    fn leaves_plain_expression() {
3138        assert_eq!(normalize_eval_expression("1+1", None).unwrap(), "1+1");
3139        assert_eq!(normalize_eval_expression("(1+1)", None).unwrap(), "(1+1)");
3140        assert_eq!(
3141            normalize_eval_expression("document.title", None).unwrap(),
3142            "document.title"
3143        );
3144    }
3145
3146    #[test]
3147    fn args_force_call() {
3148        let out = normalize_eval_expression("(el) => el", Some(r#"["@e1"]"#)).unwrap();
3149        assert_eq!(out, r#"((el) => el)("e1")"#);
3150    }
3151}
3152
3153async fn finish(
3154    life: &Lifecycle,
3155    session: OneShotSession,
3156    work_res: Result<Value, CliError>,
3157) -> Result<Value, CliError> {
3158    let close_res = session.shutdown().await;
3159    mark_closed(life);
3160    match (work_res, close_res) {
3161        (Ok(v), Ok(())) => Ok(v),
3162        (Err(e), _) => Err(e),
3163        (Ok(_), Err(e)) => Err(e),
3164    }
3165}
3166
3167pub async fn run_goto(life: &Lifecycle, url: &str) -> Result<Value, CliError> {
3168    run_goto_with_robots(
3169        life,
3170        url,
3171        CaptureOpts::default(),
3172        crate::robots::RobotsPolicy::Honor,
3173    )
3174    .await
3175}
3176
3177pub async fn run_goto_with_robots(
3178    life: &Lifecycle,
3179    url: &str,
3180    capture: CaptureOpts,
3181    robots: crate::robots::RobotsPolicy,
3182) -> Result<Value, CliError> {
3183    let mut session = launch_marked(life, capture).await?;
3184    let work = session.goto(url, robots).await;
3185    finish(life, session, work).await
3186}
3187
3188pub async fn run_scrape(
3189    life: &Lifecycle,
3190    url: &str,
3191    robots: crate::robots::RobotsPolicy,
3192    capture: CaptureOpts,
3193) -> Result<Value, CliError> {
3194    let mut session = launch_marked(life, capture).await?;
3195    let work = session.scrape(url, robots).await;
3196    finish(life, session, work).await
3197}
3198
3199pub async fn run_goto_capture(
3200    life: &Lifecycle,
3201    url: &str,
3202    capture: CaptureOpts,
3203) -> Result<Value, CliError> {
3204    run_goto_with_robots(life, url, capture, crate::robots::RobotsPolicy::Honor).await
3205}
3206
3207pub async fn run_view(
3208    life: &Lifecycle,
3209    verbose: bool,
3210    capture: CaptureOpts,
3211) -> Result<Value, CliError> {
3212    let mut session = launch_marked(life, capture).await?;
3213    let work = async {
3214        let _ = session
3215            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3216            .await?;
3217        session.view(verbose).await
3218    }
3219    .await;
3220    finish(life, session, work).await
3221}
3222
3223pub async fn run_press(
3224    life: &Lifecycle,
3225    target: &str,
3226    dblclick: bool,
3227    include_snapshot: bool,
3228    capture: CaptureOpts,
3229) -> Result<Value, CliError> {
3230    let mut session = launch_marked(life, capture).await?;
3231    let work = async {
3232        let _ = session
3233            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3234            .await?;
3235        session.press(target, dblclick, include_snapshot).await
3236    }
3237    .await;
3238    finish(life, session, work).await
3239}
3240
3241pub async fn run_write(
3242    life: &Lifecycle,
3243    target: &str,
3244    value: &str,
3245    include_snapshot: bool,
3246    capture: CaptureOpts,
3247) -> Result<Value, CliError> {
3248    let mut session = launch_marked(life, capture).await?;
3249    let work = async {
3250        let _ = session
3251            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3252            .await?;
3253        session.write(target, value, include_snapshot).await
3254    }
3255    .await;
3256    finish(life, session, work).await
3257}
3258
3259pub async fn run_keys(
3260    life: &Lifecycle,
3261    key: &str,
3262    include_snapshot: bool,
3263    capture: CaptureOpts,
3264) -> Result<Value, CliError> {
3265    let mut session = launch_marked(life, capture).await?;
3266    let work = async {
3267        let _ = session
3268            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3269            .await?;
3270        session.keys(key, include_snapshot).await
3271    }
3272    .await;
3273    finish(life, session, work).await
3274}
3275
3276pub async fn run_type(
3277    life: &Lifecycle,
3278    target: Option<&str>,
3279    text: &str,
3280    clear: bool,
3281    submit: Option<&str>,
3282    focus_only: bool,
3283    capture: CaptureOpts,
3284) -> Result<Value, CliError> {
3285    let mut session = launch_marked(life, capture).await?;
3286    let work = async {
3287        let _ = session
3288            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3289            .await?;
3290        session
3291            .type_text(target, text, clear, submit, focus_only)
3292            .await
3293    }
3294    .await;
3295    finish(life, session, work).await
3296}
3297
3298pub async fn run_with_session<F, Fut>(
3299    life: &Lifecycle,
3300    capture: CaptureOpts,
3301    work: F,
3302) -> Result<Value, CliError>
3303where
3304    F: FnOnce(OneShotSession) -> Fut,
3305    Fut: std::future::Future<Output = Result<(OneShotSession, Value), CliError>>,
3306{
3307    let session = launch_marked(life, capture).await?;
3308    match work(session).await {
3309        Ok((session, value)) => finish(life, session, Ok(value)).await,
3310        Err(e) => {
3311            mark_closed(life);
3312            Err(e)
3313        }
3314    }
3315}
3316
3317/// Block on tokio multi-thread runtime for one-shot browser work.
3318pub fn block_on_browser<F, T>(fut: F) -> Result<T, CliError>
3319where
3320    F: std::future::Future<Output = Result<T, CliError>>,
3321{
3322    block_on_browser_timeout(fut, 0)
3323}
3324
3325/// Like `block_on_browser`, but abort with `ErrorKind::Timeout` when `timeout_secs > 0`.
3326pub fn block_on_browser_timeout<F, T>(fut: F, timeout_secs: u64) -> Result<T, CliError>
3327where
3328    F: std::future::Future<Output = Result<T, CliError>>,
3329{
3330    let rt = tokio::runtime::Builder::new_multi_thread()
3331        .enable_all()
3332        .worker_threads(2)
3333        .thread_name("bac-browser")
3334        .build()
3335        .map_err(|e| {
3336            CliError::new(
3337                ErrorKind::Software,
3338                format!("Failed to create tokio runtime: {e}"),
3339            )
3340        })?;
3341    if timeout_secs == 0 {
3342        return rt.block_on(fut);
3343    }
3344    rt.block_on(async {
3345        match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), fut).await {
3346            Ok(inner) => inner,
3347            Err(_) => Err(CliError::with_suggestion(
3348                ErrorKind::Timeout,
3349                format!("operation exceeded --timeout {timeout_secs}s"),
3350                "Raise --timeout or reduce wait/navigation work",
3351            )),
3352        }
3353    })
3354}
3355
3356#[cfg(test)]
3357mod tests {
3358    use super::{is_internal_browser_url, is_noise_network_url, tree_to_at_refs};
3359    use crate::native::browser::WaitUntil;
3360    use serde_json::json;
3361
3362    #[test]
3363    fn tree_to_at_refs_rewrites_markers() {
3364        let raw = r#"- link "Home" [ref=e1]
3365  - button "Go" [checked=false, ref=e2]
3366"#;
3367        let out = tree_to_at_refs(raw);
3368        assert!(out.contains("[@e1]"), "out={out}");
3369        assert!(out.contains("@e2"), "out={out}");
3370    }
3371
3372    #[test]
3373    fn internal_browser_urls_filtered() {
3374        assert!(is_internal_browser_url("chrome://new-tab-page/"));
3375        assert!(is_internal_browser_url("chrome-extension://abc/x.js"));
3376        assert!(is_internal_browser_url("devtools://devtools/bundled/"));
3377        assert!(!is_internal_browser_url("https://example.com/"));
3378        assert!(!is_internal_browser_url("about:blank"));
3379        assert!(is_noise_network_url(
3380            "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
3381        ));
3382        assert!(is_noise_network_url("blob:https://example.com/uuid"));
3383        assert!(is_noise_network_url("chrome://new-tab-page/"));
3384        assert!(!is_noise_network_url("https://example.com/"));
3385    }
3386
3387    #[test]
3388    fn wait_until_tokens_parse() {
3389        assert_eq!(
3390            WaitUntil::parse_token("networkidle"),
3391            WaitUntil::NetworkIdle
3392        );
3393        assert_eq!(
3394            WaitUntil::parse_token("domcontentloaded"),
3395            WaitUntil::DomContentLoaded
3396        );
3397        assert_eq!(WaitUntil::parse_token("load"), WaitUntil::Load);
3398        assert_eq!(WaitUntil::parse_token("none"), WaitUntil::None);
3399    }
3400
3401    #[test]
3402    fn net_request_id_resolution_logic() {
3403        let requests = [
3404            json!({"requestId": "rid-1", "method": "GET", "url": "https://a.example/"}),
3405            json!({"requestId": "rid-2", "method": "POST", "url": "https://b.example/"}),
3406        ];
3407        let by_index = requests.get(1).unwrap();
3408        assert_eq!(by_index["requestId"], "rid-2");
3409        let by_rid = requests.iter().find(|r| r["requestId"] == "rid-1").unwrap();
3410        assert_eq!(by_rid["url"], "https://a.example/");
3411        // String id that is numeric index
3412        let idx: usize = "0".parse().unwrap();
3413        assert_eq!(requests[idx]["requestId"], "rid-1");
3414    }
3415}