Skip to main content

browser_automation_cli/browser/
mod.rs

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