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