use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{broadcast, Mutex}; use super::cdp::chrome::{auto_connect_cdp, launch_chrome, ChromeProcess, LaunchOptions}; use super::cdp::client::CdpClient; use super::cdp::discovery::discover_cdp_url; use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess}; use super::cdp::types::*; use super::element::{resolve_element_object_id, RefMap}; /// The daemon's session name, set once at daemon start. Names the Chrome tab /// group that abs-created tabs land in when driving the user's real Chrome via /// the `ab-connect` extension, so each agent/session gets its own group. pub static DAEMON_SESSION: std::sync::OnceLock = std::sync::OnceLock::new(); // --------------------------------------------------------------------------- // Launch validation // --------------------------------------------------------------------------- /// Validates launch/connect options for incompatible combinations. /// Returns `Ok(())` if valid, or `Err(msg)` with a user-friendly error. pub fn validate_launch_options( extensions: Option<&[String]>, has_cdp: bool, profile: Option<&str>, storage_state: Option<&str>, allow_file_access: bool, executable_path: Option<&str>, ) -> Result<(), String> { let has_extensions = extensions.map(|e| !e.is_empty()).unwrap_or(false); if has_extensions && has_cdp { return Err( "Cannot use extensions with cdp_url (extensions require local browser launch)" .to_string(), ); } if profile.is_some() && has_cdp { return Err( "Cannot use profile with cdp_url (profile requires local browser launch)".to_string(), ); } if storage_state.is_some() && profile.is_some() { return Err("Cannot use storage_state with profile".to_string()); } if storage_state.is_some() && has_extensions { return Err("Cannot use storage_state with extensions".to_string()); } if allow_file_access { if let Some(path) = executable_path { let lower = path.to_lowercase(); if lower.contains("firefox") || lower.contains("webkit") || lower.contains("safari") { return Err( "allow_file_access is not supported with non-Chromium browsers".to_string(), ); } } } Ok(()) } /// Validates that Chrome-only options are not used with Lightpanda. fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> { if options .extensions .as_ref() .map(|e| !e.is_empty()) .unwrap_or(false) { return Err("Extensions are not supported with Lightpanda".to_string()); } if options.profile.is_some() { return Err("Profiles are not supported with Lightpanda".to_string()); } if options.storage_state.is_some() { return Err("Storage state is not supported with Lightpanda".to_string()); } if options.allow_file_access { return Err("File access is not supported with Lightpanda".to_string()); } if !options.headless { return Err("Headed mode is not supported with Lightpanda (headless only)".to_string()); } if !options.args.is_empty() { return Err( "Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(), ); } Ok(()) } /// Returns true for Chrome internal targets that should not be selected /// during auto-connect (e.g. chrome://, chrome-extension://, devtools://). fn is_internal_chrome_target(url: &str) -> bool { url.starts_with("chrome://") || url.starts_with("chrome-extension://") || url.starts_with("devtools://") } pub(crate) fn should_track_target(target: &TargetInfo) -> bool { (target.target_type == "page" || target.target_type == "webview") && (target.url.is_empty() || !is_internal_chrome_target(&target.url)) } /// Origin + path of a URL, dropping the query string and fragment, for /// `--reuse-tab` matching. SPA/SSO URLs carry volatile `?client_id=…&state=…` /// and `#/route` parts, so two opens of the "same" page rarely match /// byte-for-byte; comparing origin+path lands the reuse on the right tab. /// Returns the input unchanged if it doesn't parse as a URL. fn normalize_url_for_match(url: &str) -> String { match url::Url::parse(url) { Ok(u) => format!("{}{}", u.origin().ascii_serialization(), u.path()), Err(_) => url.to_string(), } } fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool { if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) { page.url = target.url.clone(); page.title = target.title.clone(); page.target_type = target.target_type.clone(); return true; } false } fn active_page_index_after_removal( active_page_index: usize, removed_index: usize, remaining_pages: usize, ) -> usize { if remaining_pages == 0 { return 0; } if removed_index < active_page_index { return active_page_index - 1; } if active_page_index >= remaining_pages { return remaining_pages - 1; } active_page_index } /// Resolve the session's active page index: prefer the pinned `active_target_id` /// (stable across tab reorder / passive discovery / removal), falling back to the /// raw `active_page_index` only when nothing is pinned or the pin is gone. Keeping /// commands anchored to the pinned target is what stops `eval`/`get url`/`snapshot` /// from drifting onto a foreign tab between commands (issue #14). fn resolve_active_index( pages: &[PageInfo], active_target_id: Option<&str>, active_page_index: usize, ) -> usize { if let Some(tid) = active_target_id { if let Some(i) = pages.iter().position(|p| p.target_id == tid) { return i; } } active_page_index } /// Target ids to prune after a `Target.getTargets` resync: tracked pages whose /// target is no longer in the live set — EXCEPT the explicitly-pinned active /// target, which is protected. The relay against a busy real Chrome occasionally /// returns a different window's tabs for a single `getTargets` call ("tab list /// hops windows", issue #31); pruning on that transient snapshot would drop the /// agent's adopted tab and drift subsequent eval/click onto a foreign tab. A /// genuine close still arrives as `Target.targetDestroyed` (handled in the event /// drain), which removes the pin properly — so protecting it here only guards /// against flaky snapshots, not real closures. fn prunable_target_ids( pages: &[PageInfo], live_ids: &HashSet, pinned: Option<&str>, ) -> Vec { pages .iter() .map(|p| p.target_id.clone()) .filter(|tid| !live_ids.contains(tid) && pinned != Some(tid.as_str())) .collect() } /// Whether the resolved active page is a tab the session created (its target_id /// is in `created_targets`). Pure core of [`BrowserManager::active_is_session_owned`] /// so the relay no-hijack rule is unit-testable without a live browser. fn active_index_is_owned( pages: &[PageInfo], active_target_id: Option<&str>, active_page_index: usize, created_targets: &HashSet, ) -> bool { pages .get(resolve_active_index( pages, active_target_id, active_page_index, )) .map(|p| created_targets.contains(&p.target_id)) .unwrap_or(false) } /// Converts common error messages into AI-friendly, actionable descriptions. pub fn to_ai_friendly_error(error: &str) -> String { let lower = error.to_lowercase(); if lower.contains("strict mode violation") { return "Element matched multiple results. Use a more specific selector.".to_string(); } if lower.contains("element is not visible") { return "Element exists but is not visible. Wait for it to become visible or scroll it into view." .to_string(); } if lower.contains("intercept") { return "Another element is covering the target element. Try scrolling or closing overlays." .to_string(); } if lower.contains("timeout") { return "Operation timed out. The page may still be loading or the element may not exist." .to_string(); } if lower.contains("element not found") || lower.contains("no element") { return "Element not found. Verify the selector is correct and the element exists in the DOM." .to_string(); } error.to_string() } #[derive(Debug, Clone)] pub struct PageInfo { pub tab_id: u32, /// Optional user-assigned label (e.g. "docs", "app"). Set via /// `tab new --label `. Labels are agent-assigned and never /// auto-generated, never rewritten on navigation, and unique within a /// session. Agents use labels instead of `t` for readable multi-tab /// workflows. pub label: Option, pub target_id: String, pub session_id: String, pub url: String, pub title: String, pub target_type: String, // "page" or "webview" } /// Canonical string form of a stable tab id: `t1`, `t2`, ... The `t` prefix /// disambiguates stable ids from positional indices (which the CLI no longer /// accepts) and matches the `@e` convention used for element refs. pub fn format_tab_id(tab_id: u32) -> String { format!("t{}", tab_id) } /// A tab reference as parsed from CLI/JSON input. Either a stable id like /// `t2` or a user-assigned label like `docs`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TabRef { Id(u32), Label(String), } impl TabRef { /// Parse a user-supplied string tab reference. Rejects bare integers /// with a teaching error so agents and scripts don't silently confuse /// stable ids with positional indices. pub fn parse(input: &str) -> Result { let input = input.trim(); if input.is_empty() { return Err("Empty tab reference; expected `t` (e.g. `t2`) or a label".to_string()); } if let Some(digits) = input.strip_prefix('t').or_else(|| input.strip_prefix('T')) { if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) { let id: u32 = digits.parse().map_err(|_| { format!( "Tab id `{}` out of range; ids are incrementing positive integers", input ) })?; if id == 0 { return Err(format!( "Tab id `{}` is invalid; tab ids start at t1", input )); } return Ok(TabRef::Id(id)); } } if input.chars().all(|c| c.is_ascii_digit()) { return Err(format!( "Expected a tab id like `t{}` or a label; positional integers are not accepted \ (run `chrome-use tab` to list stable tab ids)", input )); } if !is_valid_label(input) { return Err(format!( "Invalid tab label `{}`; labels must start with a letter and contain only \ letters, digits, `-`, and `_`", input )); } Ok(TabRef::Label(input.to_string())) } } /// Labels must look like identifiers: start with a letter, contain only /// letters/digits/dashes/underscores. This keeps them distinguishable from /// `t` ids at a glance and safe to pass through shells without quoting. pub fn is_valid_label(s: &str) -> bool { let mut chars = s.chars(); match chars.next() { Some(c) if c.is_ascii_alphabetic() => {} _ => return false, } chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WaitUntil { Load, DomContentLoaded, NetworkIdle, None, } impl WaitUntil { pub fn from_str(s: &str) -> Self { match s { "domcontentloaded" => Self::DomContentLoaded, "networkidle" => Self::NetworkIdle, "none" => Self::None, _ => Self::Load, } } pub fn as_str(&self) -> &'static str { match self { Self::Load => "load", Self::DomContentLoaded => "domcontentloaded", Self::NetworkIdle => "networkidle", Self::None => "none", } } } pub enum BrowserProcess { Chrome(ChromeProcess), Lightpanda(LightpandaProcess), } impl BrowserProcess { pub fn kill(&mut self) { match self { BrowserProcess::Chrome(p) => p.kill(), BrowserProcess::Lightpanda(p) => p.kill(), } } pub fn wait_or_kill(&mut self, timeout: std::time::Duration) { match self { BrowserProcess::Chrome(p) => p.wait_or_kill(timeout), BrowserProcess::Lightpanda(p) => p.kill(), } } /// Non-blocking check whether the browser process has exited. pub fn has_exited(&mut self) -> bool { match self { BrowserProcess::Chrome(p) => p.has_exited(), BrowserProcess::Lightpanda(_) => false, } } } pub struct BrowserManager { pub client: Arc, browser_process: Option, ws_url: String, pages: Vec, active_page_index: usize, default_timeout_ms: u64, /// Stored download path from launch options, re-applied to new contexts (e.g., recording) pub download_path: Option, /// Whether to ignore HTTPS certificate errors, re-applied to new contexts (e.g., recording) pub ignore_https_errors: bool, /// Origins visited during this session, used by save_state to collect cross-origin localStorage. visited_origins: HashSet, /// Target IDs of tabs THIS session created via `Target.createTarget`. When /// connected to the user's real Chrome (not a launched browser), these are /// closed on `close()` so the session's tabs don't pile up in the user's /// browser after it ends. Only ever holds tabs we created — never the user's /// existing tabs or other sessions' tabs — so closing them is always safe. created_targets: HashSet, /// The session's *intended* active tab, pinned by stable target_id rather /// than the fragile `active_page_index`. Set on every explicit open / tab new /// / tab switch. `active_session_id` resolves through this so a foreign tab /// opening (passive discovery), a tab closing, or list reordering can't drift /// the session's commands onto the wrong page — the wrong-origin-fetch hazard /// in the dogfood reports. Falls back to the index if the pinned tab is gone. active_target_id: Option, next_tab_id: u32, /// Whether to enable the CDP `Runtime` domain (console / error / exception capture). /// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal /// (the patchright / rebrowser "runtime leak") — even when attached to the user's /// real Chrome. Opt in via `AGENT_BROWSER_CAPTURE_CONSOLE=1` when you need the /// `console` / `errors` commands to return page output. pub capture_console: bool, } /// Whether console/error capture (and thus `Runtime.enable`) is opted into for this /// daemon. Defaults to `false` so the common automation path leaves no Runtime-domain /// fingerprint. Set `AGENT_BROWSER_CAPTURE_CONSOLE=1` (or `true`) to turn it on. pub fn console_capture_enabled() -> bool { std::env::var("AGENT_BROWSER_CAPTURE_CONSOLE") .ok() .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100); const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10); /// Outcome of a single `Browser.getVersion` liveness probe. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LivenessProbe { /// Chrome answered — the connection is definitely alive. Responded, /// The CDP transport errored (WebSocket closed/reset) — the socket is gone. TransportError, /// The probe timed out with no response. TimedOut, } /// Decide whether a CDP connection should be considered alive from one probe. /// /// The subtle case is [`LivenessProbe::TimedOut`]. For a browser we launched /// ourselves (`is_external_attach == false`) a hung CDP socket is a real /// problem and the daemon should reconnect. But for an *externally attached* /// browser — the stealth fork's default, where we attach to the user's real /// Chrome — a slow/no response is almost always Chrome being briefly busy or, /// critically, showing the Chrome 136+ "Allow remote debugging?" consent modal, /// which blocks CDP responses until the user clicks Allow. /// /// Treating that timeout as "dead" tears down the already-consented connection /// and forces a reconnect, which re-pops the consent prompt; repeated on every /// command it produces an endless prompt loop and a connection storm that can /// freeze Chrome. So for external attaches we keep the connection alive on /// timeout. A genuinely dead external socket instead surfaces as /// [`LivenessProbe::TransportError`] (and Chrome being closed by the user is a /// transport error, not a timeout), so zombie-socket detection is preserved. fn connection_alive_from_probe(probe: LivenessProbe, is_external_attach: bool) -> bool { match probe { LivenessProbe::Responded => true, LivenessProbe::TransportError => false, LivenessProbe::TimedOut => is_external_attach, } } impl BrowserManager { pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result { let engine = engine.unwrap_or("chrome"); match engine { "chrome" => { validate_launch_options( options.extensions.as_deref(), false, options.profile.as_deref(), options.storage_state.as_deref(), options.allow_file_access, options.executable_path.as_deref(), )?; } "lightpanda" => { validate_lightpanda_options(&options)?; } _ => { return Err(format!( "Unknown engine '{}'. Supported engines: chrome, lightpanda", engine )); } } let ignore_https_errors = options.ignore_https_errors; let user_agent = options.user_agent.clone(); let color_scheme = options.color_scheme.clone(); let download_path = options.download_path.clone(); let (ws_url, process) = match engine { "lightpanda" => { let lp_options = LightpandaLaunchOptions { executable_path: options.executable_path.clone(), proxy: options.proxy.clone(), port: None, }; let lp = launch_lightpanda(&lp_options).await?; let url = lp.ws_url.clone(); (url, BrowserProcess::Lightpanda(lp)) } _ => { let chrome = tokio::task::spawn_blocking(move || launch_chrome(&options)) .await .map_err(|e| format!("Chrome launch task failed: {}", e))??; let url = chrome.ws_url.clone(); (url, BrowserProcess::Chrome(chrome)) } }; // A launched browser carries a debug port → it's the other path that can // pop Chrome's consent modal; record it for #31 diagnosis. crate::connect::log_connect_mode( &ws_url, true, DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"), ); let manager = if engine == "lightpanda" { initialize_lightpanda_manager(ws_url, process).await? } else { let client = Arc::new(CdpClient::connect(&ws_url).await?); let mut manager = Self { client, browser_process: Some(process), ws_url, pages: Vec::new(), active_page_index: 0, default_timeout_ms: 25_000, download_path: download_path.clone(), ignore_https_errors, visited_origins: HashSet::new(), created_targets: HashSet::new(), active_target_id: None, next_tab_id: 1, capture_console: console_capture_enabled(), }; manager.discover_and_attach_targets().await?; manager }; let session_id = manager.active_session_id()?.to_string(); if ignore_https_errors { let _ = manager .client .send_command( "Security.setIgnoreCertificateErrors", Some(json!({ "ignore": true })), Some(&session_id), ) .await; } if let Some(ref ua) = user_agent { let _ = manager .client .send_command( "Emulation.setUserAgentOverride", Some(json!({ "userAgent": ua })), Some(&session_id), ) .await; } if let Some(ref scheme) = color_scheme { let _ = manager .client .send_command( "Emulation.setEmulatedMedia", Some(json!({ "features": [{ "name": "prefers-color-scheme", "value": scheme }] })), Some(&session_id), ) .await; } if let Some(ref path) = download_path { let _ = manager .client .send_command( "Browser.setDownloadBehavior", Some(json!({ "behavior": "allow", "downloadPath": path })), None, ) .await; } Ok(manager) } pub async fn connect_cdp(url: &str) -> Result { Self::connect_cdp_inner(url, false, None).await } /// Connect to a provider CDP proxy where the WebSocket IS the page session. /// Skips browser-level Target.* commands that most proxies don't support. pub async fn connect_cdp_direct(url: &str) -> Result { Self::connect_cdp_inner(url, true, None).await } pub async fn connect_cdp_with_headers( url: &str, headers: Option>, ) -> Result { Self::connect_cdp_inner(url, false, headers).await } async fn connect_cdp_inner( url: &str, direct_page: bool, headers: Option>, ) -> Result { let ws_url = resolve_cdp_url(url).await?; // Record the transport so a reappearing "Allow remote debugging?" modal // can be traced to a raw-port attach vs the consent-free relay (#31). crate::connect::log_connect_mode( &ws_url, false, DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"), ); let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?); let mut manager = Self { client, browser_process: None, ws_url, pages: Vec::new(), active_page_index: 0, default_timeout_ms: 25_000, download_path: None, ignore_https_errors: false, visited_origins: HashSet::new(), created_targets: HashSet::new(), active_target_id: None, next_tab_id: 1, capture_console: console_capture_enabled(), }; if direct_page { let tab_id = manager.assign_tab_id(); manager.pages.push(PageInfo { tab_id, label: None, target_id: "provider-page".to_string(), session_id: String::new(), url: String::new(), title: String::new(), target_type: "page".to_string(), }); manager.active_page_index = 0; manager.pin_active_target(); manager.enable_domains_direct().await?; } else { manager.discover_and_attach_targets().await?; } Ok(manager) } pub async fn connect_auto() -> Result { let ws_url = auto_connect_cdp().await?; Self::connect_cdp(&ws_url).await } async fn discover_and_attach_targets(&mut self) -> Result<(), String> { self.client .send_command_typed::<_, Value>( "Target.setDiscoverTargets", &SetDiscoverTargetsParams { discover: true }, None, ) .await?; let result: GetTargetsResult = self .client .send_command_typed("Target.getTargets", &json!({}), None) .await?; let page_targets: Vec = result .target_infos .into_iter() .filter(should_track_target) .collect(); if page_targets.is_empty() { // Create a new tab let agent_group = self.agent_group(); let result: CreateTargetResult = self .client .send_command_typed( "Target.createTarget", &CreateTargetParams { url: "about:blank".to_string(), agent_group, background: None, }, None, ) .await?; // We created this tab — own it so close() can clean it up. self.created_targets.insert(result.target_id.clone()); let attach_result: AttachToTargetResult = self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: result.target_id.clone(), flatten: true, }, None, ) .await?; let tab_id = self.next_tab_id; self.next_tab_id += 1; self.pages.push(PageInfo { tab_id, label: None, target_id: result.target_id, session_id: attach_result.session_id.clone(), url: "about:blank".to_string(), title: String::new(), target_type: "page".to_string(), }); self.active_page_index = 0; self.pin_active_target(); self.enable_domains(&attach_result.session_id).await?; } else { for target in &page_targets { let attach_result: AttachToTargetResult = self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: target.target_id.clone(), flatten: true, }, None, ) .await?; let tab_id = self.next_tab_id; self.next_tab_id += 1; self.pages.push(PageInfo { tab_id, label: None, target_id: target.target_id.clone(), session_id: attach_result.session_id.clone(), url: target.url.clone(), title: target.title.clone(), target_type: target.target_type.clone(), }); } self.active_page_index = 0; self.pin_active_target(); let session_id = self.pages[0].session_id.clone(); self.enable_domains(&session_id).await?; } Ok(()) } pub async fn enable_domains_pub(&self, session_id: &str) -> Result<(), String> { self.enable_domains(session_id).await } async fn enable_domains(&self, session_id: &str) -> Result<(), String> { self.client .send_command_no_params("Page.enable", Some(session_id)) .await?; // `Runtime.enable` leaves a detectable CDP signal (the patchright/rebrowser // "runtime leak"), so only enable it when console/error capture is opted in. // `Runtime.evaluate` / `Runtime.callFunctionOn` work fine without it. if self.capture_console { self.client .send_command_no_params("Runtime.enable", Some(session_id)) .await?; } // Resume the target if it is paused waiting for the debugger. // This is needed for real browser sessions (Chrome 144+) where targets // are paused after attach until explicitly resumed. No-op otherwise. let _ = self .client .send_command_no_params("Runtime.runIfWaitingForDebugger", Some(session_id)) .await; self.client .send_command_no_params("Network.enable", Some(session_id)) .await?; // Enable auto-attach for cross-origin iframe support. // flatten: true gives each iframe its own session_id. // Ignored on engines that don't support it (e.g. Lightpanda). let _ = self .client .send_command( "Target.setAutoAttach", Some(json!({ "autoAttach": true, "waitForDebuggerOnStart": false, "flatten": true })), Some(session_id), ) .await; // Silent operation: agent tabs are driven in the background (we never // force them to the foreground), so emulate focus. Without this a // backgrounded tab is render-throttled and reports `document.hidden` / // `!document.hasFocus()` — which both breaks timing-sensitive pages and // is itself a bot signal (a real user looks at the page). Best-effort; // ignored on engines without Emulation support. let _ = self .client .send_command( "Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })), Some(session_id), ) .await; Ok(()) } /// Enable domains on a direct page connection (no session_id needed). async fn enable_domains_direct(&self) -> Result<(), String> { self.client .send_command_no_params("Page.enable", None) .await?; // See `enable_domains`: `Runtime.enable` is a CDP fingerprint, gated on opt-in. if self.capture_console { self.client .send_command_no_params("Runtime.enable", None) .await?; } let _ = self .client .send_command_no_params("Runtime.runIfWaitingForDebugger", None) .await; self.client .send_command_no_params("Network.enable", None) .await?; Ok(()) } /// Index of the session's active page, resolved through the pinned /// `active_target_id` (stable across reorder/removal/passive discovery) and /// falling back to `active_page_index` when nothing is pinned or the pin is /// gone. This is what keeps commands on the tab the agent actually opened. fn resolved_active_index(&self) -> usize { resolve_active_index( &self.pages, self.active_target_id.as_deref(), self.active_page_index, ) } /// Whether the resolved active page is a tab THIS session created (via /// `Target.createTarget` — `tab new`, `ensure_page`, or the first `open`). /// On the shared real browser a fresh session also passively attaches to the /// user's existing tabs; those are NOT owned, and navigating one would /// clobber the user's page. Used to gate `navigate` on the relay. fn active_is_session_owned(&self) -> bool { active_index_is_owned( &self.pages, self.active_target_id.as_deref(), self.active_page_index, &self.created_targets, ) } /// Pin the current active page by target_id so later commands stick to it. /// Call after any explicit open / tab new / tab switch. fn pin_active_target(&mut self) { self.active_target_id = self .pages .get(self.active_page_index) .map(|p| p.target_id.clone()); } pub fn active_session_id(&self) -> Result<&str, String> { self.pages .get(self.resolved_active_index()) .map(|p| p.session_id.as_str()) .ok_or_else(|| "No active page".to_string()) } pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result { // On the shared real browser (extension relay), a fresh session only // passively attached to the user's existing tabs — it doesn't own any. The // pre-fix code made one of those the active tab, so the first `open` then // navigated (clobbered) the user's page: in dogfooding an `open` replaced a // half-filled form with the target site. If the active tab isn't one we // created, open our own tab in this session's group and navigate THAT, so // the user's (and other sessions') tabs are never hijacked. Off the relay // (a browser we launched) reusing the active tab is correct, so this is // gated on `agent_group()`. if self.agent_group().is_some() && !self.active_is_session_owned() { self.tab_new(None, None).await?; } let session_id = self.active_session_id()?.to_string(); let mut lifecycle_rx = self.client.subscribe(); let nav_result: PageNavigateResult = self .client .send_command_typed( "Page.navigate", &PageNavigateParams { url: url.to_string(), referrer: None, }, Some(&session_id), ) .await?; if let Some(ref error_text) = nav_result.error_text { return Err(format!("Navigation failed: {}", error_text)); } // Only wait for lifecycle events if Chrome created a new loader (full navigation). // If loader_id is None, it was a same-document navigation (e.g., hash routing) // which does not fire Page.loadEventFired or Page.domContentEventFired. let mut nav_warning: Option = None; if nav_result.loader_id.is_some() && wait_until != WaitUntil::None { if let Err(e) = self .wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx) .await { // The lifecycle event (e.g. `load`) didn't fire within the // timeout. On SPAs this is common — a long-pending XHR or a stuck // sub-resource holds `load` open long after the DOM is interactive // and the page is usable, so `open` would hard-fail even though // eval/screenshot work immediately (issue #10). If the DOM is // already ready, treat navigation as done (with a warning, carried // in the response so the CLI can surface it) instead of failing. // Only a still-loading document is a real failure. let ready = self .evaluate_simple("document.readyState") .await .ok() .and_then(|v| v.as_str().map(str::to_string)) .unwrap_or_default(); if ready == "interactive" || ready == "complete" { nav_warning = Some(format!( "`{}` didn't complete within the timeout, but the DOM is ready ({}) — \ continuing. Pass `--wait-until domcontentloaded` to skip this wait on \ SPAs with long-lived requests.", wait_until.as_str(), ready )); } else { return Err(e); } } } let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string()); let title = self.get_title().await.unwrap_or_default(); // Track visited origin for cross-origin localStorage collection in save_state if let Ok(parsed) = url::Url::parse(&page_url) { let origin = parsed.origin().ascii_serialization(); if origin != "null" { self.visited_origins.insert(origin); } } // An explicit `open`/navigate IS the "explicit open" the pin invariant is // built around (see `active_target_id`). On the relay path `open` reuses an // existing tab via this method rather than `add_page`, so without pinning // here `active_target_id` stayed `None` and the session rode the fragile // `active_page_index` — a later passive tab close/reorder then drifted // `eval`/`get url`/`snapshot` onto a foreign tab between commands (issue // #14). Sync the index to the resolved active page, then pin it by stable // target_id so subsequent commands stick to the tab we just navigated. self.active_page_index = self.resolved_active_index(); if let Some(page) = self.pages.get_mut(self.active_page_index) { page.url = page_url.clone(); page.title = title.clone(); } self.pin_active_target(); let mut out = json!({ "url": page_url, "title": title }); if let Some(w) = nav_warning { out["warning"] = json!(w); } Ok(out) } async fn wait_for_lifecycle( &self, wait_until: WaitUntil, session_id: &str, rx: &mut broadcast::Receiver, ) -> Result<(), String> { let event_name = match wait_until { WaitUntil::Load => "Page.loadEventFired", WaitUntil::DomContentLoaded => "Page.domContentEventFired", WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await, WaitUntil::None => return Ok(()), }; let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); tokio::time::timeout(timeout, async { loop { match rx.recv().await { Ok(event) => { if event.method == event_name && event.session_id.as_deref() == Some(session_id) { return Ok(()); } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } Err("Event stream closed".to_string()) }) .await .map_err(|_| format!("Timeout waiting for {}", event_name))? } async fn wait_for_network_idle( &self, session_id: &str, rx: &mut broadcast::Receiver, ) -> Result<(), String> { let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); poll_network_idle(session_id, rx, timeout).await } pub async fn get_url(&self) -> Result { let result = self.evaluate_simple("location.href").await?; Ok(result.as_str().unwrap_or("").to_string()) } pub async fn get_title(&self) -> Result { let result = self.evaluate_simple("document.title").await?; Ok(result.as_str().unwrap_or("").to_string()) } pub async fn get_content(&self) -> Result { let result = self .evaluate_simple("document.documentElement.outerHTML") .await?; Ok(result.as_str().unwrap_or("").to_string()) } pub async fn evaluate(&self, script: &str, _args: Option) -> Result { let session_id = self.active_session_id()?.to_string(); let result: EvaluateResult = self .client .send_command_typed( "Runtime.evaluate", &EvaluateParams { expression: script.to_string(), return_by_value: Some(true), await_promise: Some(true), }, Some(&session_id), ) .await?; if let Some(ref details) = result.exception_details { let msg = details .exception .as_ref() .and_then(|e| e.description.as_deref()) .unwrap_or(&details.text); return Err(format!("Evaluation error: {}", msg)); } Ok(result.result.value.unwrap_or(Value::Null)) } async fn evaluate_simple(&self, expression: &str) -> Result { self.evaluate(expression, None).await } pub async fn wait_for_lifecycle_external( &self, wait_until: WaitUntil, session_id: &str, ) -> Result<(), String> { let mut rx = self.client.subscribe(); self.wait_for_lifecycle(wait_until, session_id, &mut rx) .await } pub async fn close(&mut self) -> Result<(), String> { if self.browser_process.is_some() { // Only send Browser.close when we launched the browser ourselves. // For external connections (--auto-connect, --cdp) we just disconnect // without shutting down the user's browser. let _ = self .client .send_command_no_params("Browser.close", None) .await; } else { // Connected to the user's real Chrome: we must NOT close their // browser, but we DO own the tabs this session created. Close them so // they don't pile up in the user's window (in their per-session tab // group) every time a session ends, idles out, or the daemon shuts // down. `created_targets` only holds tabs we made via // Target.createTarget — never the user's existing tabs or other // sessions' — so this is always safe. Best-effort per tab. for target_id in self.created_targets.drain() { let _ = self .client .send_command_typed::<_, Value>( "Target.closeTarget", &CloseTargetParams { target_id }, None, ) .await; } } if let Some(mut process) = self.browser_process.take() { let timeout = std::time::Duration::from_secs(5); let _ = tokio::task::spawn_blocking(move || { process.wait_or_kill(timeout); }) .await; } Ok(()) } pub fn has_pages(&self) -> bool { !self.pages.is_empty() } pub fn default_timeout_ms(&self) -> u64 { self.default_timeout_ms } /// Checks if the CDP connection is alive by sending a `Browser.getVersion` /// probe. See [`connection_alive_from_probe`] for how the outcome maps to a /// liveness verdict — in particular why a timeout does NOT tear down an /// externally-attached browser. pub async fn is_connection_alive(&self) -> bool { let timeout = tokio::time::Duration::from_secs(3); let probe = match tokio::time::timeout( timeout, self.client .send_command_no_params("Browser.getVersion", None), ) .await { Ok(Ok(_)) => LivenessProbe::Responded, Ok(Err(_)) => LivenessProbe::TransportError, Err(_) => LivenessProbe::TimedOut, }; // No child process => we attached to an external browser (the user's // real Chrome — the stealth fork's default). let is_external_attach = self.browser_process.is_none(); connection_alive_from_probe(probe, is_external_attach) } /// Non-blocking check whether the locally-launched browser process has exited /// (crashed or terminated). Also reaps the zombie if it has exited. /// Returns false for external CDP connections (no child process to monitor). pub fn has_process_exited(&mut self) -> bool { if let Some(ref mut process) = self.browser_process { process.has_exited() } else { false } } pub fn get_cdp_url(&self) -> &str { &self.ws_url } /// Returns the Chrome debug server address as "host:port". pub fn chrome_host_port(&self) -> &str { let stripped = self .ws_url .strip_prefix("ws://") .or_else(|| self.ws_url.strip_prefix("wss://")) .unwrap_or(&self.ws_url); stripped.split('/').next().unwrap_or(stripped) } pub fn active_target_id(&self) -> Result<&str, String> { self.pages .get(self.resolved_active_index()) .map(|p| p.target_id.as_str()) .ok_or_else(|| "No active page".to_string()) } /// Returns true if this manager was connected via CDP (as opposed to local launch). pub fn is_cdp_connection(&self) -> bool { self.browser_process.is_none() } /// Ensures the browser has at least one page. If `pages` is empty, creates a new /// about:blank page and attaches to it. pub async fn ensure_page(&mut self) -> Result<(), String> { if !self.pages.is_empty() { return Ok(()); } let agent_group = self.agent_group(); let result: CreateTargetResult = self .client .send_command_typed( "Target.createTarget", &CreateTargetParams { url: "about:blank".to_string(), agent_group, background: None, }, None, ) .await?; // We created this tab — own it so close() can clean it up. self.created_targets.insert(result.target_id.clone()); let attach_result: AttachToTargetResult = self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: result.target_id.clone(), flatten: true, }, None, ) .await?; let tab_id = self.next_tab_id; self.next_tab_id += 1; self.pages.push(PageInfo { tab_id, label: None, target_id: result.target_id, session_id: attach_result.session_id.clone(), url: "about:blank".to_string(), title: String::new(), target_type: "page".to_string(), }); self.active_page_index = 0; // Pin this freshly-created tab (matches `add_page`) so it's a stable // anchor from the first command, not a bare index (issue #14). self.pin_active_target(); self.enable_domains(&attach_result.session_id).await?; Ok(()) } // ----------------------------------------------------------------------- // Tab management // ----------------------------------------------------------------------- /// Checks if `active_page_index` is still valid and adjusts it if not /// (e.g., after a tab was closed). pub fn update_active_page_if_needed(&mut self) { if self.pages.is_empty() { self.active_page_index = 0; return; } if self.active_page_index >= self.pages.len() { self.active_page_index = self.pages.len() - 1; } } fn update_active_page_after_removal(&mut self, removed_index: usize) { self.active_page_index = active_page_index_after_removal( self.active_page_index, removed_index, self.pages.len(), ); } pub fn tab_list(&self) -> Vec { let active = self.resolved_active_index(); self.pages .iter() .enumerate() .map(|(i, p)| { json!({ "tabId": format_tab_id(p.tab_id), // Stable CDP target id. Unlike `t` (per-session, reassigned // each connect) this is the same handle across every session // attached to the relayed Chrome, so it's how you adopt a // specific pre-existing tab from another session (issue #21). "targetId": p.target_id, "label": p.label, "title": p.title, "url": p.url, "type": p.target_type, "active": i == active, }) }) .collect() } /// The active tab's stable handle + current location, for `chrome-use /// current` (#26). `targetId` survives cross-process navigation, so it's the /// handle an agent should hold across a multi-step flow. pub fn active_page_info(&self) -> Option { let i = self.resolved_active_index(); self.pages.get(i).map(|p| { json!({ "tabId": format_tab_id(p.tab_id), "targetId": p.target_id, "label": p.label, "url": p.url, "title": p.title, }) }) } /// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked. /// Lets callers adopt a tab by the cross-session-stable target id. pub fn tab_id_for_target(&self, target_id: &str) -> Option { self.pages .iter() .find(|p| p.target_id == target_id) .map(|p| p.tab_id) } /// Re-pull the live target set and reconcile `self.pages`: adopt tabs that /// appeared since connect (another session's tab, or one that just /// re-attached after a cross-process nav), refresh url/title on known tabs, /// and drop tabs that are gone (clearing phantom rows). Never steals focus — /// the active tab is preserved, and re-pinned if it was pruned. Powers a live /// `tab list` and adopt-by-targetId so a fresh session can reach a stranded, /// still-filled tab without reloading it (issue #21). /// Detect targets that appeared since the `before` set (e.g. a click that /// opened a new tab via a `target=_blank` link or `window.open`), attach + /// track each in the background, and return the first newly-opened page. /// /// Lighter than [`resync_targets`] — one `getTargets` and work only on the /// new targets, no whole-tab url/title refresh — so it's cheap enough to run /// after every click. The new tab is added in the background (never steals /// the active tab, per #7/#8.1); the caller surfaces it so the agent knows a /// tab opened instead of seeing the old page (issue #24-A). pub async fn adopt_newly_opened(&mut self, before: &HashSet) -> Option { let result: GetTargetsResult = self .client .send_command_typed("Target.getTargets", &json!({}), None) .await .ok()?; let live: Vec = result .target_infos .into_iter() .filter(should_track_target) .collect(); let mut opened: Option = None; for target in &live { if before.contains(&target.target_id) || self.pages.iter().any(|p| p.target_id == target.target_id) { continue; } let attach: AttachToTargetResult = match self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: target.target_id.clone(), flatten: true, }, None, ) .await { Ok(r) => r, Err(_) => continue, }; let tab_id = self.assign_tab_id(); let page = PageInfo { tab_id, label: None, target_id: target.target_id.clone(), session_id: attach.session_id.clone(), url: target.url.clone(), title: target.title.clone(), target_type: target.target_type.clone(), }; self.add_background_page(page.clone()); let _ = self.enable_domains(&attach.session_id).await; if opened.is_none() { opened = Some(page); } } opened } pub async fn resync_targets(&mut self) -> Result<(), String> { self.client .send_command_typed::<_, Value>( "Target.setDiscoverTargets", &SetDiscoverTargetsParams { discover: true }, None, ) .await?; let result: GetTargetsResult = self .client .send_command_typed("Target.getTargets", &json!({}), None) .await?; let live: Vec = result .target_infos .into_iter() .filter(should_track_target) .collect(); let live_ids: HashSet = live.iter().map(|t| t.target_id.clone()).collect(); for target in &live { if self.update_page_target_info(target) { continue; } // A target this session hasn't tracked yet — attach and add it in the // background so it's listable/adoptable without stealing the active tab. let attach_result: AttachToTargetResult = match self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: target.target_id.clone(), flatten: true, }, None, ) .await { Ok(r) => r, // The tab may have closed between getTargets and attach, or be a // restricted page — skip it rather than failing the whole resync. Err(_) => continue, }; let tab_id = self.assign_tab_id(); self.add_background_page(PageInfo { tab_id, label: None, target_id: target.target_id.clone(), session_id: attach_result.session_id.clone(), url: target.url.clone(), title: target.title.clone(), target_type: target.target_type.clone(), }); let _ = self.enable_domains(&attach_result.session_id).await; } // Drop tabs that no longer exist so `tab list` doesn't show phantom rows — // but never prune the explicitly-pinned active target on a transient // getTargets snapshot (issue #31; see `prunable_target_ids`). let gone = prunable_target_ids(&self.pages, &live_ids, self.active_target_id.as_deref()); for tid in gone { self.remove_page_by_target_id(&tid); } // Refresh url/title from each live tab. The relay only stamps target_info // on attach, so after a navigation its cached url/title go stale (or stay // blank for a tab attached at about:blank) — which made `tab list` show // blank rows you couldn't tell apart, defeating the point of listing them // to pick a tab to adopt (issue #21). `Target.getTargetInfo` is a plain // CDP read (no Runtime fingerprint), one cheap call per tab. let sessions: Vec<(usize, String)> = self .pages .iter() .enumerate() .map(|(i, p)| (i, p.session_id.clone())) .collect(); for (i, sid) in sessions { if sid.is_empty() { continue; } if let Ok(resp) = self .client .send_command("Target.getTargetInfo", None, Some(&sid)) .await { if let Some(ti) = resp.get("targetInfo") { if let Some(page) = self.pages.get_mut(i) { if let Some(u) = ti.get("url").and_then(|v| v.as_str()) { if !u.is_empty() { page.url = u.to_string(); } } if let Some(t) = ti.get("title").and_then(|v| v.as_str()) { page.title = t.to_string(); } } } } } Ok(()) } /// If `--reuse-tab` and a tracked tab already shows `url`, switch to it /// (without reloading, so any in-page state survives) and return its info. /// Returns `None` when no tab matches and the caller should navigate/create. /// Matches on exact URL or the same origin+path (ignoring query/fragment) so /// a re-`open` of a stable entry URL lands on the existing tab instead of /// piling up duplicates (issue #21). pub async fn reuse_tab_for_url(&mut self, url: &str) -> Result, String> { self.resync_targets().await.ok(); let want = normalize_url_for_match(url); let tab_id = self .pages .iter() .find(|p| !want.is_empty() && (p.url == url || normalize_url_for_match(&p.url) == want)) .map(|p| p.tab_id); match tab_id { Some(id) => Ok(Some(self.tab_switch_by_id(id).await?)), None => Ok(None), } } /// Resolve a user-supplied `TabRef` (either `t` or a label) to the /// stable numeric `tab_id`. Returns a teaching error for unknown tabs. pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result { match tab_ref { TabRef::Id(id) => { if self.has_tab_id(*id) { Ok(*id) } else { Err(format!( "Tab {} not found; run `chrome-use tab` to list open tabs", format_tab_id(*id) )) } } TabRef::Label(name) => self .pages .iter() .find(|p| p.label.as_deref() == Some(name.as_str())) .map(|p| p.tab_id) .ok_or_else(|| { format!( "No tab with label `{}`; run `chrome-use tab` to list open tabs", name ) }), } } /// Returns true iff a tab already carries the given label. pub fn has_label(&self, label: &str) -> bool { self.pages.iter().any(|p| p.label.as_deref() == Some(label)) } /// Chrome tab-group name for tabs this manager creates, or `None` when not /// driving the user's real Chrome via the `ab-connect` extension relay. /// /// Grouping only makes sense on the shared real browser (one Chrome, many /// agents): each session's tabs go into its own group. On a launched / direct /// CDP browser the endpoint is strict, so we must NOT send the custom param — /// hence `None` there. We detect the relay by matching our `ws_url` against /// the live relay URL the native-messaging host published. fn agent_group(&self) -> Option { let via_relay = crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str()); if !via_relay { return None; } let name = DAEMON_SESSION .get() .map(String::as_str) .unwrap_or("default"); if name.is_empty() { None } else { Some(name.to_string()) } } pub async fn tab_new( &mut self, url: Option<&str>, label: Option<&str>, ) -> Result { if let Some(label) = label { if !is_valid_label(label) { return Err(format!( "Invalid tab label `{}`; labels must start with a letter and contain only \ letters, digits, `-`, and `_`", label )); } if self.has_label(label) { return Err(format!( "Label `{}` is already used by another tab; labels must be unique within a \ session", label )); } } let target_url = url.unwrap_or("about:blank"); let agent_group = self.agent_group(); let result: CreateTargetResult = self .client .send_command_typed( "Target.createTarget", &CreateTargetParams { url: target_url.to_string(), agent_group, background: Some(true), }, None, ) .await?; // We created this tab — own it so close() can clean it up. self.created_targets.insert(result.target_id.clone()); let attach: AttachToTargetResult = self .client .send_command_typed( "Target.attachToTarget", &AttachToTargetParams { target_id: result.target_id.clone(), flatten: true, }, None, ) .await?; self.enable_domains(&attach.session_id).await?; let tab_id = self.next_tab_id; self.next_tab_id += 1; let index = self.pages.len(); let label = label.map(|s| s.to_string()); self.pages.push(PageInfo { tab_id, label: label.clone(), target_id: result.target_id, session_id: attach.session_id, url: target_url.to_string(), title: String::new(), target_type: "page".to_string(), }); self.active_page_index = index; self.pin_active_target(); Ok(json!({ "tabId": format_tab_id(tab_id), "label": label, "url": target_url, "total": self.pages.len(), })) } pub async fn tab_switch(&mut self, index: usize) -> Result { if index >= self.pages.len() { return Err(format!( "Tab index {} out of range (0-{})", index, self.pages.len().saturating_sub(1) )); } self.active_page_index = index; self.pin_active_target(); let session_id = self.pages[index].session_id.clone(); self.enable_domains(&session_id).await?; // Silent: switching the agent's *internal* active page must not yank the // user's foreground tab. The page is driven in the background (focus is // emulated in enable_domains); the explicit `bringToFront` command is the // only way a tab is deliberately surfaced. let url = self.get_url().await.unwrap_or_default(); let title = self.get_title().await.unwrap_or_default(); if let Some(page) = self.pages.get_mut(index) { page.url = url.clone(); page.title = title.clone(); } let page = &self.pages[index]; Ok(json!({ "tabId": format_tab_id(page.tab_id), "label": page.label, "url": url, "title": title, })) } pub async fn tab_close(&mut self, index: Option) -> Result { let target_index = index.unwrap_or(self.active_page_index); if target_index >= self.pages.len() { return Err(format!("Tab index {} out of range", target_index)); } if self.pages.len() <= 1 { return Err("Cannot close the last tab".to_string()); } let page = self.pages.remove(target_index); self.update_active_page_after_removal(target_index); let closed_tab_id = page.tab_id; let closed_label = page.label.clone(); let _ = self .client .send_command_typed::<_, Value>( "Target.closeTarget", &CloseTargetParams { target_id: page.target_id, }, None, ) .await; let session_id = self.pages[self.active_page_index].session_id.clone(); self.enable_domains(&session_id).await?; Ok(json!({ "tabId": format_tab_id(closed_tab_id), "label": closed_label, "closed": true, })) } // ----------------------------------------------------------------------- // Emulation // ----------------------------------------------------------------------- pub async fn set_viewport( &self, width: i32, height: i32, device_scale_factor: f64, mobile: bool, ) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Emulation.setDeviceMetricsOverride", Some(json!({ "width": width, "height": height, "deviceScaleFactor": device_scale_factor, "mobile": mobile, })), Some(session_id), ) .await?; // Screencast captures the actual content area, not the emulated CSS // viewport, so resize the content area to match. if let Ok(target_id) = self.active_target_id() { if let Ok(window_info) = self .client .send_command( "Browser.getWindowForTarget", Some(json!({ "targetId": target_id })), None, ) .await { if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) { if let Err(e) = self .client .send_command( "Browser.setContentsSize", Some(json!({ "windowId": window_id, "width": width, "height": height, })), None, ) .await { eprintln!("Browser.setContentsSize failed (experimental CDP): {e}"); } } } } Ok(()) } pub async fn set_user_agent(&self, user_agent: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Emulation.setUserAgentOverride", Some(json!({ "userAgent": user_agent })), Some(session_id), ) .await?; Ok(()) } pub async fn set_emulated_media( &self, media: Option<&str>, features: Option>, ) -> Result<(), String> { let session_id = self.active_session_id()?; let mut params = json!({}); if let Some(m) = media { params["media"] = Value::String(m.to_string()); } if let Some(feats) = features { let features_arr: Vec = feats .iter() .map(|(name, value)| json!({ "name": name, "value": value })) .collect(); params["features"] = Value::Array(features_arr); } self.client .send_command("Emulation.setEmulatedMedia", Some(params), Some(session_id)) .await?; Ok(()) } pub async fn bring_to_front(&self) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command("Page.bringToFront", None, Some(session_id)) .await?; Ok(()) } pub async fn set_timezone(&self, timezone_id: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Emulation.setTimezoneOverride", Some(json!({ "timezoneId": timezone_id })), Some(session_id), ) .await?; Ok(()) } pub async fn set_locale(&self, locale: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Emulation.setLocaleOverride", Some(json!({ "locale": locale })), Some(session_id), ) .await?; Ok(()) } pub async fn set_geolocation( &self, latitude: f64, longitude: f64, accuracy: Option, ) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Emulation.setGeolocationOverride", Some(json!({ "latitude": latitude, "longitude": longitude, "accuracy": accuracy.unwrap_or(1.0), })), Some(session_id), ) .await?; Ok(()) } pub async fn grant_permissions(&self, permissions: &[String]) -> Result<(), String> { self.client .send_command( "Browser.grantPermissions", Some(json!({ "permissions": permissions })), None, ) .await?; Ok(()) } pub async fn handle_dialog( &self, accept: bool, prompt_text: Option<&str>, ) -> Result<(), String> { let session_id = self.active_session_id()?; let mut params = json!({ "accept": accept }); if let Some(text) = prompt_text { params["promptText"] = Value::String(text.to_string()); } self.client .send_command( "Page.handleJavaScriptDialog", Some(params), Some(session_id), ) .await?; Ok(()) } pub async fn upload_files( &self, selector: &str, files: &[String], ref_map: &RefMap, iframe_sessions: &HashMap, ) -> Result<(), String> { let session_id = self.active_session_id()?; let (object_id, effective_session_id) = resolve_element_object_id(&self.client, session_id, ref_map, selector, iframe_sessions) .await?; let describe: Value = self .client .send_command( "DOM.describeNode", Some(json!({ "objectId": object_id })), Some(&effective_session_id), ) .await?; let backend_node_id = describe .get("node") .and_then(|n| n.get("backendNodeId")) .and_then(|v| v.as_i64()) .ok_or("Could not get backendNodeId for file input")?; self.client .send_command( "DOM.setFileInputFiles", Some(json!({ "files": files, "backendNodeId": backend_node_id, })), Some(&effective_session_id), ) .await .map_err(|e| { // Chrome's chrome.debugger API (the extension-relay transport) // forbids DOM.setFileInputFiles for security, surfacing as an // opaque `-32000 "Not allowed"`. Translate it into an actionable // message rather than leaking the raw CDP error (issue #13). if e.contains("Not allowed") || e.contains("-32000") { "file upload isn't supported over the extension relay — \ Chrome's chrome.debugger API forbids DOM.setFileInputFiles. \ Use a direct-CDP session instead: \ `chrome-use --session up --launch open ` (carry your \ login over with `cookies export` | `cookies set --curl`), \ then run `upload` in that session. \ See https://github.com/leeguooooo/chrome-use/issues/13" .to_string() } else { e } })?; Ok(()) } pub async fn add_script_to_evaluate(&self, source: &str) -> Result { let session_id = self.active_session_id()?; let result = self .client .send_command( "Page.addScriptToEvaluateOnNewDocument", Some(json!({ "source": source })), Some(session_id), ) .await?; Ok(result .get("identifier") .and_then(|v| v.as_str()) .unwrap_or("") .to_string()) } pub async fn remove_script_to_evaluate(&self, identifier: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Page.removeScriptToEvaluateOnNewDocument", Some(json!({ "identifier": identifier })), Some(session_id), ) .await?; Ok(()) } pub async fn tab_switch_by_id(&mut self, tab_id: u32) -> Result { let index = self .pages .iter() .position(|p| p.tab_id == tab_id) .ok_or_else(|| format!("Tab ID {} not found", tab_id))?; self.tab_switch(index).await } pub async fn tab_close_by_id(&mut self, tab_id: Option) -> Result { let index = match tab_id { Some(id) => Some( self.pages .iter() .position(|p| p.tab_id == id) .ok_or_else(|| format!("Tab ID {} not found", id))?, ), None => None, }; self.tab_close(index).await } pub fn assign_tab_id(&mut self) -> u32 { let id = self.next_tab_id; self.next_tab_id += 1; id } pub fn add_page(&mut self, page: PageInfo) { let index = self.pages.len(); self.pages.push(page); self.active_page_index = index; self.pin_active_target(); } /// Add a passively-discovered page WITHOUT changing the active tab. /// /// On a shared browser (ab-connect), `Target.targetCreated` events stream in /// for tabs the user or OTHER agent sessions open. Those are drained on every /// command; routing them through `add_page` made the active tab silently jump /// to a foreign tab, so the session's own `eval`/`get title`/`screenshot` /// landed on the wrong page. Passively-tracked pages must not steal focus — /// only explicit opens (`tab new`, switch) set the active tab. pub fn add_background_page(&mut self, page: PageInfo) { if self.pages.iter().any(|p| p.target_id == page.target_id) { return; } self.pages.push(page); } pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool { update_page_target_info_in_pages(&mut self.pages, target) } pub fn remove_page_by_target_id(&mut self, target_id: &str) { if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) { let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id); self.pages.remove(pos); self.update_active_page_after_removal(pos); // If we just removed the pinned active target, the pin now dangles and // `resolved_active_index` silently falls back to `active_page_index`. // After a passive about:blank discovery that index can point at a blank // tab, so `wait` → eval/snapshot lands on about:blank (issue #7). Re-pin // to the surviving active page so the pin is never left pointing at a // target that no longer exists. if removed_was_pinned { self.pin_active_target(); } } } pub fn has_target(&self, target_id: &str) -> bool { self.pages.iter().any(|p| p.target_id == target_id) } pub fn page_count(&self) -> usize { self.pages.len() } /// Returns the stable `tab_id` of the currently active page, if any. pub fn active_tab_id(&self) -> Option { self.pages.get(self.active_page_index).map(|p| p.tab_id) } /// Returns true if a tab with the given stable `tab_id` is still open. pub fn has_tab_id(&self, tab_id: u32) -> bool { self.pages.iter().any(|p| p.tab_id == tab_id) } pub fn pages_list(&self) -> Vec { self.pages.clone() } pub fn visited_origins(&self) -> &HashSet { &self.visited_origins } pub async fn set_download_behavior(&self, download_path: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client .send_command( "Browser.setDownloadBehavior", Some(json!({ "behavior": "allowAndName", "downloadPath": download_path, "eventsEnabled": true, })), Some(session_id), ) .await?; Ok(()) } } /// Core network-idle polling loop, extracted so it can be unit-tested without a /// full `BrowserManager` / CDP connection. /// /// Returns `Ok(())` once no network requests have been in-flight for at least /// 500 ms, or `Err` if `overall_timeout` elapses first. async fn poll_network_idle( session_id: &str, rx: &mut broadcast::Receiver, overall_timeout: tokio::time::Duration, ) -> Result<(), String> { let pending = Arc::new(Mutex::new(HashSet::::new())); tokio::time::timeout(overall_timeout, async { let mut idle_start: Option = None; loop { let recv_result = tokio::time::timeout(tokio::time::Duration::from_millis(600), rx.recv()).await; match recv_result { Ok(Ok(event)) if event.session_id.as_deref() == Some(session_id) => { let mut p = pending.lock().await; match event.method.as_str() { "Network.requestWillBeSent" => { if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str()) { p.insert(id.to_string()); idle_start = None; } } "Network.loadingFinished" | "Network.loadingFailed" => { if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str()) { p.remove(id); if p.is_empty() { idle_start = Some(tokio::time::Instant::now()); } } } "Page.loadEventFired" if p.is_empty() => { idle_start = Some(tokio::time::Instant::now()); } _ => {} } } Ok(Ok(_)) => {} Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, Ok(Err(_)) => break, Err(_) => { // Timeout on recv -- if no pending requests, start (or // continue) the idle timer instead of returning // immediately. This prevents false-positive idle // detection when the subscription starts after the page // has already loaded (e.g. cached pages). let p = pending.lock().await; if p.is_empty() && idle_start.is_none() { idle_start = Some(tokio::time::Instant::now()); } } } if let Some(start) = idle_start { if start.elapsed() >= tokio::time::Duration::from_millis(500) { return Ok(()); } } } Ok(()) }) .await .map_err(|_| "Timeout waiting for networkidle".to_string())? } async fn connect_cdp_with_retry( ws_url: &str, total_timeout: Duration, poll_interval: Duration, ) -> Result { let deadline = Instant::now() + total_timeout; loop { match CdpClient::connect(ws_url).await { Ok(client) => return Ok(client), Err(err) => { if Instant::now() >= deadline { return Err(err); } } } tokio::time::sleep(poll_interval).await; } } async fn initialize_lightpanda_manager( ws_url: String, process: BrowserProcess, ) -> Result { let deadline = Instant::now() + LIGHTPANDA_TARGET_INIT_TIMEOUT; let mut process = Some(process); loop { let client = match connect_cdp_with_retry( &ws_url, LIGHTPANDA_CDP_CONNECT_TIMEOUT, LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL, ) .await { Ok(client) => client, Err(err) => { if Instant::now() >= deadline { return Err(lightpanda_target_init_timeout(Some(&err))); } tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; continue; } }; let mut manager = BrowserManager { client: Arc::new(client), browser_process: None, ws_url: ws_url.clone(), pages: Vec::new(), active_page_index: 0, default_timeout_ms: 25_000, download_path: None, ignore_https_errors: false, visited_origins: HashSet::new(), created_targets: HashSet::new(), active_target_id: None, next_tab_id: 1, capture_console: console_capture_enabled(), }; match discover_and_attach_lightpanda_targets(&mut manager, deadline).await { Ok(()) => { manager.browser_process = process.take(); return Ok(manager); } Err(err) => { if Instant::now() >= deadline { return Err(lightpanda_target_init_timeout(Some(&err))); } tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; } } } } async fn discover_and_attach_lightpanda_targets( manager: &mut BrowserManager, deadline: Instant, ) -> Result<(), String> { run_with_lightpanda_deadline( deadline, manager.discover_and_attach_targets(), "Target domain initialization attempt exceeded the remaining startup deadline", ) .await } fn remaining_until(deadline: Instant) -> Option { deadline.checked_duration_since(Instant::now()) } async fn run_with_lightpanda_deadline( deadline: Instant, operation: F, timeout_context: &'static str, ) -> Result where F: Future>, { let remaining = remaining_until(deadline) .ok_or_else(|| lightpanda_target_init_timeout(Some("deadline expired before retry")))?; match tokio::time::timeout(remaining, operation).await { Ok(result) => result, Err(_) => Err(lightpanda_target_init_timeout(Some(timeout_context))), } } fn lightpanda_target_init_timeout(last_error: Option<&str>) -> String { let mut message = format!( "Timed out after {}ms waiting for Lightpanda Target domain to initialize", LIGHTPANDA_TARGET_INIT_TIMEOUT.as_millis(), ); if let Some(last_error) = last_error { message.push_str(&format!("\nLast error: {}", last_error)); } message } async fn resolve_cdp_url(input: &str) -> Result { if input.starts_with("ws://") || input.starts_with("wss://") { return Ok(input.to_string()); } if input.starts_with("http://") || input.starts_with("https://") { let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?; // If no explicit port and path is empty/root, this is likely a provider // WebSocket endpoint (e.g. https://xxx.cdp0.browser-use.com). Convert // the scheme to ws/wss and connect directly instead of probing :9222. if parsed.port().is_none() && (parsed.path().is_empty() || parsed.path() == "/") { let ws_scheme = if input.starts_with("https://") { "wss" } else { "ws" }; let mut ws_url = parsed.clone(); let _ = ws_url.set_scheme(ws_scheme); return Ok(ws_url.to_string()); } let host = parsed .host_str() .ok_or_else(|| format!("No host in CDP URL: {}", input))?; let port = parsed.port().unwrap_or(9222); let query = parsed.query().map(|q| q.to_string()); return discover_cdp_url(host, port, query.as_deref()).await; } // Try as numeric port if let Ok(port) = input.parse::() { return discover_cdp_url("127.0.0.1", port, None).await; } Err(format!( "Invalid CDP target: {}. Use ws://, http://, or a port number.", input )) } #[cfg(test)] mod tests { use super::*; use tokio::time::sleep; #[test] fn test_format_tab_id() { assert_eq!(format_tab_id(1), "t1"); assert_eq!(format_tab_id(42), "t42"); } #[test] fn liveness_responded_is_alive_for_both_kinds() { assert!(connection_alive_from_probe(LivenessProbe::Responded, true)); assert!(connection_alive_from_probe(LivenessProbe::Responded, false)); } #[test] fn liveness_transport_error_is_dead_for_both_kinds() { // A closed/reset WebSocket is a genuine death — reconnect in both cases. assert!(!connection_alive_from_probe( LivenessProbe::TransportError, true )); assert!(!connection_alive_from_probe( LivenessProbe::TransportError, false )); } #[test] fn liveness_timeout_keeps_external_attach_alive() { // Regression guard for the remote-debugging consent storm: a timed-out // probe must NOT tear down an externally-attached browser, otherwise the // daemon reconnects and re-pops Chrome's "Allow remote debugging?" modal // on every command (endless prompts + browser freeze). assert!(connection_alive_from_probe(LivenessProbe::TimedOut, true)); } #[test] fn liveness_timeout_marks_launched_browser_dead() { // A browser we launched that stops responding is a real problem worth a // reconnect (and has no consent modal to worry about). assert!(!connection_alive_from_probe(LivenessProbe::TimedOut, false)); } #[test] fn test_parse_tab_ref_id() { assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1))); assert_eq!(TabRef::parse("t42"), Ok(TabRef::Id(42))); assert_eq!(TabRef::parse("T7"), Ok(TabRef::Id(7))); } #[test] fn test_parse_tab_ref_label() { assert_eq!(TabRef::parse("docs"), Ok(TabRef::Label("docs".to_string()))); assert_eq!( TabRef::parse("app-2"), Ok(TabRef::Label("app-2".to_string())) ); assert_eq!( TabRef::parse("my_tab"), Ok(TabRef::Label("my_tab".to_string())) ); } #[test] fn test_parse_tab_ref_rejects_bare_integer() { let err = TabRef::parse("2").unwrap_err(); assert!( err.contains("positional integers are not accepted"), "error should teach the user to use `t`: {}", err ); assert!(err.contains("t2")); } #[test] fn test_parse_tab_ref_rejects_empty() { assert!(TabRef::parse("").is_err()); assert!(TabRef::parse(" ").is_err()); } #[test] fn test_parse_tab_ref_rejects_zero() { let err = TabRef::parse("t0").unwrap_err(); assert!(err.contains("start at t1")); } #[test] fn test_parse_tab_ref_rejects_invalid_label() { assert!(TabRef::parse("2docs").is_err()); assert!(TabRef::parse("-docs").is_err()); assert!(TabRef::parse("docs!").is_err()); assert!(TabRef::parse("docs space").is_err()); } #[test] fn test_is_valid_label() { assert!(is_valid_label("docs")); assert!(is_valid_label("Docs")); assert!(is_valid_label("app-2")); assert!(is_valid_label("my_tab")); assert!(!is_valid_label("")); assert!(!is_valid_label("2docs")); assert!(!is_valid_label("-docs")); assert!(!is_valid_label("docs!")); } #[test] fn test_should_track_popup_target_with_empty_url() { let target = TargetInfo { target_id: "popup-1".to_string(), target_type: "page".to_string(), title: String::new(), url: String::new(), attached: None, browser_context_id: None, }; assert!(should_track_target(&target)); } #[test] fn test_should_not_track_internal_chrome_target() { let target = TargetInfo { target_id: "chrome-tab".to_string(), target_type: "page".to_string(), title: "New Tab".to_string(), url: "chrome://newtab/".to_string(), attached: None, browser_context_id: None, }; assert!(!should_track_target(&target)); } #[test] fn test_update_page_target_info_in_pages_updates_existing_page() { let mut pages = vec![PageInfo { tab_id: 1, label: None, target_id: "popup-1".to_string(), session_id: "session-1".to_string(), url: String::new(), title: String::new(), target_type: "page".to_string(), }]; let target = TargetInfo { target_id: "popup-1".to_string(), target_type: "page".to_string(), title: "Popup".to_string(), url: "https://example.com/popup".to_string(), attached: None, browser_context_id: None, }; assert!(update_page_target_info_in_pages(&mut pages, &target)); assert_eq!(pages[0].url, "https://example.com/popup"); assert_eq!(pages[0].title, "Popup"); } #[test] fn test_active_page_index_after_removal_shifts_when_earlier_tab_is_removed() { assert_eq!(active_page_index_after_removal(2, 0, 3), 1); } #[test] fn test_active_page_index_after_removal_keeps_same_slot_when_later_tab_is_removed() { assert_eq!(active_page_index_after_removal(1, 2, 3), 1); } #[test] fn test_active_page_index_after_removal_clamps_when_active_last_tab_is_removed() { assert_eq!(active_page_index_after_removal(3, 3, 3), 2); } #[test] fn test_active_page_index_after_removal_resets_when_last_page_disappears() { assert_eq!(active_page_index_after_removal(0, 0, 0), 0); } fn page(target_id: &str) -> PageInfo { PageInfo { tab_id: 1, label: None, target_id: target_id.to_string(), session_id: format!("session-{target_id}"), url: String::new(), title: String::new(), target_type: "page".to_string(), } } // --- issue #21: --reuse-tab URL matching ignores query/fragment --- #[test] fn normalize_url_match_strips_query_and_fragment() { // Two opens of the "same" SSO page differ only in volatile query/hash — // they must normalize equal so --reuse-tab lands on the existing tab. let a = normalize_url_for_match( "https://login.account.rakuten.com/sso/authorize?client_id=x&state=abc#/sign_in", ); let b = normalize_url_for_match( "https://login.account.rakuten.com/sso/authorize?client_id=y&state=zzz#/forgot", ); assert_eq!(a, b); assert_eq!(a, "https://login.account.rakuten.com/sso/authorize"); } #[test] fn normalize_url_match_distinguishes_different_paths() { let cart = normalize_url_for_match("https://cart.step.rakuten.co.jp/cart"); let order = normalize_url_for_match("https://cart.step.rakuten.co.jp/order"); assert_ne!(cart, order); } #[test] fn normalize_url_match_passes_through_unparseable() { assert_eq!(normalize_url_for_match("not a url"), "not a url"); } // --- issue #14: a pinned target must keep commands on the right tab --- #[test] fn resolve_active_index_prefers_pin_over_stale_index() { // The tab we opened ("A") is at index 0, but `active_page_index` is stale // and points at a foreign tab ("B"). With the pin set, resolution sticks // to A — the drift that bit issue #14 (eval landing on /notifications). let pages = vec![page("A"), page("B")]; assert_eq!(resolve_active_index(&pages, Some("A"), 1), 0); } #[test] fn resolve_active_index_unpinned_drifts_with_index() { // Documents the pre-fix hazard: with no pin, resolution blindly trusts // `active_page_index`, so a clamp/reorder from passive tab discovery lands // commands on a foreign tab. This is exactly what pinning on `open` avoids. let pages = vec![page("A"), page("B")]; assert_eq!(resolve_active_index(&pages, None, 1), 1); } #[test] fn resolve_active_index_falls_back_when_pin_is_gone() { // If the pinned tab was closed (target_id no longer present), fall back to // the index rather than panicking or returning a bogus slot. let pages = vec![page("A"), page("B")]; assert_eq!(resolve_active_index(&pages, Some("CLOSED"), 1), 1); } // --- issue: `open` must not hijack a user's tab on the relay (dogfood) --- #[test] fn active_not_owned_when_only_user_tabs_discovered() { // A fresh relay session passively attached to the user's tabs but created // none — so navigate must NOT reuse the active tab (it'd clobber the // user's page); it has to open its own first. let pages = vec![page("USER_A"), page("USER_B")]; let created = HashSet::new(); assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created)); } #[test] fn active_owned_when_session_created_the_tab() { let pages = vec![page("USER_A"), page("OURS")]; let mut created = HashSet::new(); created.insert("OURS".to_string()); // Active pinned to the tab we created → safe to navigate it. assert!(active_index_is_owned(&pages, Some("OURS"), 1, &created)); // But pinned to the user's tab → not owned, even though we own another. assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created)); } #[test] fn active_not_owned_when_no_pages() { let created = HashSet::new(); assert!(!active_index_is_owned(&[], None, 0, &created)); } #[test] fn prune_protects_pinned_target_on_transient_snapshot() { // The relay returned a getTargets snapshot missing the pinned tab "A" // (it hopped to another window). "B" is also absent. Without protection // both would be pruned and the next command would drift; with the pin // protected, only the genuinely-unpinned "B" is dropped (issue #31). let pages = vec![page("A"), page("B")]; let live: HashSet = HashSet::new(); // snapshot returned neither let gone = prunable_target_ids(&pages, &live, Some("A")); assert_eq!(gone, vec!["B".to_string()]); // With no pin, both are prunable (unchanged behavior). let gone_unpinned = prunable_target_ids(&pages, &live, None); assert_eq!(gone_unpinned.len(), 2); // A pinned target that IS in the live set is simply not prunable anyway. let mut live2 = HashSet::new(); live2.insert("A".to_string()); assert_eq!(prunable_target_ids(&pages, &live2, Some("A")), vec!["B".to_string()]); } #[test] fn resolve_active_index_pin_survives_passive_background_tab() { // A foreign tab ("Z") gets appended by passive discovery after we pinned // "A". The append doesn't shift A's position, and the pin keeps us on A // regardless of what `active_page_index` happens to be. let pages = vec![page("A"), page("B"), page("Z")]; assert_eq!(resolve_active_index(&pages, Some("A"), 2), 0); } // issue #7: removing the pinned active target must re-anchor the pin to a // surviving page. Models `remove_page_by_target_id`'s index + re-pin steps // purely (BrowserManager needs a live CDP client, so the method itself can't // be unit-constructed). The invariant: after removal the pin never dangles // and never silently resolves to a passively-discovered about:blank tab. fn simulate_remove( target_ids: &[&str], active_index: usize, pinned: &str, remove_id: &str, ) -> (Vec, usize, Option) { let pos = target_ids.iter().position(|t| *t == remove_id).unwrap(); let removed_was_pinned = pinned == remove_id; let mut pages: Vec = target_ids.iter().map(|s| s.to_string()).collect(); pages.remove(pos); let new_active = active_page_index_after_removal(active_index, pos, pages.len()); let new_pin = if removed_was_pinned { pages.get(new_active).cloned() } else { Some(pinned.to_string()) }; (pages, new_active, new_pin) } fn resolve_active<'a>( pages: &'a [String], active_index: usize, pin: &Option, ) -> &'a str { if let Some(tid) = pin { if let Some(p) = pages.iter().find(|p| *p == tid) { return p; } } pages.get(active_index).map(|s| s.as_str()).unwrap_or("") } #[test] fn test_removing_unpinned_blank_keeps_pin_on_real_page() { // pages = [creepjs(pinned, active), about:blank]; a passive blank closes. let (pages, active, pin) = simulate_remove(&["creepjs", "blank"], 0, "creepjs", "blank"); assert_eq!(resolve_active(&pages, active, &pin), "creepjs"); } #[test] fn test_removing_pinned_page_repins_to_survivor_not_dangling() { // pages = [blank, creepjs(pinned, active)]; the pinned page itself closes. let (pages, active, pin) = simulate_remove(&["blank", "creepjs"], 1, "creepjs", "creepjs"); // pin must point at a page that still exists (no dangling fallback). let resolved = resolve_active(&pages, active, &pin); assert!( pages.iter().any(|p| p == resolved), "resolved a dangling target" ); assert_eq!(resolved, "blank"); } #[test] fn test_resolve_falls_back_cleanly_when_pin_dangles() { // A stale pin (target already gone) must resolve to a real surviving page, // never panic or return the missing id. let pages = vec!["creepjs".to_string(), "blank".to_string()]; let pin = Some("gone".to_string()); assert_eq!(resolve_active(&pages, 0, &pin), "creepjs"); } #[test] fn test_validate_launch_options_extensions_and_cdp() { let ext = vec!["/path/to/ext".to_string()]; assert!(validate_launch_options(Some(&ext), true, None, None, false, None,).is_err()); } #[test] fn test_validate_launch_options_profile_and_cdp() { assert!(validate_launch_options(None, true, Some("/path"), None, false, None,).is_err()); } #[test] fn test_validate_launch_options_storage_state_and_profile() { assert!(validate_launch_options( None, false, Some("/profile"), Some("/state.json"), false, None, ) .is_err()); } #[test] fn test_validate_launch_options_storage_state_and_extensions() { let ext = vec!["/ext".to_string()]; assert!( validate_launch_options(Some(&ext), false, None, Some("/state.json"), false, None,) .is_err() ); } #[test] fn test_validate_launch_options_allow_file_access_firefox() { assert!( validate_launch_options(None, false, None, None, true, Some("/usr/bin/firefox"),) .is_err() ); } #[test] fn test_validate_launch_options_valid() { assert!(validate_launch_options(None, false, None, None, false, None,).is_ok()); } #[test] fn test_to_ai_friendly_error_strict_mode() { assert_eq!( to_ai_friendly_error("Strict mode violation: multiple elements"), "Element matched multiple results. Use a more specific selector." ); } #[test] fn test_to_ai_friendly_error_not_visible() { assert_eq!( to_ai_friendly_error("element is not visible"), "Element exists but is not visible. Wait for it to become visible or scroll it into view." ); } #[test] fn test_to_ai_friendly_error_intercept() { assert_eq!( to_ai_friendly_error("element intercepted by another element"), "Another element is covering the target element. Try scrolling or closing overlays." ); } #[test] fn test_to_ai_friendly_error_timeout() { assert_eq!( to_ai_friendly_error("Timeout waiting for element"), "Operation timed out. The page may still be loading or the element may not exist." ); } #[test] fn test_to_ai_friendly_error_not_found() { assert_eq!( to_ai_friendly_error("Element not found"), "Element not found. Verify the selector is correct and the element exists in the DOM." ); } #[test] fn test_to_ai_friendly_error_unknown() { let msg = "Some custom error message"; assert_eq!(to_ai_friendly_error(msg), msg); } /// Errors containing "not found" but NOT "element" should pass through unchanged. #[test] fn test_to_ai_friendly_error_ignores_non_element_not_found() { let err = "Chrome not found. Install Chrome or use --executable-path."; assert_eq!(to_ai_friendly_error(err), err); } #[test] fn test_to_ai_friendly_error_catches_no_element() { let mapped = "Element not found. Verify the selector is correct and the element exists in the DOM."; assert_eq!(to_ai_friendly_error("No element found for css 'x'"), mapped); } #[test] fn test_remaining_until_returns_none_for_past_deadline() { let deadline = Instant::now() .checked_sub(Duration::from_millis(1)) .expect("past instant should be representable"); assert!(remaining_until(deadline).is_none()); } #[tokio::test] async fn test_run_with_lightpanda_deadline_enforces_timeout() { let deadline = Instant::now() + Duration::from_millis(25); let err = tokio::time::timeout( Duration::from_secs(1), run_with_lightpanda_deadline( deadline, async { sleep(Duration::from_millis(100)).await; Ok::<(), String>(()) }, "Target domain initialization attempt exceeded the remaining startup deadline", ), ) .await .expect("outer timeout should not fire") .unwrap_err(); assert!(err.contains( "Timed out after 10000ms waiting for Lightpanda Target domain to initialize" )); assert!(err.contains("remaining startup deadline")); } #[tokio::test] async fn test_run_with_lightpanda_deadline_returns_operation_error() { let deadline = Instant::now() + Duration::from_secs(1); let err = run_with_lightpanda_deadline( deadline, async { Err::<(), String>("Target.getTargets failed".to_string()) }, "unused timeout context", ) .await .unwrap_err(); assert_eq!(err, "Target.getTargets failed"); } #[test] fn test_lightpanda_target_init_timeout_includes_last_error() { let err = lightpanda_target_init_timeout(Some("Target.setDiscoverTargets failed")); assert!(err.contains( "Timed out after 10000ms waiting for Lightpanda Target domain to initialize" )); assert!(err.contains("Target.setDiscoverTargets failed")); } #[test] fn test_is_internal_chrome_target() { assert!(is_internal_chrome_target("chrome://newtab/")); assert!(is_internal_chrome_target( "chrome://omnibox-popup.top-chrome/" )); assert!(is_internal_chrome_target( "chrome-extension://abc123/popup.html" )); assert!(is_internal_chrome_target( "devtools://devtools/bundled/inspector.html" )); assert!(!is_internal_chrome_target("https://example.com")); assert!(!is_internal_chrome_target("http://localhost:3000")); assert!(!is_internal_chrome_target("about:blank")); } // ----------------------------------------------------------------------- // poll_network_idle tests // ----------------------------------------------------------------------- fn cdp_event(method: &str, session_id: &str, params: Value) -> CdpEvent { CdpEvent { method: method.to_string(), params, session_id: Some(session_id.to_string()), } } /// Regression test for #846: when no network events arrive at all (e.g. /// page fully served from cache), poll_network_idle must NOT return /// instantly. It should observe at least 500 ms of idle before resolving. #[tokio::test] async fn test_network_idle_no_events_does_not_return_instantly() { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; let start = tokio::time::Instant::now(); let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) .await .expect("outer timeout should not fire"); assert!(result.is_ok()); let elapsed = start.elapsed(); assert!( elapsed >= Duration::from_millis(500), "network idle returned in {:?}, expected >= 500ms", elapsed ); drop(tx); } /// Normal flow: requests start and finish, idle is detected after the last /// request completes and 500 ms of silence passes. #[tokio::test] async fn test_network_idle_after_requests_complete() { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; let _keep_alive = tx.clone(); tokio::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", session, json!({ "requestId": "r1" }), )); sleep(Duration::from_millis(100)).await; let _ = tx.send(cdp_event( "Network.loadingFinished", session, json!({ "requestId": "r1" }), )); }); let start = tokio::time::Instant::now(); let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) .await .expect("outer timeout should not fire"); assert!(result.is_ok()); let elapsed = start.elapsed(); assert!( elapsed >= Duration::from_millis(500), "should wait >= 500ms after last request finishes, got {:?}", elapsed ); } /// A new request arriving during the idle window resets the timer. #[tokio::test] async fn test_network_idle_resets_on_new_request() { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; let _keep_alive = tx.clone(); tokio::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", session, json!({ "requestId": "r1" }), )); sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.loadingFinished", session, json!({ "requestId": "r1" }), )); // Wait 200ms (< 500ms idle window), then fire another request sleep(Duration::from_millis(200)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", session, json!({ "requestId": "r2" }), )); sleep(Duration::from_millis(100)).await; let _ = tx.send(cdp_event( "Network.loadingFinished", session, json!({ "requestId": "r2" }), )); }); let start = tokio::time::Instant::now(); let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) .await .expect("outer timeout should not fire"); assert!(result.is_ok()); let elapsed = start.elapsed(); // r2 finishes at ~400ms; idle should be detected at ~900ms assert!( elapsed >= Duration::from_millis(800), "should wait for idle after second request, got {:?}", elapsed ); } /// When the overall timeout expires before idle is reached, the function /// returns an error. #[tokio::test] async fn test_network_idle_overall_timeout() { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; // Keep sending requests so idle is never reached tokio::spawn(async move { for i in 0u64.. { let _ = tx.send(cdp_event( "Network.requestWillBeSent", session, json!({ "requestId": format!("r{}", i) }), )); sleep(Duration::from_millis(100)).await; } }); let result = poll_network_idle(session, &mut rx, Duration::from_millis(800)).await; assert!(result.is_err()); assert!(result .unwrap_err() .contains("Timeout waiting for networkidle")); } }