Skip to main content

browser_automation_cli/browser/
mod.rs

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