Skip to main content

browser_automation_cli/native/
browser.rs

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