Skip to main content

browser_automation_cli/native/
browser.rs

1#![allow(missing_docs)]
2use serde_json::{json, Value};
3use std::collections::{HashMap, HashSet};
4use std::future::Future;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::sync::{broadcast, Mutex};
8
9use super::cdp::chrome::LaunchOptions;
10use super::cdp::client::CdpClient;
11use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
12use super::cdp::types::*;
13use super::element::{resolve_element_object_id, RefMap};
14
15// ---------------------------------------------------------------------------
16// Launch validation
17// ---------------------------------------------------------------------------
18
19/// Validates launch/connect options for incompatible combinations.
20/// Returns `Ok(())` if valid, or `Err(msg)` with a user-friendly error.
21pub fn validate_launch_options(
22    extensions: Option<&[String]>,
23    has_cdp: bool,
24    profile: Option<&str>,
25    storage_state: Option<&str>,
26    allow_file_access: bool,
27    executable_path: Option<&str>,
28) -> Result<(), String> {
29    let has_extensions = extensions.map(|e| !e.is_empty()).unwrap_or(false);
30
31    if has_extensions && has_cdp {
32        return Err(
33            "Cannot use extensions with cdp_url (extensions require local browser launch)"
34                .to_string(),
35        );
36    }
37    if profile.is_some() && has_cdp {
38        return Err(
39            "Cannot use profile with cdp_url (profile requires local browser launch)".to_string(),
40        );
41    }
42    if storage_state.is_some() && profile.is_some() {
43        return Err("Cannot use storage_state with profile".to_string());
44    }
45    if storage_state.is_some() && has_extensions {
46        return Err("Cannot use storage_state with extensions".to_string());
47    }
48    if allow_file_access {
49        if let Some(path) = executable_path {
50            let lower = path.to_lowercase();
51            if lower.contains("firefox") || lower.contains("webkit") || lower.contains("safari") {
52                return Err(
53                    "allow_file_access is not supported with non-Chromium browsers".to_string(),
54                );
55            }
56        }
57    }
58    Ok(())
59}
60
61/// Validates that Chrome-only options are not used with Lightpanda.
62fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
63    if options
64        .extensions
65        .as_ref()
66        .map(|e| !e.is_empty())
67        .unwrap_or(false)
68    {
69        return Err("Extensions are not supported with Lightpanda".to_string());
70    }
71    if options.profile.is_some() {
72        return Err("Profiles are not supported with Lightpanda".to_string());
73    }
74    if options.storage_state.is_some() {
75        return Err("Storage state is not supported with Lightpanda".to_string());
76    }
77    if options.allow_file_access {
78        return Err("File access is not supported with Lightpanda".to_string());
79    }
80    if !options.headless {
81        return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
82    }
83    if options.webgpu {
84        return Err("WebGPU (--webgpu) is not supported with Lightpanda".to_string());
85    }
86    if !options.args.is_empty() {
87        return Err(
88            "Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
89        );
90    }
91    Ok(())
92}
93
94/// Returns true for Chrome internal targets that should not be selected
95/// during auto-connect (e.g. chrome://, chrome-extension://, devtools://).
96fn is_internal_chrome_target(url: &str) -> bool {
97    url.starts_with("chrome://")
98        || url.starts_with("chrome-extension://")
99        || url.starts_with("devtools://")
100}
101
102pub(crate) fn should_track_target(target: &TargetInfo) -> bool {
103    (target.target_type == "page" || target.target_type == "webview")
104        && (target.url.is_empty() || !is_internal_chrome_target(&target.url))
105}
106
107fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
108    if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
109        page.url = target.url.clone();
110        page.title = target.title.clone();
111        page.target_type = target.target_type.clone();
112        return true;
113    }
114    false
115}
116
117fn active_page_index_after_removal(
118    active_page_index: usize,
119    removed_index: usize,
120    remaining_pages: usize,
121) -> usize {
122    if remaining_pages == 0 {
123        return 0;
124    }
125
126    if removed_index < active_page_index {
127        return active_page_index - 1;
128    }
129
130    if active_page_index >= remaining_pages {
131        return remaining_pages - 1;
132    }
133
134    active_page_index
135}
136
137/// Converts common error messages into AI-friendly, actionable descriptions.
138pub fn to_ai_friendly_error(error: &str) -> String {
139    let lower = error.to_lowercase();
140    if lower.contains("strict mode violation") {
141        return "Element matched multiple results. Use a more specific selector.".to_string();
142    }
143    if lower.contains("element is not visible") {
144        return "Element exists but is not visible. Wait for it to become visible or scroll it into view."
145            .to_string();
146    }
147    if lower.contains("intercept") {
148        return "Another element is covering the target element. Try scrolling or closing overlays."
149            .to_string();
150    }
151    if lower.contains("timeout") {
152        return "Operation timed out. The page may still be loading or the element may not exist."
153            .to_string();
154    }
155    if lower.contains("element not found") || lower.contains("no element") {
156        return "Element not found. Verify the selector is correct and the element exists in the DOM."
157            .to_string();
158    }
159    error.to_string()
160}
161
162#[derive(Debug, Clone)]
163pub struct PageInfo {
164    pub tab_id: u32,
165    /// Optional user-assigned label (e.g. "docs", "app"). Set via
166    /// `tab new --label <name>`. Labels are agent-assigned and never
167    /// auto-generated, never rewritten on navigation, and unique within a
168    /// session. Agents use labels instead of `t<N>` for readable multi-tab
169    /// workflows.
170    pub label: Option<String>,
171    pub target_id: String,
172    pub session_id: String,
173    pub url: String,
174    pub title: String,
175    pub target_type: String, // "page" or "webview"
176}
177
178/// Canonical string form of a stable tab id: `t1`, `t2`, ... The `t` prefix
179/// disambiguates stable ids from positional indices (which the CLI no longer
180/// accepts) and matches the `@e<N>` convention used for element refs.
181pub fn format_tab_id(tab_id: u32) -> String {
182    format!("t{}", tab_id)
183}
184
185/// A tab reference as parsed from CLI/JSON input. Either a stable id like
186/// `t2` or a user-assigned label like `docs`.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum TabRef {
189    Id(u32),
190    Label(String),
191}
192
193impl TabRef {
194    /// Parse a user-supplied string tab reference. Rejects bare integers
195    /// with a teaching error so agents and scripts don't silently confuse
196    /// stable ids with positional indices.
197    pub fn parse(input: &str) -> Result<Self, String> {
198        let input = input.trim();
199        if input.is_empty() {
200            return Err("Empty tab reference; expected `t<N>` (e.g. `t2`) or a label".to_string());
201        }
202        if let Some(digits) = input.strip_prefix('t').or_else(|| input.strip_prefix('T')) {
203            if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
204                let id: u32 = digits.parse().map_err(|_| {
205                    format!(
206                        "Tab id `{}` out of range; ids are incrementing positive integers",
207                        input
208                    )
209                })?;
210                if id == 0 {
211                    return Err(format!(
212                        "Tab id `{}` is invalid; tab ids start at t1",
213                        input
214                    ));
215                }
216                return Ok(TabRef::Id(id));
217            }
218        }
219        if input.chars().all(|c| c.is_ascii_digit()) {
220            return Err(format!(
221                "Expected a tab id like `t{}` or a label; positional integers are not accepted \
222                 (run `browser-automation-cli tab` to list stable tab ids)",
223                input
224            ));
225        }
226        if !is_valid_label(input) {
227            return Err(format!(
228                "Invalid tab label `{}`; labels must start with a letter and contain only \
229                 letters, digits, `-`, and `_`",
230                input
231            ));
232        }
233        Ok(TabRef::Label(input.to_string()))
234    }
235}
236
237/// Labels must look like identifiers: start with a letter, contain only
238/// letters/digits/dashes/underscores. This keeps them distinguishable from
239/// `t<N>` ids at a glance and safe to pass through shells without quoting.
240pub fn is_valid_label(s: &str) -> bool {
241    let mut chars = s.chars();
242    match chars.next() {
243        Some(c) if c.is_ascii_alphabetic() => {}
244        _ => return false,
245    }
246    chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum WaitUntil {
251    Load,
252    DomContentLoaded,
253    NetworkIdle,
254    None,
255}
256
257impl std::str::FromStr for WaitUntil {
258    type Err = std::convert::Infallible;
259
260    fn from_str(s: &str) -> Result<Self, Self::Err> {
261        Ok(match s {
262            "domcontentloaded" => Self::DomContentLoaded,
263            "networkidle" => Self::NetworkIdle,
264            "none" => Self::None,
265            _ => Self::Load,
266        })
267    }
268}
269
270impl WaitUntil {
271    /// Parse wait-until token; unknown values map to `Load`.
272    pub fn parse_token(s: &str) -> Self {
273        s.parse().unwrap_or(Self::Load)
274    }
275}
276
277/// Child process for engines we spawn ourselves (Lightpanda only).
278/// Chrome one-shot is owned by chromiumoxide (`owns_oxide_browser`).
279pub enum BrowserProcess {
280    Lightpanda(LightpandaProcess),
281}
282
283impl BrowserProcess {
284    pub fn kill(&mut self) {
285        match self {
286            BrowserProcess::Lightpanda(p) => p.kill(),
287        }
288    }
289
290    pub fn wait_or_kill(&mut self, _timeout: std::time::Duration) {
291        match self {
292            BrowserProcess::Lightpanda(p) => p.kill(),
293        }
294    }
295
296    /// Non-blocking check whether the browser process has exited.
297    pub fn has_exited(&mut self) -> bool {
298        match self {
299            BrowserProcess::Lightpanda(_) => false,
300        }
301    }
302
303    pub fn id(&self) -> Option<u32> {
304        match self {
305            BrowserProcess::Lightpanda(_) => None,
306        }
307    }
308}
309
310pub struct BrowserManager {
311    pub client: Arc<CdpClient>,
312    browser_process: Option<BrowserProcess>,
313    /// True when this manager owns a chromiumoxide-launched Chrome (FINALIZE close/wait/kill).
314    owns_oxide_browser: bool,
315    ws_url: String,
316    pages: Vec<PageInfo>,
317    active_page_index: usize,
318    default_timeout_ms: u64,
319    /// Stored download path from launch options, re-applied to new contexts (e.g., recording)
320    pub download_path: Option<String>,
321    /// Whether to ignore HTTPS certificate errors, re-applied to new contexts (e.g., recording)
322    pub ignore_https_errors: bool,
323    /// Origins visited during this session, used by save_state to collect cross-origin localStorage.
324    visited_origins: HashSet<String>,
325    next_tab_id: u32,
326    /// True when the CDP WebSocket is already scoped to a page target and
327    /// browser-level Target.* commands are not available.
328    direct_page: bool,
329    /// One-shot temp Chrome user-data-dir to remove after FINALIZE.
330    temp_user_data_dir: Option<std::path::PathBuf>,
331}
332
333const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
334const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
335const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
336
337impl BrowserManager {
338    /// OS pid of the launched Chrome process, if this manager owns one.
339    pub fn chrome_pid(&self) -> Option<u32> {
340        if let Some(ref process) = self.browser_process {
341            return process.id();
342        }
343        None
344    }
345
346    pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
347        let engine = engine.unwrap_or("chrome");
348
349        match engine {
350            "chrome" => {
351                validate_launch_options(
352                    options.extensions.as_deref(),
353                    false,
354                    options.profile.as_deref(),
355                    options.storage_state.as_deref(),
356                    options.allow_file_access,
357                    options.executable_path.as_deref(),
358                )?;
359            }
360            "lightpanda" => {
361                validate_lightpanda_options(&options)?;
362            }
363            _ => {
364                return Err(format!(
365                    "Unknown engine '{}'. Supported engines: chrome, lightpanda",
366                    engine
367                ));
368            }
369        }
370
371        let ignore_https_errors = options.ignore_https_errors;
372        let user_agent = options.user_agent.clone();
373        let color_scheme = options.color_scheme.clone();
374        let download_path = options.download_path.clone();
375
376        let manager = if engine == "lightpanda" {
377            let lp_options = LightpandaLaunchOptions {
378                executable_path: options.executable_path.clone(),
379                proxy: options.proxy.clone(),
380                port: None,
381            };
382            let lp = launch_lightpanda(&lp_options).await?;
383            let ws_url = lp.ws_url.clone();
384            let process = BrowserProcess::Lightpanda(lp);
385            let mut manager = initialize_lightpanda_manager(ws_url, process).await?;
386            manager.owns_oxide_browser = false;
387            manager
388        } else {
389            // Chrome one-shot: single chromiumoxide stack (Browser::launch only — no dual WS).
390            let launched = super::cdp::oxide::launch_with_oxide(&options).await?;
391            let ws_url = launched.ws_url.clone();
392            let temp_user_data_dir = launched.temp_user_data_dir.clone();
393            let client =
394                Arc::new(CdpClient::from_browser(launched.browser, launched.handler).await?);
395            let mut manager = Self {
396                client,
397                browser_process: None,
398                owns_oxide_browser: true,
399                ws_url,
400                pages: Vec::new(),
401                active_page_index: 0,
402                default_timeout_ms: 25_000,
403                download_path: download_path.clone(),
404                ignore_https_errors,
405                visited_origins: HashSet::new(),
406                next_tab_id: 1,
407                direct_page: false,
408                temp_user_data_dir,
409            };
410            manager.discover_and_attach_targets().await?;
411            manager
412        };
413
414        let session_id = manager.active_session_id()?.to_string();
415
416        if ignore_https_errors {
417            let _ = manager
418                .client
419                .send_command(
420                    "Security.setIgnoreCertificateErrors",
421                    Some(json!({ "ignore": true })),
422                    Some(&session_id),
423                )
424                .await;
425        }
426
427        if let Some(ref ua) = user_agent {
428            let _ = manager
429                .client
430                .send_command(
431                    "Emulation.setUserAgentOverride",
432                    Some(json!({ "userAgent": ua })),
433                    Some(&session_id),
434                )
435                .await;
436        }
437
438        if let Some(ref scheme) = color_scheme {
439            let _ = manager
440                .client
441                .send_command(
442                    "Emulation.setEmulatedMedia",
443                    Some(json!({ "features": [{ "name": "prefers-color-scheme", "value": scheme }] })),
444                    Some(&session_id),
445                )
446                .await;
447        }
448
449        if let Some(ref path) = download_path {
450            let _ = manager
451                .client
452                .send_command(
453                    "Browser.setDownloadBehavior",
454                    Some(json!({ "behavior": "allow", "downloadPath": path })),
455                    None,
456                )
457                .await;
458        }
459
460        Ok(manager)
461    }
462
463    async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
464        self.client
465            .send_command_typed::<_, Value>(
466                "Target.setDiscoverTargets",
467                &SetDiscoverTargetsParams { discover: true },
468                None,
469            )
470            .await?;
471
472        let result: GetTargetsResult = self
473            .client
474            .send_command_typed("Target.getTargets", &json!({}), None)
475            .await?;
476
477        let page_targets: Vec<TargetInfo> = result
478            .target_infos
479            .into_iter()
480            .filter(should_track_target)
481            .collect();
482
483        if page_targets.is_empty() {
484            // Create a new tab
485            let result: CreateTargetResult = self
486                .client
487                .send_command_typed(
488                    "Target.createTarget",
489                    &CreateTargetParams {
490                        url: "about:blank".to_string(),
491                        browser_context_id: None,
492                    },
493                    None,
494                )
495                .await?;
496
497            let attach_result: AttachToTargetResult = self
498                .client
499                .send_command_typed(
500                    "Target.attachToTarget",
501                    &AttachToTargetParams {
502                        target_id: result.target_id.clone(),
503                        flatten: true,
504                    },
505                    None,
506                )
507                .await?;
508
509            let tab_id = self.next_tab_id;
510            self.next_tab_id += 1;
511            self.pages.push(PageInfo {
512                tab_id,
513                label: None,
514                target_id: result.target_id,
515                session_id: attach_result.session_id.clone(),
516                url: "about:blank".to_string(),
517                title: String::new(),
518                target_type: "page".to_string(),
519            });
520            self.active_page_index = 0;
521            self.enable_domains(&attach_result.session_id).await?;
522        } else {
523            for target in &page_targets {
524                let attach_result: AttachToTargetResult = self
525                    .client
526                    .send_command_typed(
527                        "Target.attachToTarget",
528                        &AttachToTargetParams {
529                            target_id: target.target_id.clone(),
530                            flatten: true,
531                        },
532                        None,
533                    )
534                    .await?;
535
536                let tab_id = self.next_tab_id;
537                self.next_tab_id += 1;
538                self.pages.push(PageInfo {
539                    tab_id,
540                    label: None,
541                    target_id: target.target_id.clone(),
542                    session_id: attach_result.session_id.clone(),
543                    url: target.url.clone(),
544                    title: target.title.clone(),
545                    target_type: target.target_type.clone(),
546                });
547            }
548
549            self.active_page_index = 0;
550            let session_id = self.pages[0].session_id.clone();
551            self.enable_domains(&session_id).await?;
552        }
553
554        Ok(())
555    }
556
557    pub async fn enable_domains_pub(&self, session_id: &str) -> Result<(), String> {
558        self.enable_domains(session_id).await
559    }
560
561    pub async fn prepare_domains_pub(&self, session_id: &str) -> Result<(), String> {
562        self.prepare_domains(session_id).await
563    }
564
565    pub async fn resume_if_waiting_pub(&self, session_id: &str) -> Result<(), String> {
566        self.resume_if_waiting(session_id).await
567    }
568
569    pub async fn enable_browser_auto_attach_pub(&self) -> Result<(), String> {
570        self.client
571            .send_command(
572                "Target.setAutoAttach",
573                Some(json!({
574                    "autoAttach": true,
575                    "waitForDebuggerOnStart": true,
576                    "flatten": true
577                })),
578                None,
579            )
580            .await?;
581        Ok(())
582    }
583
584    async fn enable_domains(&self, session_id: &str) -> Result<(), String> {
585        self.prepare_domains(session_id).await?;
586        self.resume_if_waiting(session_id).await?;
587        Ok(())
588    }
589
590    async fn prepare_domains(&self, session_id: &str) -> Result<(), String> {
591        self.client
592            .send_command_no_params("Page.enable", Some(session_id))
593            .await?;
594        self.client
595            .send_command_no_params("Runtime.enable", Some(session_id))
596            .await?;
597        self.client
598            .send_command_no_params("Network.enable", Some(session_id))
599            .await?;
600        // Enable auto-attach for cross-origin iframe support.
601        // flatten: true gives each iframe its own session_id.
602        // waitForDebuggerOnStart keeps child targets paused until the one-shot session
603        // installs any required network controls and explicitly resumes them.
604        // Ignored on engines that don't support it (e.g. Lightpanda).
605        let _ = self
606            .client
607            .send_command(
608                "Target.setAutoAttach",
609                Some(json!({
610                    "autoAttach": true,
611                    "waitForDebuggerOnStart": true,
612                    "flatten": true
613                })),
614                Some(session_id),
615            )
616            .await;
617        Ok(())
618    }
619
620    async fn resume_if_waiting(&self, session_id: &str) -> Result<(), String> {
621        // Needed for real browser sessions (Chrome 144+) where targets are
622        // paused after attach until explicitly resumed. No-op otherwise.
623        let _ = self
624            .client
625            .send_command_no_wait("Runtime.runIfWaitingForDebugger", None, Some(session_id))
626            .await;
627        Ok(())
628    }
629
630    pub fn active_session_id(&self) -> Result<&str, String> {
631        self.pages
632            .get(self.active_page_index)
633            .map(|p| p.session_id.as_str())
634            .ok_or_else(|| "No active page".to_string())
635    }
636
637    pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
638        let session_id = self.active_session_id()?.to_string();
639        let mut lifecycle_rx = self.client.subscribe();
640
641        let nav_result: PageNavigateResult = self
642            .client
643            .send_command_typed(
644                "Page.navigate",
645                &PageNavigateParams {
646                    url: url.to_string(),
647                    referrer: None,
648                },
649                Some(&session_id),
650            )
651            .await?;
652
653        if let Some(ref error_text) = nav_result.error_text {
654            return Err(format!("Navigation failed: {}", error_text));
655        }
656
657        // Only wait for lifecycle events if Chrome created a new loader (full navigation).
658        // If loader_id is None, it was a same-document navigation (e.g., hash routing)
659        // which does not fire Page.loadEventFired or Page.domContentEventFired.
660        if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
661            self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
662                .await?;
663        }
664
665        let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
666        let title = self.get_title().await.unwrap_or_default();
667
668        // Track visited origin for cross-origin localStorage collection in save_state
669        if let Ok(parsed) = url::Url::parse(&page_url) {
670            let origin = parsed.origin().ascii_serialization();
671            if origin != "null" {
672                self.visited_origins.insert(origin);
673            }
674        }
675
676        if let Some(page) = self.pages.get_mut(self.active_page_index) {
677            page.url = page_url.clone();
678            page.title = title.clone();
679        }
680
681        Ok(json!({ "url": page_url, "title": title }))
682    }
683
684    async fn wait_for_lifecycle(
685        &self,
686        wait_until: WaitUntil,
687        session_id: &str,
688        rx: &mut broadcast::Receiver<CdpEvent>,
689    ) -> Result<(), String> {
690        let (event_name, ready_states): (&str, &[&str]) = match wait_until {
691            WaitUntil::Load => ("Page.loadEventFired", &["complete"]),
692            WaitUntil::DomContentLoaded => {
693                ("Page.domContentEventFired", &["interactive", "complete"])
694            }
695            WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await,
696            WaitUntil::None => return Ok(()),
697        };
698
699        let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
700
701        tokio::time::timeout(timeout, async {
702            loop {
703                // Poll readyState: oxide browser-level listeners may miss session events
704                // (and about:blank often completes before the subscription is armed).
705                if let Ok(state) = self
706                    .client
707                    .send_command(
708                        "Runtime.evaluate",
709                        Some(json!({
710                            "expression": "document.readyState",
711                            "returnByValue": true
712                        })),
713                        Some(session_id),
714                    )
715                    .await
716                {
717                    let rs = state
718                        .get("result")
719                        .and_then(|r| r.get("value"))
720                        .and_then(|v| v.as_str())
721                        .unwrap_or("");
722                    if ready_states.contains(&rs) {
723                        return Ok(());
724                    }
725                }
726
727                match tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()).await
728                {
729                    Ok(Ok(event)) => {
730                        let sid_ok = event.session_id.is_none()
731                            || event.session_id.as_deref() == Some(session_id);
732                        if event.method == event_name && sid_ok {
733                            return Ok(());
734                        }
735                    }
736                    Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
737                    Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
738                        // Keep polling readyState until outer timeout.
739                    }
740                    Err(_) => {
741                        // recv timed out — loop and re-check readyState
742                    }
743                }
744            }
745        })
746        .await
747        .map_err(|_| format!("Timeout waiting for {}", event_name))?
748    }
749
750    async fn wait_for_network_idle(
751        &self,
752        session_id: &str,
753        rx: &mut broadcast::Receiver<CdpEvent>,
754    ) -> Result<(), String> {
755        let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
756        poll_network_idle(session_id, rx, timeout).await
757    }
758
759    pub async fn get_url(&self) -> Result<String, String> {
760        let result = self.evaluate_simple("location.href").await?;
761        Ok(result.as_str().unwrap_or("").to_string())
762    }
763
764    pub async fn get_title(&self) -> Result<String, String> {
765        let result = self.evaluate_simple("document.title").await?;
766        Ok(result.as_str().unwrap_or("").to_string())
767    }
768
769    pub async fn get_content(&self) -> Result<String, String> {
770        let result = self
771            .evaluate_simple("document.documentElement.outerHTML")
772            .await?;
773        Ok(result.as_str().unwrap_or("").to_string())
774    }
775
776    pub async fn evaluate(&self, script: &str, _args: Option<Value>) -> Result<Value, String> {
777        let session_id = self.active_session_id()?.to_string();
778
779        let result: EvaluateResult = self
780            .client
781            .send_command_typed(
782                "Runtime.evaluate",
783                &EvaluateParams {
784                    expression: script.to_string(),
785                    return_by_value: Some(true),
786                    await_promise: Some(true),
787                },
788                Some(&session_id),
789            )
790            .await?;
791
792        if let Some(ref details) = result.exception_details {
793            let msg = details
794                .exception
795                .as_ref()
796                .and_then(|e| e.description.as_deref())
797                .unwrap_or(&details.text);
798            return Err(format!("Evaluation error: {}", msg));
799        }
800
801        Ok(result.result.value.unwrap_or(Value::Null))
802    }
803
804    async fn evaluate_simple(&self, expression: &str) -> Result<Value, String> {
805        self.evaluate(expression, None).await
806    }
807
808    pub async fn wait_for_lifecycle_external(
809        &self,
810        wait_until: WaitUntil,
811        session_id: &str,
812    ) -> Result<(), String> {
813        let mut rx = self.client.subscribe();
814        self.wait_for_lifecycle(wait_until, session_id, &mut rx)
815            .await
816    }
817
818    pub async fn close(&mut self) -> Result<(), String> {
819        // Chrome one-shot: chromiumoxide FINALIZE (close + wait + kill fallback).
820        if self.owns_oxide_browser {
821            self.owns_oxide_browser = false;
822            let res = super::cdp::oxide::finalize_browser(self.client.browser()).await;
823            if let Some(dir) = self.temp_user_data_dir.take() {
824                let _ = std::fs::remove_dir_all(&dir);
825            }
826            return res;
827        }
828
829        if self.browser_process.is_some() {
830            // Lightpanda (or other process we spawned): ask browser to close, then reap.
831            let _ = self
832                .client
833                .send_command_no_params("Browser.close", None)
834                .await;
835        }
836
837        if let Some(mut process) = self.browser_process.take() {
838            let timeout = std::time::Duration::from_secs(5);
839            let _ = tokio::task::spawn_blocking(move || {
840                process.wait_or_kill(timeout);
841            })
842            .await;
843        }
844
845        if let Some(dir) = self.temp_user_data_dir.take() {
846            let _ = std::fs::remove_dir_all(&dir);
847        }
848
849        Ok(())
850    }
851
852    pub fn has_pages(&self) -> bool {
853        !self.pages.is_empty()
854    }
855
856    pub fn default_timeout_ms(&self) -> u64 {
857        self.default_timeout_ms
858    }
859
860    /// Checks if the CDP connection is alive by sending a simple command.
861    /// Returns false if the command times out or fails.
862    pub async fn is_connection_alive(&self) -> bool {
863        let timeout = tokio::time::Duration::from_secs(3);
864        let result = tokio::time::timeout(
865            timeout,
866            self.client
867                .send_command_no_params("Browser.getVersion", None),
868        )
869        .await;
870
871        match result {
872            Ok(Ok(_)) => true,
873            Ok(Err(_)) | Err(_) => false,
874        }
875    }
876
877    /// Non-blocking check whether the locally-launched browser process has exited
878    /// (crashed or terminated). Also reaps the zombie if it has exited.
879    /// Returns false for external CDP connections (no child process to monitor).
880    pub fn has_process_exited(&mut self) -> bool {
881        if let Some(ref mut process) = self.browser_process {
882            process.has_exited()
883        } else {
884            false
885        }
886    }
887
888    pub fn get_cdp_url(&self) -> &str {
889        &self.ws_url
890    }
891
892    /// Returns the Chrome debug server address as "host:port".
893    pub fn chrome_host_port(&self) -> &str {
894        let stripped = self
895            .ws_url
896            .strip_prefix("ws://")
897            .or_else(|| self.ws_url.strip_prefix("wss://"))
898            .unwrap_or(&self.ws_url);
899        stripped.split('/').next().unwrap_or(stripped)
900    }
901
902    pub fn active_target_id(&self) -> Result<&str, String> {
903        self.pages
904            .get(self.active_page_index)
905            .map(|p| p.target_id.as_str())
906            .ok_or_else(|| "No active page".to_string())
907    }
908
909    /// Returns true if this manager was connected via CDP (as opposed to local launch).
910    pub fn is_cdp_connection(&self) -> bool {
911        self.browser_process.is_none()
912    }
913
914    pub fn is_direct_page_connection(&self) -> bool {
915        self.direct_page
916    }
917
918    /// Ensures the browser has at least one page. If `pages` is empty, creates a new
919    /// about:blank page and attaches to it.
920    pub async fn ensure_page(&mut self) -> Result<(), String> {
921        if !self.pages.is_empty() {
922            return Ok(());
923        }
924
925        let result: CreateTargetResult = self
926            .client
927            .send_command_typed(
928                "Target.createTarget",
929                &CreateTargetParams {
930                    url: "about:blank".to_string(),
931                    browser_context_id: None,
932                },
933                None,
934            )
935            .await?;
936
937        let attach_result: AttachToTargetResult = self
938            .client
939            .send_command_typed(
940                "Target.attachToTarget",
941                &AttachToTargetParams {
942                    target_id: result.target_id.clone(),
943                    flatten: true,
944                },
945                None,
946            )
947            .await?;
948
949        let tab_id = self.next_tab_id;
950        self.next_tab_id += 1;
951        self.pages.push(PageInfo {
952            tab_id,
953            label: None,
954            target_id: result.target_id,
955            session_id: attach_result.session_id.clone(),
956            url: "about:blank".to_string(),
957            title: String::new(),
958            target_type: "page".to_string(),
959        });
960        self.active_page_index = 0;
961        self.enable_domains(&attach_result.session_id).await?;
962
963        Ok(())
964    }
965
966    // -----------------------------------------------------------------------
967    // Tab management
968    // -----------------------------------------------------------------------
969
970    /// Checks if `active_page_index` is still valid and adjusts it if not
971    /// (e.g., after a tab was closed).
972    pub fn update_active_page_if_needed(&mut self) {
973        if self.pages.is_empty() {
974            self.active_page_index = 0;
975            return;
976        }
977        if self.active_page_index >= self.pages.len() {
978            self.active_page_index = self.pages.len() - 1;
979        }
980    }
981
982    fn update_active_page_after_removal(&mut self, removed_index: usize) {
983        self.active_page_index = active_page_index_after_removal(
984            self.active_page_index,
985            removed_index,
986            self.pages.len(),
987        );
988    }
989
990    pub fn tab_list(&self) -> Vec<Value> {
991        self.pages
992            .iter()
993            .enumerate()
994            .map(|(i, p)| {
995                json!({
996                    "tabId": format_tab_id(p.tab_id),
997                    "label": p.label,
998                    "title": p.title,
999                    "url": p.url,
1000                    "type": p.target_type,
1001                    "active": i == self.active_page_index,
1002                })
1003            })
1004            .collect()
1005    }
1006
1007    /// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
1008    /// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
1009    pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
1010        match tab_ref {
1011            TabRef::Id(id) => {
1012                if self.has_tab_id(*id) {
1013                    Ok(*id)
1014                } else {
1015                    Err(format!(
1016                        "Tab {} not found; run `browser-automation-cli tab` to list open tabs",
1017                        format_tab_id(*id)
1018                    ))
1019                }
1020            }
1021            TabRef::Label(name) => self
1022                .pages
1023                .iter()
1024                .find(|p| p.label.as_deref() == Some(name.as_str()))
1025                .map(|p| p.tab_id)
1026                .ok_or_else(|| {
1027                    format!(
1028                        "No tab with label `{}`; run `browser-automation-cli tab` to list open tabs",
1029                        name
1030                    )
1031                }),
1032        }
1033    }
1034
1035    /// Returns true iff a tab already carries the given label.
1036    pub fn has_label(&self, label: &str) -> bool {
1037        self.pages.iter().any(|p| p.label.as_deref() == Some(label))
1038    }
1039
1040    pub async fn tab_new(
1041        &mut self,
1042        url: Option<&str>,
1043        label: Option<&str>,
1044    ) -> Result<Value, String> {
1045        self.tab_new_in_context(url, label, None).await
1046    }
1047
1048    /// Create a tab, optionally inside a BrowserContext (cookie/storage isolation).
1049    pub async fn tab_new_in_context(
1050        &mut self,
1051        url: Option<&str>,
1052        label: Option<&str>,
1053        browser_context_id: Option<String>,
1054    ) -> Result<Value, String> {
1055        if let Some(label) = label {
1056            if !is_valid_label(label) {
1057                return Err(format!(
1058                    "Invalid tab label `{}`; labels must start with a letter and contain only \
1059                     letters, digits, `-`, and `_`",
1060                    label
1061                ));
1062            }
1063            if self.has_label(label) {
1064                return Err(format!(
1065                    "Label `{}` is already used by another tab; labels must be unique within a \
1066                     session",
1067                    label
1068                ));
1069            }
1070        }
1071
1072        let target_url = url.unwrap_or("about:blank");
1073
1074        let result: CreateTargetResult = self
1075            .client
1076            .send_command_typed(
1077                "Target.createTarget",
1078                &CreateTargetParams {
1079                    url: target_url.to_string(),
1080                    browser_context_id: browser_context_id.clone(),
1081                },
1082                None,
1083            )
1084            .await?;
1085
1086        let attach: AttachToTargetResult = self
1087            .client
1088            .send_command_typed(
1089                "Target.attachToTarget",
1090                &AttachToTargetParams {
1091                    target_id: result.target_id.clone(),
1092                    flatten: true,
1093                },
1094                None,
1095            )
1096            .await?;
1097
1098        self.enable_domains(&attach.session_id).await?;
1099
1100        let tab_id = self.next_tab_id;
1101        self.next_tab_id += 1;
1102        let index = self.pages.len();
1103        let label = label.map(|s| s.to_string());
1104        self.pages.push(PageInfo {
1105            tab_id,
1106            label: label.clone(),
1107            target_id: result.target_id,
1108            session_id: attach.session_id,
1109            url: target_url.to_string(),
1110            title: String::new(),
1111            target_type: "page".to_string(),
1112        });
1113        self.active_page_index = index;
1114
1115        let mut out = json!({
1116            "tabId": format_tab_id(tab_id),
1117            "index": index,
1118            "label": label,
1119            "url": target_url,
1120            "total": self.pages.len(),
1121        });
1122        if let Some(ctx) = browser_context_id {
1123            if let Some(obj) = out.as_object_mut() {
1124                obj.insert("browser_context_id".into(), json!(ctx));
1125            }
1126        }
1127        Ok(out)
1128    }
1129
1130    /// Create an isolated BrowserContext and return its id.
1131    pub async fn create_browser_context(&self) -> Result<String, String> {
1132        let result = self
1133            .client
1134            .send_command("Browser.createBrowserContext", None, None)
1135            .await?;
1136        result
1137            .get("browserContextId")
1138            .and_then(|v| v.as_str())
1139            .map(|s| s.to_string())
1140            .ok_or_else(|| "Browser.createBrowserContext missing browserContextId".to_string())
1141    }
1142
1143    pub async fn tab_switch(&mut self, index: usize) -> Result<Value, String> {
1144        if index >= self.pages.len() {
1145            return Err(format!(
1146                "Tab index {} out of range (0-{})",
1147                index,
1148                self.pages.len().saturating_sub(1)
1149            ));
1150        }
1151
1152        self.active_page_index = index;
1153        let session_id = self.pages[index].session_id.clone();
1154        self.enable_domains(&session_id).await?;
1155
1156        // Bring tab to front
1157        let _ = self
1158            .client
1159            .send_command("Page.bringToFront", None, Some(&session_id))
1160            .await;
1161
1162        let url = self.get_url().await.unwrap_or_default();
1163        let title = self.get_title().await.unwrap_or_default();
1164
1165        if let Some(page) = self.pages.get_mut(index) {
1166            page.url = url.clone();
1167            page.title = title.clone();
1168        }
1169
1170        let page = &self.pages[index];
1171        Ok(json!({
1172            "tabId": format_tab_id(page.tab_id),
1173            "label": page.label,
1174            "url": url,
1175            "title": title,
1176        }))
1177    }
1178
1179    pub async fn tab_close(&mut self, index: Option<usize>) -> Result<Value, String> {
1180        let target_index = index.unwrap_or(self.active_page_index);
1181
1182        if target_index >= self.pages.len() {
1183            return Err(format!("Tab index {} out of range", target_index));
1184        }
1185
1186        if self.pages.len() <= 1 {
1187            return Err("Cannot close the last tab".to_string());
1188        }
1189
1190        let page = self.pages.remove(target_index);
1191        self.update_active_page_after_removal(target_index);
1192        let closed_tab_id = page.tab_id;
1193        let closed_label = page.label.clone();
1194        let _ = self
1195            .client
1196            .send_command_typed::<_, Value>(
1197                "Target.closeTarget",
1198                &CloseTargetParams {
1199                    target_id: page.target_id,
1200                },
1201                None,
1202            )
1203            .await;
1204
1205        let session_id = self.pages[self.active_page_index].session_id.clone();
1206        self.enable_domains(&session_id).await?;
1207
1208        Ok(json!({
1209            "tabId": format_tab_id(closed_tab_id),
1210            "label": closed_label,
1211            "closed": true,
1212        }))
1213    }
1214
1215    // -----------------------------------------------------------------------
1216    // Emulation
1217    // -----------------------------------------------------------------------
1218
1219    pub async fn set_viewport(
1220        &self,
1221        width: i32,
1222        height: i32,
1223        device_scale_factor: f64,
1224        mobile: bool,
1225    ) -> Result<(), String> {
1226        let session_id = self.active_session_id()?;
1227        self.client
1228            .send_command(
1229                "Emulation.setDeviceMetricsOverride",
1230                Some(json!({
1231                    "width": width,
1232                    "height": height,
1233                    "deviceScaleFactor": device_scale_factor,
1234                    "mobile": mobile,
1235                })),
1236                Some(session_id),
1237            )
1238            .await?;
1239
1240        // Screencast captures the actual content area, not the emulated CSS
1241        // viewport, so resize the content area to match.
1242        if let Ok(target_id) = self.active_target_id() {
1243            if let Ok(window_info) = self
1244                .client
1245                .send_command(
1246                    "Browser.getWindowForTarget",
1247                    Some(json!({ "targetId": target_id })),
1248                    None,
1249                )
1250                .await
1251            {
1252                if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) {
1253                    if let Err(e) = self
1254                        .client
1255                        .send_command(
1256                            "Browser.setContentsSize",
1257                            Some(json!({
1258                                "windowId": window_id,
1259                                "width": width,
1260                                "height": height,
1261                            })),
1262                            None,
1263                        )
1264                        .await
1265                    {
1266                        eprintln!("Browser.setContentsSize failed (experimental CDP): {e}");
1267                    }
1268                }
1269            }
1270        }
1271
1272        Ok(())
1273    }
1274
1275    pub async fn set_user_agent(&self, user_agent: &str) -> Result<(), String> {
1276        let session_id = self.active_session_id()?;
1277        self.client
1278            .send_command(
1279                "Emulation.setUserAgentOverride",
1280                Some(json!({ "userAgent": user_agent })),
1281                Some(session_id),
1282            )
1283            .await?;
1284        Ok(())
1285    }
1286
1287    pub async fn set_emulated_media(
1288        &self,
1289        media: Option<&str>,
1290        features: Option<Vec<(String, String)>>,
1291    ) -> Result<(), String> {
1292        let session_id = self.active_session_id()?;
1293        let mut params = json!({});
1294        if let Some(m) = media {
1295            params["media"] = Value::String(m.to_string());
1296        }
1297        if let Some(feats) = features {
1298            let features_arr: Vec<Value> = feats
1299                .iter()
1300                .map(|(name, value)| json!({ "name": name, "value": value }))
1301                .collect();
1302            params["features"] = Value::Array(features_arr);
1303        }
1304        self.client
1305            .send_command("Emulation.setEmulatedMedia", Some(params), Some(session_id))
1306            .await?;
1307        Ok(())
1308    }
1309
1310    pub async fn bring_to_front(&self) -> Result<(), String> {
1311        let session_id = self.active_session_id()?;
1312        self.client
1313            .send_command("Page.bringToFront", None, Some(session_id))
1314            .await?;
1315        Ok(())
1316    }
1317
1318    pub async fn set_timezone(&self, timezone_id: &str) -> Result<(), String> {
1319        let session_id = self.active_session_id()?;
1320        self.client
1321            .send_command(
1322                "Emulation.setTimezoneOverride",
1323                Some(json!({ "timezoneId": timezone_id })),
1324                Some(session_id),
1325            )
1326            .await?;
1327        Ok(())
1328    }
1329
1330    pub async fn set_locale(&self, locale: &str) -> Result<(), String> {
1331        let session_id = self.active_session_id()?;
1332        self.client
1333            .send_command(
1334                "Emulation.setLocaleOverride",
1335                Some(json!({ "locale": locale })),
1336                Some(session_id),
1337            )
1338            .await?;
1339        Ok(())
1340    }
1341
1342    pub async fn set_geolocation(
1343        &self,
1344        latitude: f64,
1345        longitude: f64,
1346        accuracy: Option<f64>,
1347    ) -> Result<(), String> {
1348        let session_id = self.active_session_id()?;
1349        self.client
1350            .send_command(
1351                "Emulation.setGeolocationOverride",
1352                Some(json!({
1353                    "latitude": latitude,
1354                    "longitude": longitude,
1355                    "accuracy": accuracy.unwrap_or(1.0),
1356                })),
1357                Some(session_id),
1358            )
1359            .await?;
1360        Ok(())
1361    }
1362
1363    pub async fn grant_permissions(&self, permissions: &[String]) -> Result<(), String> {
1364        self.client
1365            .send_command(
1366                "Browser.grantPermissions",
1367                Some(json!({ "permissions": permissions })),
1368                None,
1369            )
1370            .await?;
1371        Ok(())
1372    }
1373
1374    pub async fn handle_dialog(
1375        &self,
1376        accept: bool,
1377        prompt_text: Option<&str>,
1378    ) -> Result<(), String> {
1379        let session_id = self.active_session_id()?;
1380        let mut params = json!({ "accept": accept });
1381        if let Some(text) = prompt_text {
1382            params["promptText"] = Value::String(text.to_string());
1383        }
1384        self.client
1385            .send_command(
1386                "Page.handleJavaScriptDialog",
1387                Some(params),
1388                Some(session_id),
1389            )
1390            .await?;
1391        Ok(())
1392    }
1393
1394    pub async fn upload_files(
1395        &self,
1396        selector: &str,
1397        files: &[String],
1398        ref_map: &RefMap,
1399        iframe_sessions: &HashMap<String, String>,
1400    ) -> Result<(), String> {
1401        let session_id = self.active_session_id()?;
1402
1403        let (object_id, effective_session_id) =
1404            resolve_element_object_id(&self.client, session_id, ref_map, selector, iframe_sessions)
1405                .await?;
1406
1407        let describe: Value = self
1408            .client
1409            .send_command(
1410                "DOM.describeNode",
1411                Some(json!({ "objectId": object_id })),
1412                Some(&effective_session_id),
1413            )
1414            .await?;
1415
1416        let backend_node_id = describe
1417            .get("node")
1418            .and_then(|n| n.get("backendNodeId"))
1419            .and_then(|v| v.as_i64())
1420            .ok_or("Could not get backendNodeId for file input")?;
1421
1422        self.client
1423            .send_command(
1424                "DOM.setFileInputFiles",
1425                Some(json!({
1426                    "files": files,
1427                    "backendNodeId": backend_node_id,
1428                })),
1429                Some(&effective_session_id),
1430            )
1431            .await?;
1432
1433        Ok(())
1434    }
1435
1436    pub async fn add_script_to_evaluate(&self, source: &str) -> Result<String, String> {
1437        let session_id = self.active_session_id()?;
1438        let result = self
1439            .client
1440            .send_command(
1441                "Page.addScriptToEvaluateOnNewDocument",
1442                Some(json!({ "source": source })),
1443                Some(session_id),
1444            )
1445            .await?;
1446        Ok(result
1447            .get("identifier")
1448            .and_then(|v| v.as_str())
1449            .unwrap_or("")
1450            .to_string())
1451    }
1452
1453    pub async fn remove_script_to_evaluate(&self, identifier: &str) -> Result<(), String> {
1454        let session_id = self.active_session_id()?;
1455        self.client
1456            .send_command(
1457                "Page.removeScriptToEvaluateOnNewDocument",
1458                Some(json!({ "identifier": identifier })),
1459                Some(session_id),
1460            )
1461            .await?;
1462        Ok(())
1463    }
1464
1465    pub async fn tab_switch_by_id(&mut self, tab_id: u32) -> Result<Value, String> {
1466        let index = self
1467            .pages
1468            .iter()
1469            .position(|p| p.tab_id == tab_id)
1470            .ok_or_else(|| format!("Tab ID {} not found", tab_id))?;
1471        self.tab_switch(index).await
1472    }
1473
1474    pub async fn tab_close_by_id(&mut self, tab_id: Option<u32>) -> Result<Value, String> {
1475        let index = match tab_id {
1476            Some(id) => Some(
1477                self.pages
1478                    .iter()
1479                    .position(|p| p.tab_id == id)
1480                    .ok_or_else(|| format!("Tab ID {} not found", id))?,
1481            ),
1482            None => None,
1483        };
1484        self.tab_close(index).await
1485    }
1486
1487    pub fn assign_tab_id(&mut self) -> u32 {
1488        let id = self.next_tab_id;
1489        self.next_tab_id += 1;
1490        id
1491    }
1492
1493    pub fn add_page(&mut self, page: PageInfo) {
1494        let index = self.pages.len();
1495        self.pages.push(page);
1496        self.active_page_index = index;
1497    }
1498
1499    pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
1500        update_page_target_info_in_pages(&mut self.pages, target)
1501    }
1502
1503    pub fn remove_page_by_target_id(&mut self, target_id: &str) {
1504        if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
1505            self.pages.remove(pos);
1506            self.update_active_page_after_removal(pos);
1507        }
1508    }
1509
1510    pub fn has_target(&self, target_id: &str) -> bool {
1511        self.pages.iter().any(|p| p.target_id == target_id)
1512    }
1513
1514    pub fn page_count(&self) -> usize {
1515        self.pages.len()
1516    }
1517
1518    /// Returns the stable `tab_id` of the currently active page, if any.
1519    pub fn active_tab_id(&self) -> Option<u32> {
1520        self.pages.get(self.active_page_index).map(|p| p.tab_id)
1521    }
1522
1523    /// Returns true if a tab with the given stable `tab_id` is still open.
1524    pub fn has_tab_id(&self, tab_id: u32) -> bool {
1525        self.pages.iter().any(|p| p.tab_id == tab_id)
1526    }
1527
1528    pub fn pages_list(&self) -> Vec<PageInfo> {
1529        self.pages.clone()
1530    }
1531
1532    pub fn visited_origins(&self) -> &HashSet<String> {
1533        &self.visited_origins
1534    }
1535
1536    pub async fn set_download_behavior(&self, download_path: &str) -> Result<(), String> {
1537        let session_id = self.active_session_id()?;
1538        self.client
1539            .send_command(
1540                "Browser.setDownloadBehavior",
1541                Some(json!({
1542                    "behavior": "allowAndName",
1543                    "downloadPath": download_path,
1544                    "eventsEnabled": true,
1545                })),
1546                Some(session_id),
1547            )
1548            .await?;
1549        Ok(())
1550    }
1551}
1552
1553/// Core network-idle polling loop, extracted so it can be unit-tested without a
1554/// full `BrowserManager` / CDP connection.
1555///
1556/// Returns `Ok(())` once no network requests have been in-flight for at least
1557/// 500 ms, or `Err` if `overall_timeout` elapses first.
1558async fn poll_network_idle(
1559    session_id: &str,
1560    rx: &mut broadcast::Receiver<CdpEvent>,
1561    overall_timeout: tokio::time::Duration,
1562) -> Result<(), String> {
1563    let pending = Arc::new(Mutex::new(HashSet::<String>::new()));
1564
1565    tokio::time::timeout(overall_timeout, async {
1566        let mut idle_start: Option<tokio::time::Instant> = None;
1567
1568        loop {
1569            let recv_result =
1570                tokio::time::timeout(tokio::time::Duration::from_millis(600), rx.recv()).await;
1571
1572            match recv_result {
1573                Ok(Ok(event))
1574                    if event.session_id.is_none()
1575                        || event.session_id.as_deref() == Some(session_id) =>
1576                {
1577                    let mut p = pending.lock().await;
1578                    match event.method.as_str() {
1579                        "Network.requestWillBeSent" => {
1580                            if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str())
1581                            {
1582                                p.insert(id.to_string());
1583                                idle_start = None;
1584                            }
1585                        }
1586                        "Network.loadingFinished" | "Network.loadingFailed" => {
1587                            if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str())
1588                            {
1589                                p.remove(id);
1590                                if p.is_empty() {
1591                                    idle_start = Some(tokio::time::Instant::now());
1592                                }
1593                            }
1594                        }
1595                        "Page.loadEventFired" if p.is_empty() => {
1596                            idle_start = Some(tokio::time::Instant::now());
1597                        }
1598                        _ => {}
1599                    }
1600                }
1601                Ok(Ok(_)) => {}
1602                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
1603                Ok(Err(_)) => break,
1604                Err(_) => {
1605                    // Timeout on recv -- if no pending requests, start (or
1606                    // continue) the idle timer instead of returning
1607                    // immediately.  This prevents false-positive idle
1608                    // detection when the subscription starts after the page
1609                    // has already loaded (e.g. cached pages).
1610                    let p = pending.lock().await;
1611                    if p.is_empty() && idle_start.is_none() {
1612                        idle_start = Some(tokio::time::Instant::now());
1613                    }
1614                }
1615            }
1616
1617            if let Some(start) = idle_start {
1618                if start.elapsed() >= tokio::time::Duration::from_millis(500) {
1619                    return Ok(());
1620                }
1621            }
1622        }
1623
1624        Ok(())
1625    })
1626    .await
1627    .map_err(|_| "Timeout waiting for networkidle".to_string())?
1628}
1629
1630async fn attach_ws_with_retry(
1631    ws_url: &str,
1632    total_timeout: Duration,
1633    poll_interval: Duration,
1634) -> Result<CdpClient, String> {
1635    let deadline = Instant::now() + total_timeout;
1636
1637    loop {
1638        match CdpClient::connect(ws_url).await {
1639            Ok(client) => return Ok(client),
1640            Err(err) => {
1641                if Instant::now() >= deadline {
1642                    return Err(err);
1643                }
1644            }
1645        }
1646
1647        tokio::time::sleep(poll_interval).await;
1648    }
1649}
1650
1651async fn initialize_lightpanda_manager(
1652    ws_url: String,
1653    process: BrowserProcess,
1654) -> Result<BrowserManager, String> {
1655    let deadline = Instant::now() + LIGHTPANDA_TARGET_INIT_TIMEOUT;
1656    let mut process = Some(process);
1657
1658    loop {
1659        let client = match attach_ws_with_retry(
1660            &ws_url,
1661            LIGHTPANDA_CDP_CONNECT_TIMEOUT,
1662            LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL,
1663        )
1664        .await
1665        {
1666            Ok(client) => client,
1667            Err(err) => {
1668                if Instant::now() >= deadline {
1669                    return Err(lightpanda_target_init_timeout(Some(&err)));
1670                }
1671                tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await;
1672                continue;
1673            }
1674        };
1675
1676        let mut manager = BrowserManager {
1677            client: Arc::new(client),
1678            browser_process: None,
1679            owns_oxide_browser: false,
1680            ws_url: ws_url.clone(),
1681            pages: Vec::new(),
1682            active_page_index: 0,
1683            default_timeout_ms: 25_000,
1684            download_path: None,
1685            ignore_https_errors: false,
1686            visited_origins: HashSet::new(),
1687            next_tab_id: 1,
1688            direct_page: false,
1689            temp_user_data_dir: None,
1690        };
1691
1692        match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
1693            Ok(()) => {
1694                manager.browser_process = process.take();
1695                return Ok(manager);
1696            }
1697            Err(err) => {
1698                if Instant::now() >= deadline {
1699                    return Err(lightpanda_target_init_timeout(Some(&err)));
1700                }
1701                tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await;
1702            }
1703        }
1704    }
1705}
1706
1707async fn discover_and_attach_lightpanda_targets(
1708    manager: &mut BrowserManager,
1709    deadline: Instant,
1710) -> Result<(), String> {
1711    run_with_lightpanda_deadline(
1712        deadline,
1713        manager.discover_and_attach_targets(),
1714        "Target domain initialization attempt exceeded the remaining startup deadline",
1715    )
1716    .await
1717}
1718
1719fn remaining_until(deadline: Instant) -> Option<Duration> {
1720    deadline.checked_duration_since(Instant::now())
1721}
1722
1723async fn run_with_lightpanda_deadline<F, T>(
1724    deadline: Instant,
1725    operation: F,
1726    timeout_context: &'static str,
1727) -> Result<T, String>
1728where
1729    F: Future<Output = Result<T, String>>,
1730{
1731    let remaining = remaining_until(deadline)
1732        .ok_or_else(|| lightpanda_target_init_timeout(Some("deadline expired before retry")))?;
1733
1734    match tokio::time::timeout(remaining, operation).await {
1735        Ok(result) => result,
1736        Err(_) => Err(lightpanda_target_init_timeout(Some(timeout_context))),
1737    }
1738}
1739
1740fn lightpanda_target_init_timeout(last_error: Option<&str>) -> String {
1741    let mut message = format!(
1742        "Timed out after {}ms waiting for Lightpanda Target domain to initialize",
1743        LIGHTPANDA_TARGET_INIT_TIMEOUT.as_millis(),
1744    );
1745    if let Some(last_error) = last_error {
1746        message.push_str(&format!("\nLast error: {}", last_error));
1747    }
1748    message
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754    use tokio::time::sleep;
1755
1756    #[test]
1757    fn test_format_tab_id() {
1758        assert_eq!(format_tab_id(1), "t1");
1759        assert_eq!(format_tab_id(42), "t42");
1760    }
1761
1762    #[test]
1763    fn test_parse_tab_ref_id() {
1764        assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
1765        assert_eq!(TabRef::parse("t42"), Ok(TabRef::Id(42)));
1766        assert_eq!(TabRef::parse("T7"), Ok(TabRef::Id(7)));
1767    }
1768
1769    #[test]
1770    fn test_parse_tab_ref_label() {
1771        assert_eq!(TabRef::parse("docs"), Ok(TabRef::Label("docs".to_string())));
1772        assert_eq!(
1773            TabRef::parse("app-2"),
1774            Ok(TabRef::Label("app-2".to_string()))
1775        );
1776        assert_eq!(
1777            TabRef::parse("my_tab"),
1778            Ok(TabRef::Label("my_tab".to_string()))
1779        );
1780    }
1781
1782    #[test]
1783    fn test_parse_tab_ref_rejects_bare_integer() {
1784        let err = TabRef::parse("2").unwrap_err();
1785        assert!(
1786            err.contains("positional integers are not accepted"),
1787            "error should teach the user to use `t<N>`: {}",
1788            err
1789        );
1790        assert!(err.contains("t2"));
1791    }
1792
1793    #[test]
1794    fn test_parse_tab_ref_rejects_empty() {
1795        assert!(TabRef::parse("").is_err());
1796        assert!(TabRef::parse("   ").is_err());
1797    }
1798
1799    #[test]
1800    fn test_parse_tab_ref_rejects_zero() {
1801        let err = TabRef::parse("t0").unwrap_err();
1802        assert!(err.contains("start at t1"));
1803    }
1804
1805    #[test]
1806    fn test_parse_tab_ref_rejects_invalid_label() {
1807        assert!(TabRef::parse("2docs").is_err());
1808        assert!(TabRef::parse("-docs").is_err());
1809        assert!(TabRef::parse("docs!").is_err());
1810        assert!(TabRef::parse("docs space").is_err());
1811    }
1812
1813    #[test]
1814    fn test_is_valid_label() {
1815        assert!(is_valid_label("docs"));
1816        assert!(is_valid_label("Docs"));
1817        assert!(is_valid_label("app-2"));
1818        assert!(is_valid_label("my_tab"));
1819        assert!(!is_valid_label(""));
1820        assert!(!is_valid_label("2docs"));
1821        assert!(!is_valid_label("-docs"));
1822        assert!(!is_valid_label("docs!"));
1823    }
1824
1825    #[test]
1826    fn test_should_track_popup_target_with_empty_url() {
1827        let target = TargetInfo {
1828            target_id: "popup-1".to_string(),
1829            target_type: "page".to_string(),
1830            title: String::new(),
1831            url: String::new(),
1832            attached: None,
1833            browser_context_id: None,
1834        };
1835
1836        assert!(should_track_target(&target));
1837    }
1838
1839    #[test]
1840    fn test_should_not_track_internal_chrome_target() {
1841        let target = TargetInfo {
1842            target_id: "chrome-tab".to_string(),
1843            target_type: "page".to_string(),
1844            title: "New Tab".to_string(),
1845            url: "chrome://newtab/".to_string(),
1846            attached: None,
1847            browser_context_id: None,
1848        };
1849
1850        assert!(!should_track_target(&target));
1851    }
1852
1853    #[test]
1854    fn test_update_page_target_info_in_pages_updates_existing_page() {
1855        let mut pages = vec![PageInfo {
1856            tab_id: 1,
1857            label: None,
1858            target_id: "popup-1".to_string(),
1859            session_id: "session-1".to_string(),
1860            url: String::new(),
1861            title: String::new(),
1862            target_type: "page".to_string(),
1863        }];
1864        let target = TargetInfo {
1865            target_id: "popup-1".to_string(),
1866            target_type: "page".to_string(),
1867            title: "Popup".to_string(),
1868            url: "https://example.com/popup".to_string(),
1869            attached: None,
1870            browser_context_id: None,
1871        };
1872
1873        assert!(update_page_target_info_in_pages(&mut pages, &target));
1874        assert_eq!(pages[0].url, "https://example.com/popup");
1875        assert_eq!(pages[0].title, "Popup");
1876    }
1877
1878    #[test]
1879    fn test_active_page_index_after_removal_shifts_when_earlier_tab_is_removed() {
1880        assert_eq!(active_page_index_after_removal(2, 0, 3), 1);
1881    }
1882
1883    #[test]
1884    fn test_active_page_index_after_removal_keeps_same_slot_when_later_tab_is_removed() {
1885        assert_eq!(active_page_index_after_removal(1, 2, 3), 1);
1886    }
1887
1888    #[test]
1889    fn test_active_page_index_after_removal_clamps_when_active_last_tab_is_removed() {
1890        assert_eq!(active_page_index_after_removal(3, 3, 3), 2);
1891    }
1892
1893    #[test]
1894    fn test_active_page_index_after_removal_resets_when_last_page_disappears() {
1895        assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
1896    }
1897
1898    #[test]
1899    fn test_validate_launch_options_extensions_and_cdp() {
1900        let ext = vec!["/path/to/ext".to_string()];
1901        assert!(validate_launch_options(Some(&ext), true, None, None, false, None,).is_err());
1902    }
1903
1904    #[test]
1905    fn test_validate_launch_options_profile_and_cdp() {
1906        assert!(validate_launch_options(None, true, Some("/path"), None, false, None,).is_err());
1907    }
1908
1909    #[test]
1910    fn test_validate_launch_options_storage_state_and_profile() {
1911        assert!(validate_launch_options(
1912            None,
1913            false,
1914            Some("/profile"),
1915            Some("/state.json"),
1916            false,
1917            None,
1918        )
1919        .is_err());
1920    }
1921
1922    #[test]
1923    fn test_validate_launch_options_storage_state_and_extensions() {
1924        let ext = vec!["/ext".to_string()];
1925        assert!(
1926            validate_launch_options(Some(&ext), false, None, Some("/state.json"), false, None,)
1927                .is_err()
1928        );
1929    }
1930
1931    #[test]
1932    fn test_validate_launch_options_allow_file_access_firefox() {
1933        assert!(
1934            validate_launch_options(None, false, None, None, true, Some("/usr/bin/firefox"),)
1935                .is_err()
1936        );
1937    }
1938
1939    #[test]
1940    fn test_validate_launch_options_valid() {
1941        assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
1942    }
1943
1944    #[test]
1945    fn test_to_ai_friendly_error_strict_mode() {
1946        assert_eq!(
1947            to_ai_friendly_error("Strict mode violation: multiple elements"),
1948            "Element matched multiple results. Use a more specific selector."
1949        );
1950    }
1951
1952    #[test]
1953    fn test_to_ai_friendly_error_not_visible() {
1954        assert_eq!(
1955            to_ai_friendly_error("element is not visible"),
1956            "Element exists but is not visible. Wait for it to become visible or scroll it into view."
1957        );
1958    }
1959
1960    #[test]
1961    fn test_to_ai_friendly_error_intercept() {
1962        assert_eq!(
1963            to_ai_friendly_error("element intercepted by another element"),
1964            "Another element is covering the target element. Try scrolling or closing overlays."
1965        );
1966    }
1967
1968    #[test]
1969    fn test_to_ai_friendly_error_timeout() {
1970        assert_eq!(
1971            to_ai_friendly_error("Timeout waiting for element"),
1972            "Operation timed out. The page may still be loading or the element may not exist."
1973        );
1974    }
1975
1976    #[test]
1977    fn test_to_ai_friendly_error_not_found() {
1978        assert_eq!(
1979            to_ai_friendly_error("Element not found"),
1980            "Element not found. Verify the selector is correct and the element exists in the DOM."
1981        );
1982    }
1983
1984    #[test]
1985    fn test_to_ai_friendly_error_unknown() {
1986        let msg = "Some custom error message";
1987        assert_eq!(to_ai_friendly_error(msg), msg);
1988    }
1989
1990    /// Errors containing "not found" but NOT "element" should pass through unchanged.
1991    #[test]
1992    fn test_to_ai_friendly_error_ignores_non_element_not_found() {
1993        let err = "Chrome not found. Install Chrome or use --executable-path.";
1994        assert_eq!(to_ai_friendly_error(err), err);
1995    }
1996
1997    #[test]
1998    fn test_to_ai_friendly_error_catches_no_element() {
1999        let mapped =
2000            "Element not found. Verify the selector is correct and the element exists in the DOM.";
2001        assert_eq!(to_ai_friendly_error("No element found for css 'x'"), mapped);
2002    }
2003
2004    #[test]
2005    fn test_remaining_until_returns_none_for_past_deadline() {
2006        let deadline = Instant::now()
2007            .checked_sub(Duration::from_millis(1))
2008            .expect("past instant should be representable");
2009        assert!(remaining_until(deadline).is_none());
2010    }
2011
2012    #[tokio::test]
2013    async fn test_run_with_lightpanda_deadline_enforces_timeout() {
2014        let deadline = Instant::now() + Duration::from_millis(25);
2015        let err = tokio::time::timeout(
2016            Duration::from_secs(1),
2017            run_with_lightpanda_deadline(
2018                deadline,
2019                async {
2020                    sleep(Duration::from_millis(100)).await;
2021                    Ok::<(), String>(())
2022                },
2023                "Target domain initialization attempt exceeded the remaining startup deadline",
2024            ),
2025        )
2026        .await
2027        .expect("outer timeout should not fire")
2028        .unwrap_err();
2029
2030        assert!(err.contains(
2031            "Timed out after 10000ms waiting for Lightpanda Target domain to initialize"
2032        ));
2033        assert!(err.contains("remaining startup deadline"));
2034    }
2035
2036    #[tokio::test]
2037    async fn test_run_with_lightpanda_deadline_returns_operation_error() {
2038        let deadline = Instant::now() + Duration::from_secs(1);
2039        let err = run_with_lightpanda_deadline(
2040            deadline,
2041            async { Err::<(), String>("Target.getTargets failed".to_string()) },
2042            "unused timeout context",
2043        )
2044        .await
2045        .unwrap_err();
2046
2047        assert_eq!(err, "Target.getTargets failed");
2048    }
2049
2050    #[test]
2051    fn test_lightpanda_target_init_timeout_includes_last_error() {
2052        let err = lightpanda_target_init_timeout(Some("Target.setDiscoverTargets failed"));
2053        assert!(err.contains(
2054            "Timed out after 10000ms waiting for Lightpanda Target domain to initialize"
2055        ));
2056        assert!(err.contains("Target.setDiscoverTargets failed"));
2057    }
2058
2059    #[test]
2060    fn test_validate_lightpanda_rejects_webgpu() {
2061        let options = LaunchOptions {
2062            webgpu: true,
2063            ..Default::default()
2064        };
2065        let err = validate_lightpanda_options(&options).unwrap_err();
2066        assert!(err.contains("WebGPU"));
2067        assert!(validate_lightpanda_options(&LaunchOptions::default()).is_ok());
2068    }
2069
2070    #[test]
2071    fn test_is_internal_chrome_target() {
2072        assert!(is_internal_chrome_target("chrome://newtab/"));
2073        assert!(is_internal_chrome_target(
2074            "chrome://omnibox-popup.top-chrome/"
2075        ));
2076        assert!(is_internal_chrome_target(
2077            "chrome-extension://abc123/popup.html"
2078        ));
2079        assert!(is_internal_chrome_target(
2080            "devtools://devtools/bundled/inspector.html"
2081        ));
2082        assert!(!is_internal_chrome_target("https://example.com"));
2083        assert!(!is_internal_chrome_target("http://localhost:3000"));
2084        assert!(!is_internal_chrome_target("about:blank"));
2085    }
2086
2087    // -----------------------------------------------------------------------
2088    // poll_network_idle tests
2089    // -----------------------------------------------------------------------
2090
2091    fn cdp_event(method: &str, session_id: &str, params: Value) -> CdpEvent {
2092        CdpEvent {
2093            method: method.to_string(),
2094            params,
2095            session_id: Some(session_id.to_string()),
2096        }
2097    }
2098
2099    /// Regression test for #846: when no network events arrive at all (e.g.
2100    /// page fully served from cache), poll_network_idle must NOT return
2101    /// instantly.  It should observe at least 500 ms of idle before resolving.
2102    #[tokio::test]
2103    async fn test_network_idle_no_events_does_not_return_instantly() {
2104        let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2105        let session = "s1";
2106
2107        let start = tokio::time::Instant::now();
2108        let result = tokio::time::timeout(
2109            Duration::from_secs(5),
2110            poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2111        )
2112        .await
2113        .expect("outer timeout should not fire");
2114
2115        assert!(result.is_ok());
2116        let elapsed = start.elapsed();
2117        assert!(
2118            elapsed >= Duration::from_millis(500),
2119            "network idle returned in {:?}, expected >= 500ms",
2120            elapsed
2121        );
2122
2123        drop(tx);
2124    }
2125
2126    /// Normal flow: requests start and finish, idle is detected after the last
2127    /// request completes and 500 ms of silence passes.
2128    #[tokio::test]
2129    async fn test_network_idle_after_requests_complete() {
2130        let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2131        let session = "s1";
2132
2133        let _keep_alive = tx.clone();
2134        tokio::spawn(async move {
2135            sleep(Duration::from_millis(50)).await;
2136            let _ = tx.send(cdp_event(
2137                "Network.requestWillBeSent",
2138                session,
2139                json!({ "requestId": "r1" }),
2140            ));
2141            sleep(Duration::from_millis(100)).await;
2142            let _ = tx.send(cdp_event(
2143                "Network.loadingFinished",
2144                session,
2145                json!({ "requestId": "r1" }),
2146            ));
2147        });
2148
2149        let start = tokio::time::Instant::now();
2150        let result = tokio::time::timeout(
2151            Duration::from_secs(5),
2152            poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2153        )
2154        .await
2155        .expect("outer timeout should not fire");
2156
2157        assert!(result.is_ok());
2158        let elapsed = start.elapsed();
2159        assert!(
2160            elapsed >= Duration::from_millis(500),
2161            "should wait >= 500ms after last request finishes, got {:?}",
2162            elapsed
2163        );
2164    }
2165
2166    /// A new request arriving during the idle window resets the timer.
2167    #[tokio::test]
2168    async fn test_network_idle_resets_on_new_request() {
2169        let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2170        let session = "s1";
2171
2172        let _keep_alive = tx.clone();
2173        tokio::spawn(async move {
2174            sleep(Duration::from_millis(50)).await;
2175            let _ = tx.send(cdp_event(
2176                "Network.requestWillBeSent",
2177                session,
2178                json!({ "requestId": "r1" }),
2179            ));
2180            sleep(Duration::from_millis(50)).await;
2181            let _ = tx.send(cdp_event(
2182                "Network.loadingFinished",
2183                session,
2184                json!({ "requestId": "r1" }),
2185            ));
2186            // Wait 200ms (< 500ms idle window), then fire another request
2187            sleep(Duration::from_millis(200)).await;
2188            let _ = tx.send(cdp_event(
2189                "Network.requestWillBeSent",
2190                session,
2191                json!({ "requestId": "r2" }),
2192            ));
2193            sleep(Duration::from_millis(100)).await;
2194            let _ = tx.send(cdp_event(
2195                "Network.loadingFinished",
2196                session,
2197                json!({ "requestId": "r2" }),
2198            ));
2199        });
2200
2201        let start = tokio::time::Instant::now();
2202        let result = tokio::time::timeout(
2203            Duration::from_secs(5),
2204            poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2205        )
2206        .await
2207        .expect("outer timeout should not fire");
2208
2209        assert!(result.is_ok());
2210        let elapsed = start.elapsed();
2211        // r2 finishes at ~400ms; idle should be detected at ~900ms
2212        assert!(
2213            elapsed >= Duration::from_millis(800),
2214            "should wait for idle after second request, got {:?}",
2215            elapsed
2216        );
2217    }
2218
2219    /// When the overall timeout expires before idle is reached, the function
2220    /// returns an error.
2221    #[tokio::test]
2222    async fn test_network_idle_overall_timeout() {
2223        let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2224        let session = "s1";
2225
2226        // Keep sending requests so idle is never reached
2227        tokio::spawn(async move {
2228            for i in 0u64.. {
2229                let _ = tx.send(cdp_event(
2230                    "Network.requestWillBeSent",
2231                    session,
2232                    json!({ "requestId": format!("r{}", i) }),
2233                ));
2234                sleep(Duration::from_millis(100)).await;
2235            }
2236        });
2237
2238        let result = poll_network_idle(session, &mut rx, Duration::from_millis(800)).await;
2239        assert!(result.is_err());
2240        assert!(result
2241            .unwrap_err()
2242            .contains("Timeout waiting for networkidle"));
2243    }
2244}