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