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    // --- Layer 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        // Back-compat single text: treat as one-element OR set.
1777        let owned: Vec<String> = text.map(|t| vec![t.to_string()]).unwrap_or_default();
1778        self.wait_for_any(ms, &owned, selector, state, include_snapshot)
1779            .await
1780    }
1781
1782    /// Wait until any of `texts` appears (OR), and/or selector/state/ms.
1783    pub async fn wait_for_any(
1784        &mut self,
1785        ms: Option<u64>,
1786        texts: &[String],
1787        selector: Option<&str>,
1788        state: Option<&str>,
1789        include_snapshot: bool,
1790    ) -> Result<Value, CliError> {
1791        let mut waited = Vec::new();
1792        let has_text = !texts.is_empty();
1793
1794        if let Some(st) = state {
1795            let until = WaitUntil::parse_token(st);
1796            let session_id = self
1797                .manager
1798                .active_session_id()
1799                .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1800                .to_string();
1801            self.manager
1802                .wait_for_lifecycle_external(until, &session_id)
1803                .await
1804                .map_err(|e| {
1805                    CliError::with_suggestion(
1806                        ErrorKind::Timeout,
1807                        format!("wait state {st} failed: {e}"),
1808                        "Use --state load|domcontentloaded|networkidle|none",
1809                    )
1810                })?;
1811            waited.push(json!({"kind": "state", "state": st}));
1812        }
1813
1814        if let Some(m) = ms {
1815            if m > 0 && !has_text && selector.is_none() && state.is_none() {
1816                let data = self.wait_ms(m).await?;
1817                return self.attach_snapshot_if(include_snapshot, data).await;
1818            }
1819            if m > 0 && !has_text && selector.is_none() && state.is_some() {
1820                // pure ms after state already done above
1821                if m > 0 {
1822                    let _ = self.wait_ms(m).await?;
1823                    waited.push(json!({"kind": "ms", "ms": m}));
1824                }
1825                let data = json!({ "waited": waited, "ok": true });
1826                return self.attach_snapshot_if(include_snapshot, data).await;
1827            }
1828        }
1829
1830        if !has_text && selector.is_none() && state.is_some() {
1831            let data = json!({ "waited": waited, "ok": true });
1832            return self.attach_snapshot_if(include_snapshot, data).await;
1833        }
1834
1835        if !has_text && selector.is_none() && state.is_none() {
1836            let data = self.wait_ms(ms.unwrap_or(0)).await?;
1837            return self.attach_snapshot_if(include_snapshot, data).await;
1838        }
1839
1840        let deadline = std::time::Instant::now()
1841            + std::time::Duration::from_millis(ms.unwrap_or(10_000).max(1));
1842        loop {
1843            self.drain_events();
1844            if let Some(sel) = selector {
1845                let session_id = self
1846                    .manager
1847                    .active_session_id()
1848                    .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1849                    .to_string();
1850                if element::get_element_count(&self.manager.client, &session_id, sel)
1851                    .await
1852                    .unwrap_or(0)
1853                    > 0
1854                {
1855                    waited.push(json!({"kind": "selector", "selector": sel}));
1856                    let data = json!({ "waited": waited, "ok": true });
1857                    return self.attach_snapshot_if(include_snapshot, data).await;
1858                }
1859            }
1860            if has_text {
1861                let body = self
1862                    .manager
1863                    .evaluate("document.body ? document.body.innerText : ''", None)
1864                    .await
1865                    .unwrap_or(json!(""));
1866                let hay = body.as_str().unwrap_or("");
1867                if let Some(t) = texts.iter().find(|t| hay.contains(t.as_str())) {
1868                    waited.push(json!({"kind": "text", "text": t, "match": "any"}));
1869                    let data = json!({ "waited": waited, "ok": true });
1870                    return self.attach_snapshot_if(include_snapshot, data).await;
1871                }
1872            }
1873            if std::time::Instant::now() >= deadline {
1874                return Err(CliError::with_suggestion(
1875                    ErrorKind::Timeout,
1876                    "wait condition not met before deadline",
1877                    "Increase --ms, set --state, or ensure page content is present",
1878                ));
1879            }
1880            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1881        }
1882    }
1883
1884    #[allow(clippy::too_many_arguments)]
1885    pub async fn emulate(
1886        &mut self,
1887        user_agent: Option<&str>,
1888        locale: Option<&str>,
1889        timezone: Option<&str>,
1890        offline: bool,
1891        latitude: Option<f64>,
1892        longitude: Option<f64>,
1893        media: Option<&str>,
1894        network_conditions: Option<&str>,
1895        cpu_throttling_rate: Option<f64>,
1896        color_scheme: Option<&str>,
1897        extra_headers_json: Option<&str>,
1898        viewport: Option<&str>,
1899    ) -> Result<Value, CliError> {
1900        self.drain_events();
1901        let session_id = self
1902            .manager
1903            .active_session_id()
1904            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1905            .to_string();
1906        if let Some(ua) = user_agent {
1907            if ua.is_empty() {
1908                // clear override with empty UA not portable; skip
1909            } else {
1910                self.manager
1911                    .set_user_agent(ua)
1912                    .await
1913                    .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate ua: {e}")))?;
1914            }
1915        }
1916        if let Some(loc) = locale {
1917            self.manager
1918                .set_locale(loc)
1919                .await
1920                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate locale: {e}")))?;
1921        }
1922        if let Some(tz) = timezone {
1923            self.manager
1924                .set_timezone(tz)
1925                .await
1926                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate timezone: {e}")))?;
1927        }
1928
1929        let mut applied_network = None;
1930        if let Some(name) = network_conditions {
1931            let preset = crate::constants::network_preset_by_name(name).ok_or_else(|| {
1932                CliError::with_suggestion(
1933                    ErrorKind::Usage,
1934                    format!("unknown network conditions: {name}"),
1935                    format!(
1936                        "Use one of: {}",
1937                        crate::constants::network_preset_names().join(", ")
1938                    ),
1939                )
1940            })?;
1941            network::set_network_conditions(
1942                &self.manager.client,
1943                &session_id,
1944                preset.offline,
1945                preset.latency_ms,
1946                preset.download_throughput,
1947                preset.upload_throughput,
1948            )
1949            .await
1950            .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate network: {e}")))?;
1951            applied_network = Some(preset.name);
1952        } else if offline {
1953            network::set_offline(&self.manager.client, &session_id, true)
1954                .await
1955                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate offline: {e}")))?;
1956            applied_network = Some("Offline");
1957        }
1958
1959        if let Some(rate) = cpu_throttling_rate {
1960            let rate = rate.clamp(1.0, 20.0);
1961            network::set_cpu_throttling_rate(&self.manager.client, &session_id, rate)
1962                .await
1963                .map_err(|e| {
1964                    CliError::new(ErrorKind::Browser, format!("emulate cpu throttle: {e}"))
1965                })?;
1966        }
1967
1968        if let (Some(lat), Some(lon)) = (latitude, longitude) {
1969            self.manager
1970                .set_geolocation(lat, lon, Some(1.0))
1971                .await
1972                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate geo: {e}")))?;
1973        }
1974
1975        if let Some(scheme) = color_scheme {
1976            let value = match scheme.to_ascii_lowercase().as_str() {
1977                "dark" => "dark",
1978                "light" => "light",
1979                "auto" => "",
1980                other => {
1981                    return Err(CliError::with_suggestion(
1982                        ErrorKind::Usage,
1983                        format!("invalid color-scheme: {other}"),
1984                        "Use dark, light, or auto",
1985                    ));
1986                }
1987            };
1988            self.manager
1989                .set_emulated_media(
1990                    media,
1991                    Some(vec![("prefers-color-scheme".into(), value.into())]),
1992                )
1993                .await
1994                .map_err(|e| {
1995                    CliError::new(ErrorKind::Browser, format!("emulate color-scheme: {e}"))
1996                })?;
1997        } else if let Some(m) = media {
1998            self.manager
1999                .set_emulated_media(Some(m), None)
2000                .await
2001                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate media: {e}")))?;
2002        }
2003
2004        if let Some(headers_raw) = extra_headers_json {
2005            let map: HashMap<String, String> = if headers_raw.trim().is_empty() {
2006                HashMap::new()
2007            } else {
2008                serde_json::from_str(headers_raw).map_err(|e| {
2009                    CliError::with_suggestion(
2010                        ErrorKind::Usage,
2011                        format!("invalid extra-headers JSON: {e}"),
2012                        r#"Pass object JSON e.g. {"X-Custom":"1"}"#,
2013                    )
2014                })?
2015            };
2016            network::set_extra_headers(&self.manager.client, &session_id, &map)
2017                .await
2018                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate headers: {e}")))?;
2019        }
2020
2021        let mut applied_viewport = None;
2022        if let Some(vp) = viewport {
2023            let spec = crate::constants::parse_viewport_spec(vp).map_err(|e| {
2024                CliError::with_suggestion(
2025                    ErrorKind::Usage,
2026                    e,
2027                    "Format: WxHxDPR[,mobile][,touch][,landscape]",
2028                )
2029            })?;
2030            self.manager
2031                .set_viewport(
2032                    spec.width,
2033                    spec.height,
2034                    spec.device_scale_factor,
2035                    spec.mobile,
2036                )
2037                .await
2038                .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate viewport: {e}")))?;
2039            applied_viewport = Some(json!({
2040                "width": spec.width,
2041                "height": spec.height,
2042                "device_scale_factor": spec.device_scale_factor,
2043                "mobile": spec.mobile,
2044                "has_touch": spec.has_touch,
2045                "is_landscape": spec.is_landscape,
2046            }));
2047        }
2048
2049        Ok(json!({
2050            "emulated": true,
2051            "user_agent": user_agent,
2052            "locale": locale,
2053            "timezone": timezone,
2054            "offline": offline || applied_network == Some("Offline"),
2055            "latitude": latitude,
2056            "longitude": longitude,
2057            "media": media,
2058            "network_conditions": applied_network,
2059            "cpu_throttling_rate": cpu_throttling_rate,
2060            "color_scheme": color_scheme,
2061            "extra_headers": extra_headers_json.is_some(),
2062            "viewport": applied_viewport,
2063        }))
2064    }
2065
2066    pub async fn resize(
2067        &mut self,
2068        width: i32,
2069        height: i32,
2070        scale: f64,
2071        mobile: bool,
2072    ) -> Result<Value, CliError> {
2073        self.drain_events();
2074        self.manager
2075            .set_viewport(width, height, scale, mobile)
2076            .await
2077            .map_err(|e| CliError::new(ErrorKind::Browser, format!("resize failed: {e}")))?;
2078        Ok(json!({
2079            "width": width,
2080            "height": height,
2081            "scale": scale,
2082            "mobile": mobile,
2083        }))
2084    }
2085
2086    pub async fn perf_start(
2087        &mut self,
2088        path: Option<&Path>,
2089        reload: bool,
2090        auto_stop: bool,
2091    ) -> Result<Value, CliError> {
2092        self.drain_events();
2093        let session_id = self
2094            .manager
2095            .active_session_id()
2096            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2097            .to_string();
2098        self.trace_chunks.clear();
2099        self.manager
2100            .client
2101            .send_command(
2102                "Tracing.start",
2103                Some(json!({
2104                    "categories": "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-devtools.timeline",
2105                    "transferMode": "ReportEvents",
2106                })),
2107                None,
2108            )
2109            .await
2110            .map_err(|e| CliError::new(ErrorKind::Browser, format!("perf start: {e}")))?;
2111        let _ = self
2112            .manager
2113            .client
2114            .send_command_no_params("Performance.enable", Some(&session_id))
2115            .await;
2116        self.perf_active = true;
2117        if reload {
2118            let _ = self.reload(false).await?;
2119        }
2120        let mut out = json!({
2121            "perf": "start",
2122            "path": path.map(|p| p.to_string_lossy().to_string()),
2123            "reload": reload,
2124            "auto_stop": auto_stop,
2125        });
2126        if auto_stop {
2127            // tool-ref autoStop: stop after load/reload settles
2128            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2129            let stop = self.perf_stop(path).await?;
2130            if let Some(obj) = out.as_object_mut() {
2131                obj.insert("auto_stopped".into(), json!(true));
2132                obj.insert("stop".into(), stop);
2133            }
2134        }
2135        Ok(out)
2136    }
2137
2138    pub async fn perf_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2139        self.pump_events().await;
2140        self.tracing_complete = false;
2141        if self.perf_active {
2142            let _ = self
2143                .manager
2144                .client
2145                .send_command("Tracing.end", None, None)
2146                .await;
2147            self.perf_active = false;
2148        }
2149        // Wait for dataCollected + tracingComplete (up to ~5s).
2150        for _ in 0..100 {
2151            self.pump_events().await;
2152            if self.tracing_complete && !self.trace_chunks.is_empty() {
2153                for _ in 0..5 {
2154                    self.pump_events().await;
2155                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2156                }
2157                break;
2158            }
2159            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2160        }
2161        let body = self.trace_chunks.join("\n");
2162        let chunks = self.trace_chunks.len();
2163        self.last_trace_body = Some(body.clone());
2164        let mut out_path = path.map(|p| p.to_path_buf());
2165        if out_path.is_none() {
2166            // Default artifact so insight can always read a file after stop.
2167            let stamp = std::time::SystemTime::now()
2168                .duration_since(std::time::UNIX_EPOCH)
2169                .map(|d| d.as_millis())
2170                .unwrap_or(0);
2171            out_path = Some(PathBuf::from(format!("trace-{stamp}.ndjson")));
2172        }
2173        if let Some(ref p) = out_path {
2174            if let Some(parent) = p.parent() {
2175                if !parent.as_os_str().is_empty() {
2176                    std::fs::create_dir_all(parent).map_err(|e| {
2177                        CliError::new(ErrorKind::Io, format!("perf stop mkdir: {e}"))
2178                    })?;
2179                }
2180            }
2181            std::fs::write(p, body.as_bytes())
2182                .map_err(|e| CliError::new(ErrorKind::Io, format!("perf stop write: {e}")))?;
2183            self.last_trace_path = Some(p.clone());
2184        }
2185        self.trace_chunks.clear();
2186        self.tracing_complete = false;
2187        // Synthetic insight sets for tool-ref performance_analyze_insight flow
2188        let set_id = format!(
2189            "set-{}",
2190            std::time::SystemTime::now()
2191                .duration_since(std::time::UNIX_EPOCH)
2192                .map(|d| d.as_millis())
2193                .unwrap_or(0)
2194        );
2195        Ok(json!({
2196            "perf": "stop",
2197            "path": out_path.map(|p| p.to_string_lossy().to_string()),
2198            "events": chunks,
2199            "available_insight_sets": [{
2200                "insight_set_id": set_id,
2201                "insights": [
2202                    "DocumentLatency",
2203                    "LCPBreakdown",
2204                    "CLSCulprits",
2205                    "INPBreakdown",
2206                    "RenderBlocking",
2207                    "ThirdParties"
2208                ]
2209            }],
2210        }))
2211    }
2212
2213    pub async fn perf_insight(
2214        &mut self,
2215        name: Option<&str>,
2216        insight_set_id: Option<&str>,
2217    ) -> Result<Value, CliError> {
2218        self.pump_events().await;
2219        let session_id = self
2220            .manager
2221            .active_session_id()
2222            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2223            .to_string();
2224        let live_metrics = self
2225            .manager
2226            .client
2227            .send_command("Performance.getMetrics", None, Some(&session_id))
2228            .await
2229            .ok();
2230
2231        let offline = if let Some(ref p) = self.last_trace_path {
2232            crate::native::perf_insight::analyze_file(p, name).ok()
2233        } else if let Some(ref body) = self.last_trace_body {
2234            crate::native::perf_insight::analyze_text(body, name, None).ok()
2235        } else {
2236            None
2237        };
2238
2239        Ok(json!({
2240            "perf": "insight",
2241            "name": name,
2242            "insight_name": name,
2243            "insight_set_id": insight_set_id,
2244            "live_metrics": live_metrics,
2245            "trace_insight": offline,
2246            "trace_path": self.last_trace_path.as_ref().map(|p| p.to_string_lossy().to_string()),
2247        }))
2248    }
2249
2250    /// Offline insight from a previously written trace path (no browser required).
2251    pub fn perf_insight_file(path: &Path, name: Option<&str>) -> Result<Value, CliError> {
2252        crate::native::perf_insight::analyze_file(path, name).map_err(|e| {
2253            CliError::with_suggestion(ErrorKind::Io, e, "Pass a path produced by perf stop --path")
2254        })
2255    }
2256
2257    pub async fn screencast_start(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2258        self.pump_events().await;
2259        let session_id = self
2260            .manager
2261            .active_session_id()
2262            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2263            .to_string();
2264        self.screencast_frames.clear();
2265        self.screencast_ack_ids.clear();
2266        let dir = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2267            let stamp = std::time::SystemTime::now()
2268                .duration_since(std::time::UNIX_EPOCH)
2269                .map(|d| d.as_millis())
2270                .unwrap_or(0);
2271            PathBuf::from(format!("screencast-{stamp}"))
2272        });
2273        std::fs::create_dir_all(&dir)
2274            .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast dir: {e}")))?;
2275        self.screencast_dir = Some(dir.clone());
2276        // Page domain must be enabled for screencast frames.
2277        let _ = self
2278            .manager
2279            .client
2280            .send_command_no_params("Page.enable", Some(&session_id))
2281            .await;
2282        self.manager
2283            .client
2284            .send_command(
2285                "Page.startScreencast",
2286                Some(json!({
2287                    "format": "png",
2288                    "quality": 60,
2289                    "maxWidth": 1280,
2290                    "maxHeight": 720,
2291                    "everyNthFrame": 1,
2292                })),
2293                Some(&session_id),
2294            )
2295            .await
2296            .map_err(|e| CliError::new(ErrorKind::Browser, format!("screencast start: {e}")))?;
2297        self.screencast_active = true;
2298        // Pump a few frames immediately so FrameAck unblocks the pipeline.
2299        for _ in 0..15 {
2300            self.pump_events().await;
2301            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2302        }
2303        Ok(json!({
2304            "screencast": "start",
2305            "dir": dir.to_string_lossy(),
2306            "note": "Frames buffered in process; stop writes PNG files + manifest.json",
2307            "frames_buffered": self.screencast_frames.len(),
2308        }))
2309    }
2310
2311    pub async fn screencast_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2312        for _ in 0..40 {
2313            self.pump_events().await;
2314            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2315        }
2316        let session_id = self
2317            .manager
2318            .active_session_id()
2319            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2320            .to_string();
2321        if self.screencast_active {
2322            let _ = self
2323                .manager
2324                .client
2325                .send_command("Page.stopScreencast", None, Some(&session_id))
2326                .await;
2327            self.screencast_active = false;
2328        }
2329        self.pump_events().await;
2330
2331        let dir = self.screencast_dir.clone().unwrap_or_else(|| {
2332            PathBuf::from(format!(
2333                "screencast-{}",
2334                std::time::SystemTime::now()
2335                    .duration_since(std::time::UNIX_EPOCH)
2336                    .map(|d| d.as_millis())
2337                    .unwrap_or(0)
2338            ))
2339        });
2340        std::fs::create_dir_all(&dir)
2341            .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast stop mkdir: {e}")))?;
2342
2343        use base64::Engine;
2344        let engine = base64::engine::general_purpose::STANDARD;
2345        let mut written = 0u64;
2346        let mut paths: Vec<String> = Vec::new();
2347        for (i, b64) in self.screencast_frames.iter().enumerate() {
2348            let bytes = match engine.decode(b64) {
2349                Ok(b) => b,
2350                Err(_) => continue,
2351            };
2352            let name = format!("frame-{:05}.png", i + 1);
2353            let out = dir.join(&name);
2354            if std::fs::write(&out, &bytes).is_ok() {
2355                written += 1;
2356                paths.push(out.to_string_lossy().into_owned());
2357            }
2358        }
2359        let video_path = path.map(|p| p.to_path_buf()).or_else(|| {
2360            // If start path looked like a video file, encode there
2361            self.screencast_dir.as_ref().and_then(|d| {
2362                let s = d.to_string_lossy();
2363                if s.ends_with(".webm") || s.ends_with(".mp4") {
2364                    Some(d.clone())
2365                } else {
2366                    None
2367                }
2368            })
2369        });
2370        let mut video_out: Option<String> = None;
2371        let mut encode_note: Option<String> = None;
2372        if let Some(ref vp) = video_path {
2373            let ext = vp
2374                .extension()
2375                .and_then(|e| e.to_str())
2376                .unwrap_or("mp4")
2377                .to_ascii_lowercase();
2378            let is_video = ext == "webm" || ext == "mp4";
2379            if is_video && written > 0 {
2380                if let Some(parent) = vp.parent() {
2381                    if !parent.as_os_str().is_empty() {
2382                        let _ = std::fs::create_dir_all(parent);
2383                    }
2384                }
2385                let pattern = dir.join("frame-%05d.png");
2386                let vcodec = if ext == "webm" {
2387                    "libvpx-vp9"
2388                } else {
2389                    "libx264"
2390                };
2391                let mut cmd = std::process::Command::new("ffmpeg");
2392                cmd.arg("-y")
2393                    .arg("-framerate")
2394                    .arg("10")
2395                    .arg("-i")
2396                    .arg(&pattern)
2397                    .arg("-c:v")
2398                    .arg(vcodec)
2399                    .arg("-pix_fmt")
2400                    .arg("yuv420p")
2401                    .arg(vp);
2402                match cmd.output() {
2403                    Ok(out) if out.status.success() => {
2404                        video_out = Some(vp.to_string_lossy().into_owned());
2405                        encode_note = Some("encoded via ffmpeg".into());
2406                    }
2407                    Ok(out) => {
2408                        encode_note = Some(format!(
2409                            "ffmpeg failed: {}",
2410                            String::from_utf8_lossy(&out.stderr)
2411                        ));
2412                    }
2413                    Err(e) => {
2414                        encode_note =
2415                            Some(format!("ffmpeg not available: {e}; PNG frames kept in dir"));
2416                    }
2417                }
2418            }
2419        }
2420        let manifest = json!({
2421            "format": "png",
2422            "frame_count": written,
2423            "frames": paths,
2424            "video": video_out,
2425            "encode_note": encode_note,
2426            "ffmpeg_hint": format!(
2427                "ffmpeg -y -framerate 10 -i {}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p {}.mp4",
2428                dir.display(),
2429                dir.display()
2430            ),
2431        });
2432        let manifest_path = dir.join("manifest.json");
2433        let _ = std::fs::write(
2434            &manifest_path,
2435            serde_json::to_vec_pretty(&manifest).unwrap_or_default(),
2436        );
2437        self.screencast_frames.clear();
2438        self.screencast_ack_ids.clear();
2439        Ok(json!({
2440            "screencast": "stop",
2441            "dir": dir.to_string_lossy(),
2442            "frame_count": written,
2443            "manifest": manifest_path.to_string_lossy(),
2444            "video": video_out,
2445            "encode_note": encode_note,
2446        }))
2447    }
2448
2449    pub async fn heap_take(&mut self, path: &Path) -> Result<Value, CliError> {
2450        self.drain_events();
2451        let session_id = self
2452            .manager
2453            .active_session_id()
2454            .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2455            .to_string();
2456        self.heap_chunks.clear();
2457        self.heap_snapshot_finished = false;
2458        let _ = self
2459            .manager
2460            .client
2461            .send_command_no_params("HeapProfiler.enable", Some(&session_id))
2462            .await;
2463        self.manager
2464            .client
2465            .send_command(
2466                "HeapProfiler.takeHeapSnapshot",
2467                Some(json!({ "reportProgress": true })),
2468                Some(&session_id),
2469            )
2470            .await
2471            .map_err(|e| CliError::new(ErrorKind::Browser, format!("heap take: {e}")))?;
2472        // Wait for chunks + progress finished (up to ~10s).
2473        for _ in 0..200 {
2474            self.drain_events();
2475            if self.heap_snapshot_finished && !self.heap_chunks.is_empty() {
2476                // Drain a few more ticks for trailing chunks.
2477                for _ in 0..10 {
2478                    self.drain_events();
2479                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2480                }
2481                break;
2482            }
2483            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2484        }
2485        // Final drain
2486        for _ in 0..20 {
2487            self.drain_events();
2488            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2489        }
2490        if let Some(parent) = path.parent() {
2491            if !parent.as_os_str().is_empty() {
2492                std::fs::create_dir_all(parent)
2493                    .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take mkdir: {e}")))?;
2494            }
2495        }
2496        let body = self.heap_chunks.join("");
2497        let bytes = body.len();
2498        if bytes == 0 {
2499            return Err(CliError::with_suggestion(
2500                ErrorKind::Browser,
2501                "heap take produced empty snapshot (no HeapProfiler chunks received)",
2502                "Ensure Chrome supports HeapProfiler; re-run doctor; check event forwarders",
2503            ));
2504        }
2505        std::fs::write(path, body.as_bytes())
2506            .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take write: {e}")))?;
2507        self.heap_chunks.clear();
2508        self.heap_snapshot_finished = false;
2509        Ok(json!({
2510            "heap": "take",
2511            "path": path.to_string_lossy(),
2512            "bytes": bytes,
2513        }))
2514    }
2515
2516    pub fn heap_file_summary(path: &Path) -> Result<Value, CliError> {
2517        crate::native::heap_snapshot::summarize(path).map_err(|e| {
2518            CliError::with_suggestion(
2519                ErrorKind::Io,
2520                e,
2521                "Pass a path produced by heap take (.heapsnapshot JSON)",
2522            )
2523        })
2524    }
2525
2526    pub fn heap_close(path: &Path) -> Result<Value, CliError> {
2527        crate::native::heap_snapshot::close_snapshot(path).map_err(|e| {
2528            CliError::with_suggestion(
2529                ErrorKind::Io,
2530                e,
2531                "Pass a path produced by heap take (.heapsnapshot JSON)",
2532            )
2533        })
2534    }
2535
2536    pub fn heap_compare(base: &Path, current: &Path) -> Result<Value, CliError> {
2537        crate::native::heap_snapshot::compare(base, current).map_err(|e| {
2538            CliError::with_suggestion(ErrorKind::Io, e, "Pass two paths produced by heap take")
2539        })
2540    }
2541
2542    pub fn heap_details(path: &Path) -> Result<Value, CliError> {
2543        crate::native::heap_snapshot::details(path).map_err(|e| {
2544            CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2545        })
2546    }
2547
2548    pub fn heap_dup_strings(path: &Path) -> Result<Value, CliError> {
2549        crate::native::heap_snapshot::duplicate_strings(path).map_err(|e| {
2550            CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2551        })
2552    }
2553
2554    pub fn heap_class_nodes(path: &Path, id: u64) -> Result<Value, CliError> {
2555        crate::native::heap_snapshot::class_nodes(path, id).map_err(|e| {
2556            CliError::with_suggestion(
2557                ErrorKind::Io,
2558                e,
2559                "Pass a valid .heapsnapshot path and class id",
2560            )
2561        })
2562    }
2563
2564    pub fn heap_node_op(path: &Path, node: u64, op: &str) -> Result<Value, CliError> {
2565        crate::native::heap_snapshot::node_op(path, node, op).map_err(|e| {
2566            CliError::with_suggestion(
2567                ErrorKind::Io,
2568                e,
2569                "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2570            )
2571        })
2572    }
2573
2574    /// Offline object details for one node id (distance, retained size, detachedness).
2575    pub fn heap_object_details(path: &Path, node: u64) -> Result<Value, CliError> {
2576        crate::native::heap_snapshot::object_details(path, node).map_err(|e| {
2577            CliError::with_suggestion(
2578                ErrorKind::Io,
2579                e,
2580                "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2581            )
2582        })
2583    }
2584
2585    pub async fn extension_list(&mut self) -> Result<Value, CliError> {
2586        self.pump_events().await;
2587        let targets = self
2588            .manager
2589            .client
2590            .send_command("Target.getTargets", None, None)
2591            .await
2592            .map_err(|e| CliError::new(ErrorKind::Browser, format!("extension list: {e}")))?;
2593        let list = targets
2594            .get("targetInfos")
2595            .and_then(|v| v.as_array())
2596            .cloned()
2597            .unwrap_or_default();
2598        let extensions: Vec<Value> = list
2599            .into_iter()
2600            .filter(|t| {
2601                t.get("url")
2602                    .and_then(|u| u.as_str())
2603                    .map(|u| u.starts_with("chrome-extension://"))
2604                    .unwrap_or(false)
2605                    || t.get("type").and_then(|x| x.as_str()) == Some("service_worker")
2606            })
2607            .map(|t| {
2608                let url = t.get("url").and_then(|u| u.as_str()).unwrap_or("");
2609                let id = url
2610                    .strip_prefix("chrome-extension://")
2611                    .and_then(|rest| rest.split('/').next())
2612                    .unwrap_or("")
2613                    .to_string();
2614                json!({
2615                    "id": id,
2616                    "url": url,
2617                    "type": t.get("type"),
2618                    "title": t.get("title"),
2619                    "targetId": t.get("targetId"),
2620                })
2621            })
2622            .collect();
2623        Ok(json!({ "extensions": extensions, "count": extensions.len() }))
2624    }
2625
2626    /// Reload extension service worker target by id prefix (one-shot CDP).
2627    pub async fn extension_reload(&mut self, id: &str) -> Result<Value, CliError> {
2628        self.pump_events().await;
2629        let listed = self.extension_list().await?;
2630        let targets = listed
2631            .get("extensions")
2632            .and_then(|v| v.as_array())
2633            .cloned()
2634            .unwrap_or_default();
2635        let match_t = targets.iter().find(|t| {
2636            t.get("id")
2637                .and_then(|v| v.as_str())
2638                .map(|s| s == id || s.starts_with(id) || id.contains(s))
2639                .unwrap_or(false)
2640        });
2641        let Some(t) = match_t else {
2642            return Err(CliError::with_suggestion(
2643                ErrorKind::NoInput,
2644                format!("extension id not found: {id}"),
2645                "Run extension list after extension install <unpacked-dir>",
2646            ));
2647        };
2648        let target_id = t
2649            .get("targetId")
2650            .and_then(|v| v.as_str())
2651            .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2652            .to_string();
2653        // Close then rely on Chrome to re-spawn the extension SW on next attach.
2654        let _ = self
2655            .manager
2656            .client
2657            .send_command(
2658                "Target.closeTarget",
2659                Some(json!({ "targetId": target_id })),
2660                None,
2661            )
2662            .await;
2663        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2664        let again = self.extension_list().await?;
2665        Ok(json!({
2666            "reloaded": id,
2667            "closed_target": target_id,
2668            "after": again,
2669            "one_shot": true,
2670            "ok": true,
2671            "note": "one-shot SW restart via Target.closeTarget; install path is --load-extension on the same invocation",
2672        }))
2673    }
2674
2675    pub async fn extension_trigger(&mut self, id: &str) -> Result<Value, CliError> {
2676        self.pump_events().await;
2677        let listed = self.extension_list().await?;
2678        let targets = listed
2679            .get("extensions")
2680            .and_then(|v| v.as_array())
2681            .cloned()
2682            .unwrap_or_default();
2683        let match_t = targets.iter().find(|t| {
2684            t.get("id")
2685                .and_then(|v| v.as_str())
2686                .map(|s| s == id || s.starts_with(id))
2687                .unwrap_or(false)
2688                && t.get("type").and_then(|v| v.as_str()) == Some("service_worker")
2689        });
2690        let Some(t) = match_t else {
2691            return Err(CliError::with_suggestion(
2692                ErrorKind::NoInput,
2693                format!("extension service_worker not found for id: {id}"),
2694                "Use extension list; trigger requires a service_worker target",
2695            ));
2696        };
2697        let target_id = t
2698            .get("targetId")
2699            .and_then(|v| v.as_str())
2700            .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2701            .to_string();
2702        // Attach and try chrome.runtime / action APIs when available.
2703        let attach = self
2704            .manager
2705            .client
2706            .send_command(
2707                "Target.attachToTarget",
2708                Some(json!({ "targetId": target_id, "flatten": true })),
2709                None,
2710            )
2711            .await
2712            .map_err(|e| CliError::new(ErrorKind::Browser, format!("attach extension SW: {e}")))?;
2713        let session = attach
2714            .get("sessionId")
2715            .and_then(|v| v.as_str())
2716            .map(|s| s.to_string());
2717        let eval = self
2718            .manager
2719            .client
2720            .send_command(
2721                "Runtime.evaluate",
2722                Some(json!({
2723                    "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) }; } })()",
2724                    "returnByValue": true,
2725                    "awaitPromise": true,
2726                })),
2727                session.as_deref(),
2728            )
2729            .await;
2730        Ok(json!({
2731            "triggered": id,
2732            "targetId": target_id,
2733            "evaluate": eval.unwrap_or(Value::Null),
2734            "one_shot": true,
2735            "ok": true,
2736            "note": "best-effort SW Runtime.evaluate in the same process; popup UI may need headed Chrome",
2737        }))
2738    }
2739
2740    /// Discover third-party developer tools via `devtoolstooldiscovery` CustomEvent.
2741    pub async fn devtools3p_list(&mut self) -> Result<Value, CliError> {
2742        self.pump_events().await;
2743        let expr = r#"(() => {
2744          return new Promise((resolve) => {
2745            if (!window.__dtmcp) window.__dtmcp = {};
2746            window.__dtmcp.toolGroups = [];
2747            const groups = [];
2748            const event = new CustomEvent('devtoolstooldiscovery');
2749            event.respondWith = (toolGroup) => {
2750              if (!toolGroup || typeof toolGroup.name !== 'string' || !Array.isArray(toolGroup.tools)) {
2751                return;
2752              }
2753              const tools = [];
2754              for (const tool of toolGroup.tools) {
2755                if (!tool || typeof tool.name !== 'string') continue;
2756                tools.push({
2757                  name: tool.name,
2758                  description: typeof tool.description === 'string' ? tool.description : '',
2759                  inputSchema: tool.inputSchema || {},
2760                });
2761              }
2762              const g = {
2763                name: toolGroup.name,
2764                description: typeof toolGroup.description === 'string' ? toolGroup.description : '',
2765                tools,
2766              };
2767              groups.push(g);
2768              window.__dtmcp.toolGroups.push({
2769                name: g.name,
2770                description: g.description,
2771                tools: toolGroup.tools,
2772              });
2773              if (!window.__dtmcp.executeTool) {
2774                window.__dtmcp.executeTool = async (toolName, args) => {
2775                  for (const group of (window.__dtmcp.toolGroups || [])) {
2776                    const t = (group.tools || []).find((x) => x.name === toolName);
2777                    if (t && typeof t.execute === 'function') {
2778                      return await t.execute(args || {});
2779                    }
2780                  }
2781                  throw new Error('Tool ' + toolName + ' not found');
2782                };
2783              }
2784            };
2785            window.dispatchEvent(event);
2786            setTimeout(() => resolve(groups), 0);
2787          });
2788        })()"#;
2789        let result = self.eval(expr, None, Some("accept"), None).await?;
2790        let groups = result
2791            .get("result")
2792            .cloned()
2793            .or_else(|| result.get("value").cloned())
2794            .unwrap_or(result);
2795        let tools_flat: Vec<Value> = groups
2796            .as_array()
2797            .map(|arr| {
2798                arr.iter()
2799                    .flat_map(|g| {
2800                        g.get("tools")
2801                            .and_then(|t| t.as_array())
2802                            .cloned()
2803                            .unwrap_or_default()
2804                    })
2805                    .collect()
2806            })
2807            .unwrap_or_default();
2808        Ok(json!({
2809            "groups": groups,
2810            "tools": tools_flat,
2811            "count": tools_flat.len(),
2812            "available": true,
2813        }))
2814    }
2815
2816    pub async fn devtools3p_exec(
2817        &mut self,
2818        name: &str,
2819        params_json: Option<&str>,
2820    ) -> Result<Value, CliError> {
2821        let _ = self.devtools3p_list().await?;
2822        let params = params_json.unwrap_or("{}");
2823        // Validate JSON object
2824        let parsed: Value = serde_json::from_str(params).map_err(|e| {
2825            CliError::with_suggestion(
2826                ErrorKind::Usage,
2827                format!("invalid params JSON: {e}"),
2828                r#"Pass --params '{"key":"value"}'"#,
2829            )
2830        })?;
2831        if !parsed.is_object() {
2832            return Err(CliError::with_suggestion(
2833                ErrorKind::Usage,
2834                "params must be a JSON object",
2835                r#"Pass --params '{"key":"value"}'"#,
2836            ));
2837        }
2838        let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2839        let params_js = parsed.to_string();
2840        let expr = format!(
2841            r#"(async () => {{
2842              if (!window.__dtmcp || typeof window.__dtmcp.executeTool !== 'function') {{
2843                throw new Error('No third-party tools discovered on page');
2844              }}
2845              const out = await window.__dtmcp.executeTool({name_js}, {params_js});
2846              try {{ return JSON.parse(JSON.stringify(out)); }} catch (_) {{ return String(out); }}
2847            }})()"#
2848        );
2849        let result = self.eval(&expr, None, Some("accept"), None).await?;
2850        if result.get("exceptionDetails").is_some() {
2851            return Err(CliError::with_suggestion(
2852                ErrorKind::NoInput,
2853                format!("devtools3p exec {name} failed"),
2854                "List tools with browser-automation-cli --category-third-party devtools3p list --url <page>",
2855            ));
2856        }
2857        let value = result
2858            .get("result")
2859            .cloned()
2860            .or_else(|| result.get("value").cloned())
2861            .unwrap_or(result);
2862        Ok(json!({
2863            "name": name,
2864            "result": value,
2865            "ok": true,
2866        }))
2867    }
2868
2869    /// List WebMCP / declarative tool forms on the page (Chrome 149+ features).
2870    pub async fn webmcp_list(&mut self) -> Result<Value, CliError> {
2871        self.pump_events().await;
2872        let expr = r#"(() => {
2873          const tools = [];
2874          // Declarative form-based tools (test harness / early WebMCP)
2875          document.querySelectorAll('form[toolname]').forEach((form) => {
2876            tools.push({
2877              name: form.getAttribute('toolname') || '',
2878              description: form.getAttribute('tooldescription') || '',
2879              source: 'form',
2880            });
2881          });
2882          // Future navigator surface (best-effort)
2883          try {
2884            if (navigator.modelContext && typeof navigator.modelContext.listTools === 'function') {
2885              // sync list not always available; ignore
2886            }
2887          } catch (_) {}
2888          if (window.__webmcpTools && Array.isArray(window.__webmcpTools)) {
2889            for (const t of window.__webmcpTools) {
2890              if (t && t.name) tools.push({ name: t.name, description: t.description || '', source: 'window' });
2891            }
2892          }
2893          return tools;
2894        })()"#;
2895        let result = self.eval(expr, None, Some("accept"), None).await?;
2896        let tools = result
2897            .get("result")
2898            .cloned()
2899            .or_else(|| result.get("value").cloned())
2900            .unwrap_or(result);
2901        let count = tools.as_array().map(|a| a.len()).unwrap_or(0);
2902        Ok(json!({
2903            "tools": tools,
2904            "count": count,
2905            "available": true,
2906            "note": "Requires Chrome with WebMCP/DevToolsWebMCPSupport for full surface; form[toolname] always listed",
2907        }))
2908    }
2909
2910    pub async fn webmcp_exec(
2911        &mut self,
2912        name: &str,
2913        input_json: Option<&str>,
2914    ) -> Result<Value, CliError> {
2915        let input = input_json.unwrap_or("{}");
2916        let parsed: Value = serde_json::from_str(input).map_err(|e| {
2917            CliError::with_suggestion(
2918                ErrorKind::Usage,
2919                format!("invalid input JSON: {e}"),
2920                r#"Pass --input '{"key":"value"}'"#,
2921            )
2922        })?;
2923        let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2924        let input_js = parsed.to_string();
2925        let expr = format!(
2926            r#"(async () => {{
2927              const toolName = {name_js};
2928              const input = {input_js};
2929              // Form-based tools
2930              const form = document.querySelector('form[toolname="' + CSS.escape(toolName) + '"]')
2931                || document.querySelector('form[toolname="' + toolName + '"]');
2932              if (form) {{
2933                return await new Promise((resolve, reject) => {{
2934                  const handler = (event) => {{
2935                    event.preventDefault();
2936                    try {{
2937                      if (typeof event.respondWith === 'function') {{
2938                        // page may set respondWith on submit
2939                      }}
2940                    }} catch (_) {{}}
2941                  }};
2942                  form.addEventListener('submit', handler, {{ once: true }});
2943                  // Prefer page-defined onsubmit
2944                  if (typeof form.onsubmit === 'function') {{
2945                    const fake = {{
2946                      preventDefault() {{}},
2947                      respondWith(v) {{ resolve({{ status: 'Completed', output: v }}); }},
2948                    }};
2949                    try {{
2950                      form.onsubmit(fake);
2951                      setTimeout(() => resolve({{ status: 'Completed', output: null }}), 0);
2952                    }} catch (e) {{
2953                      reject(e);
2954                    }}
2955                    return;
2956                  }}
2957                  form.requestSubmit ? form.requestSubmit() : form.submit();
2958                  setTimeout(() => resolve({{ status: 'Completed', output: null, note: 'form submitted' }}), 50);
2959                }});
2960              }}
2961              if (window.__webmcpTools) {{
2962                const t = window.__webmcpTools.find((x) => x.name === toolName);
2963                if (t && typeof t.execute === 'function') {{
2964                  const out = await t.execute(input);
2965                  return {{ status: 'Completed', output: out }};
2966                }}
2967              }}
2968              throw new Error('Tool ' + toolName + ' not found');
2969            }})()"#
2970        );
2971        let result = self.eval(&expr, None, Some("accept"), None).await?;
2972        if result.get("exceptionDetails").is_some() {
2973            let msg = result
2974                .pointer("/exceptionDetails/exception/description")
2975                .or_else(|| result.pointer("/exceptionDetails/text"))
2976                .and_then(|v| v.as_str())
2977                .unwrap_or("tool not found");
2978            return Err(CliError::with_suggestion(
2979                ErrorKind::NoInput,
2980                format!("webmcp exec {name}: {msg}"),
2981                "List tools first; page must expose form[toolname] or __webmcpTools",
2982            ));
2983        }
2984        let value = result
2985            .get("result")
2986            .cloned()
2987            .or_else(|| result.get("value").cloned())
2988            .unwrap_or(result);
2989        Ok(json!({
2990            "name": name,
2991            "result": value,
2992            "ok": true,
2993        }))
2994    }
2995
2996    /// Close CDP + wait/kill child (FINALIZE core).
2997    pub async fn shutdown(mut self) -> Result<(), CliError> {
2998        self.console_log.clear();
2999        self.network_log.clear();
3000        self.heap_chunks.clear();
3001        self.trace_chunks.clear();
3002        self.screencast_frames.clear();
3003        self.screencast_ack_ids.clear();
3004        self.screencast_dir = None;
3005        self.last_trace_body = None;
3006        self.ref_map.clear();
3007        self.manager.close().await.map_err(|e| {
3008            CliError::with_suggestion(
3009                ErrorKind::Browser,
3010                format!("Browser close failed: {e}"),
3011                "Process reaped by chromiumoxide finalize or Lightpanda process Drop",
3012            )
3013        })
3014    }
3015}
3016
3017fn verify_image_magic(path: &Path, format: &str) -> bool {
3018    let Ok(bytes) = std::fs::read(path) else {
3019        return false;
3020    };
3021    match format {
3022        "png" => bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
3023        "jpeg" | "jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]),
3024        "webp" => {
3025            bytes.len() >= 12
3026                && bytes[0..4] == [0x52, 0x49, 0x46, 0x46]
3027                && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
3028        }
3029        _ => !bytes.is_empty(),
3030    }
3031}
3032
3033/// Rewrite native `[ref=eN]` markers to agent-facing `[@eN]`.
3034pub fn tree_to_at_refs(tree: &str) -> String {
3035    let mut out = String::with_capacity(tree.len());
3036    let bytes = tree.as_bytes();
3037    let mut i = 0;
3038    while i < bytes.len() {
3039        if bytes[i..].starts_with(b"ref=") && i + 4 < bytes.len() && bytes[i + 4] == b'e' {
3040            out.push('@');
3041            i += 4;
3042            while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
3043                out.push(bytes[i] as char);
3044                i += 1;
3045            }
3046            continue;
3047        }
3048        out.push(bytes[i] as char);
3049        i += 1;
3050    }
3051    out
3052}
3053
3054/// Normalize JS for `Runtime.evaluate`.
3055///
3056/// - With `--args`, always call as `({expr})(arg0,…)`.
3057/// - Bare function / arrow: call once as `({expr})()`.
3058/// - Already-invoked IIFE ending in `)()`: leave as-is (never double-call).
3059/// - Plain expressions: leave as-is.
3060fn normalize_eval_expression(
3061    expression: &str,
3062    args_json: Option<&str>,
3063) -> Result<String, CliError> {
3064    if let Some(args_raw) = args_json {
3065        let uids: Vec<String> = serde_json::from_str(args_raw).map_err(|e| {
3066            CliError::with_suggestion(
3067                ErrorKind::Usage,
3068                format!("eval --args must be a JSON array of uids: {e}"),
3069                r#"Example: --args '["@e1","@e2"]'"#,
3070            )
3071        })?;
3072        let args_js: Vec<String> = uids
3073            .iter()
3074            .map(|u| {
3075                let cleaned = u.trim().trim_start_matches('@');
3076                format!("\"{cleaned}\"")
3077            })
3078            .collect();
3079        let joined = args_js.join(",");
3080        return Ok(format!("({expression})({joined})"));
3081    }
3082
3083    let trimmed = expression.trim();
3084    // Strip a single trailing semicolon for IIFE detection only.
3085    let for_detect = trimmed.trim_end_matches(';').trim_end();
3086    // Already invoked: `(...)()` or `(async ...)()` — re-wrapping yields "is not a function".
3087    if for_detect.ends_with(")()") {
3088        return Ok(expression.to_string());
3089    }
3090
3091    let head = trimmed.trim_start();
3092    let is_bare_callable = head.starts_with("function")
3093        || head.starts_with("async function")
3094        || (head.starts_with("async") && trimmed.contains("=>"))
3095        || (head.starts_with('(') && trimmed.contains("=>"));
3096
3097    if is_bare_callable {
3098        // Bare function / arrow needs a single call site.
3099        return Ok(format!("({expression})()"));
3100    }
3101
3102    Ok(expression.to_string())
3103}
3104
3105fn mark_launched(life: &Lifecycle, pid: Option<u32>) {
3106    if let Ok(mut ledger) = life.ledger.lock() {
3107        ledger.chrome_launched = true;
3108        ledger.chrome_pid = pid;
3109    }
3110}
3111
3112fn mark_closed(life: &Lifecycle) {
3113    if let Ok(mut ledger) = life.ledger.lock() {
3114        ledger.chrome_launched = false;
3115        ledger.chrome_pid = None;
3116        ledger.profile_dir = None;
3117    }
3118}
3119
3120async fn launch_marked(life: &Lifecycle, capture: CaptureOpts) -> Result<OneShotSession, CliError> {
3121    let session = OneShotSession::launch_headless_with_capture(capture).await?;
3122    mark_launched(life, session.chrome_pid());
3123    Ok(session)
3124}
3125
3126#[cfg(test)]
3127mod eval_normalize_tests {
3128    use super::normalize_eval_expression;
3129
3130    #[test]
3131    fn leaves_invoked_iife_alone() {
3132        let e = "(() => { return 9; })()";
3133        assert_eq!(normalize_eval_expression(e, None).unwrap(), e);
3134        let e2 = "(async () => 1)()";
3135        assert_eq!(normalize_eval_expression(e2, None).unwrap(), e2);
3136        let e3 = "(function(){ return 2; })()";
3137        assert_eq!(normalize_eval_expression(e3, None).unwrap(), e3);
3138    }
3139
3140    #[test]
3141    fn wraps_bare_arrow_once() {
3142        assert_eq!(
3143            normalize_eval_expression("() => 7", None).unwrap(),
3144            "(() => 7)()"
3145        );
3146        assert_eq!(
3147            normalize_eval_expression("async () => 3", None).unwrap(),
3148            "(async () => 3)()"
3149        );
3150    }
3151
3152    #[test]
3153    fn leaves_plain_expression() {
3154        assert_eq!(normalize_eval_expression("1+1", None).unwrap(), "1+1");
3155        assert_eq!(normalize_eval_expression("(1+1)", None).unwrap(), "(1+1)");
3156        assert_eq!(
3157            normalize_eval_expression("document.title", None).unwrap(),
3158            "document.title"
3159        );
3160    }
3161
3162    #[test]
3163    fn args_force_call() {
3164        let out = normalize_eval_expression("(el) => el", Some(r#"["@e1"]"#)).unwrap();
3165        assert_eq!(out, r#"((el) => el)("e1")"#);
3166    }
3167}
3168
3169async fn finish(
3170    life: &Lifecycle,
3171    session: OneShotSession,
3172    work_res: Result<Value, CliError>,
3173) -> Result<Value, CliError> {
3174    let close_res = session.shutdown().await;
3175    mark_closed(life);
3176    match (work_res, close_res) {
3177        (Ok(v), Ok(())) => Ok(v),
3178        (Err(e), _) => Err(e),
3179        (Ok(_), Err(e)) => Err(e),
3180    }
3181}
3182
3183pub async fn run_goto(life: &Lifecycle, url: &str) -> Result<Value, CliError> {
3184    run_goto_with_robots(
3185        life,
3186        url,
3187        CaptureOpts::default(),
3188        crate::robots::RobotsPolicy::Honor,
3189    )
3190    .await
3191}
3192
3193pub async fn run_goto_with_robots(
3194    life: &Lifecycle,
3195    url: &str,
3196    capture: CaptureOpts,
3197    robots: crate::robots::RobotsPolicy,
3198) -> Result<Value, CliError> {
3199    let mut session = launch_marked(life, capture).await?;
3200    let work = session.goto(url, robots).await;
3201    finish(life, session, work).await
3202}
3203
3204pub async fn run_scrape(
3205    life: &Lifecycle,
3206    url: &str,
3207    robots: crate::robots::RobotsPolicy,
3208    capture: CaptureOpts,
3209) -> Result<Value, CliError> {
3210    let mut session = launch_marked(life, capture).await?;
3211    let work = session.scrape(url, robots).await;
3212    finish(life, session, work).await
3213}
3214
3215pub async fn run_goto_capture(
3216    life: &Lifecycle,
3217    url: &str,
3218    capture: CaptureOpts,
3219) -> Result<Value, CliError> {
3220    run_goto_with_robots(life, url, capture, crate::robots::RobotsPolicy::Honor).await
3221}
3222
3223pub async fn run_view(
3224    life: &Lifecycle,
3225    verbose: bool,
3226    capture: CaptureOpts,
3227) -> Result<Value, CliError> {
3228    let mut session = launch_marked(life, capture).await?;
3229    let work = async {
3230        let _ = session
3231            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3232            .await?;
3233        session.view(verbose).await
3234    }
3235    .await;
3236    finish(life, session, work).await
3237}
3238
3239pub async fn run_press(
3240    life: &Lifecycle,
3241    target: &str,
3242    dblclick: bool,
3243    include_snapshot: bool,
3244    capture: CaptureOpts,
3245) -> Result<Value, CliError> {
3246    let mut session = launch_marked(life, capture).await?;
3247    let work = async {
3248        let _ = session
3249            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3250            .await?;
3251        session.press(target, dblclick, include_snapshot).await
3252    }
3253    .await;
3254    finish(life, session, work).await
3255}
3256
3257pub async fn run_write(
3258    life: &Lifecycle,
3259    target: &str,
3260    value: &str,
3261    include_snapshot: bool,
3262    capture: CaptureOpts,
3263) -> Result<Value, CliError> {
3264    let mut session = launch_marked(life, capture).await?;
3265    let work = async {
3266        let _ = session
3267            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3268            .await?;
3269        session.write(target, value, include_snapshot).await
3270    }
3271    .await;
3272    finish(life, session, work).await
3273}
3274
3275pub async fn run_keys(
3276    life: &Lifecycle,
3277    key: &str,
3278    include_snapshot: bool,
3279    capture: CaptureOpts,
3280) -> Result<Value, CliError> {
3281    let mut session = launch_marked(life, capture).await?;
3282    let work = async {
3283        let _ = session
3284            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3285            .await?;
3286        session.keys(key, include_snapshot).await
3287    }
3288    .await;
3289    finish(life, session, work).await
3290}
3291
3292pub async fn run_type(
3293    life: &Lifecycle,
3294    target: Option<&str>,
3295    text: &str,
3296    clear: bool,
3297    submit: Option<&str>,
3298    focus_only: bool,
3299    capture: CaptureOpts,
3300) -> Result<Value, CliError> {
3301    let mut session = launch_marked(life, capture).await?;
3302    let work = async {
3303        let _ = session
3304            .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3305            .await?;
3306        session
3307            .type_text(target, text, clear, submit, focus_only)
3308            .await
3309    }
3310    .await;
3311    finish(life, session, work).await
3312}
3313
3314pub async fn run_with_session<F, Fut>(
3315    life: &Lifecycle,
3316    capture: CaptureOpts,
3317    work: F,
3318) -> Result<Value, CliError>
3319where
3320    F: FnOnce(OneShotSession) -> Fut,
3321    Fut: std::future::Future<Output = Result<(OneShotSession, Value), CliError>>,
3322{
3323    let session = launch_marked(life, capture).await?;
3324    match work(session).await {
3325        Ok((session, value)) => finish(life, session, Ok(value)).await,
3326        Err(e) => {
3327            mark_closed(life);
3328            Err(e)
3329        }
3330    }
3331}
3332
3333/// Block on tokio multi-thread runtime for one-shot browser work.
3334pub fn block_on_browser<F, T>(fut: F) -> Result<T, CliError>
3335where
3336    F: std::future::Future<Output = Result<T, CliError>>,
3337{
3338    block_on_browser_timeout(fut, 0)
3339}
3340
3341/// Like `block_on_browser`, but abort with `ErrorKind::Timeout` when `timeout_secs > 0`.
3342pub fn block_on_browser_timeout<F, T>(fut: F, timeout_secs: u64) -> Result<T, CliError>
3343where
3344    F: std::future::Future<Output = Result<T, CliError>>,
3345{
3346    let rt = tokio::runtime::Builder::new_multi_thread()
3347        .enable_all()
3348        .worker_threads(2)
3349        .thread_name("bac-browser")
3350        .build()
3351        .map_err(|e| {
3352            CliError::new(
3353                ErrorKind::Software,
3354                format!("Failed to create tokio runtime: {e}"),
3355            )
3356        })?;
3357    if timeout_secs == 0 {
3358        return rt.block_on(fut);
3359    }
3360    rt.block_on(async {
3361        match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), fut).await {
3362            Ok(inner) => inner,
3363            Err(_) => Err(CliError::with_suggestion(
3364                ErrorKind::Timeout,
3365                format!("operation exceeded --timeout {timeout_secs}s"),
3366                "Raise --timeout or reduce wait/navigation work",
3367            )),
3368        }
3369    })
3370}
3371
3372#[cfg(test)]
3373mod tests {
3374    use super::{is_internal_browser_url, is_noise_network_url, tree_to_at_refs};
3375    use crate::native::browser::WaitUntil;
3376    use serde_json::json;
3377
3378    #[test]
3379    fn tree_to_at_refs_rewrites_markers() {
3380        let raw = r#"- link "Home" [ref=e1]
3381  - button "Go" [checked=false, ref=e2]
3382"#;
3383        let out = tree_to_at_refs(raw);
3384        assert!(out.contains("[@e1]"), "out={out}");
3385        assert!(out.contains("@e2"), "out={out}");
3386    }
3387
3388    #[test]
3389    fn internal_browser_urls_filtered() {
3390        assert!(is_internal_browser_url("chrome://new-tab-page/"));
3391        assert!(is_internal_browser_url("chrome-extension://abc/x.js"));
3392        assert!(is_internal_browser_url("devtools://devtools/bundled/"));
3393        assert!(!is_internal_browser_url("https://example.com/"));
3394        assert!(!is_internal_browser_url("about:blank"));
3395        assert!(is_noise_network_url(
3396            "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
3397        ));
3398        assert!(is_noise_network_url("blob:https://example.com/uuid"));
3399        assert!(is_noise_network_url("chrome://new-tab-page/"));
3400        assert!(!is_noise_network_url("https://example.com/"));
3401    }
3402
3403    #[test]
3404    fn wait_until_tokens_parse() {
3405        assert_eq!(
3406            WaitUntil::parse_token("networkidle"),
3407            WaitUntil::NetworkIdle
3408        );
3409        assert_eq!(
3410            WaitUntil::parse_token("domcontentloaded"),
3411            WaitUntil::DomContentLoaded
3412        );
3413        assert_eq!(WaitUntil::parse_token("load"), WaitUntil::Load);
3414        assert_eq!(WaitUntil::parse_token("none"), WaitUntil::None);
3415    }
3416
3417    #[test]
3418    fn net_request_id_resolution_logic() {
3419        let requests = [
3420            json!({"requestId": "rid-1", "method": "GET", "url": "https://a.example/"}),
3421            json!({"requestId": "rid-2", "method": "POST", "url": "https://b.example/"}),
3422        ];
3423        let by_index = requests.get(1).unwrap();
3424        assert_eq!(by_index["requestId"], "rid-2");
3425        let by_rid = requests.iter().find(|r| r["requestId"] == "rid-1").unwrap();
3426        assert_eq!(by_rid["url"], "https://a.example/");
3427        // String id that is numeric index
3428        let idx: usize = "0".parse().unwrap();
3429        assert_eq!(requests[idx]["requestId"], "rid-1");
3430    }
3431}