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