Files
chrome-use/cli/src/native/browser.rs
T
leeguooooo 7085f3bf36 fix(relay): don't prune the pinned target on a transient getTargets snapshot (#31)
Driving a busy real Chrome via the relay, a single Target.getTargets call
occasionally returns a different window's tabs ('tab list hops windows'). resync
pruned every tracked page absent from that snapshot — including the agent's
explicitly-adopted (pinned) tab — after which active-target resolution fell back
to active_page_index and eval/click/snapshot drifted onto a foreign tab
(about:blank / chrome-extension:// / the user's page), breaking any 3+ step flow.

prunable_target_ids() now protects the pinned active target from snapshot-based
pruning; a genuine close still arrives as Target.targetDestroyed (event drain) and
removes it properly. Unit-tested.
2026-06-15 17:00:38 +09:00

3047 lines
111 KiB
Rust

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<String> = 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<String>,
pinned: Option<&str>,
) -> Vec<String> {
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<String>,
) -> 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 <name>`. Labels are agent-assigned and never
/// auto-generated, never rewritten on navigation, and unique within a
/// session. Agents use labels instead of `t<N>` for readable multi-tab
/// workflows.
pub label: Option<String>,
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<N>` 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<Self, String> {
let input = input.trim();
if input.is_empty() {
return Err("Empty tab reference; expected `t<N>` (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<N>` 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<CdpClient>,
browser_process: Option<BrowserProcess>,
ws_url: String,
pages: Vec<PageInfo>,
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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
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<Self, String> {
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, String> {
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, String> {
Self::connect_cdp_inner(url, true, None).await
}
pub async fn connect_cdp_with_headers(
url: &str,
headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> {
Self::connect_cdp_inner(url, false, headers).await
}
async fn connect_cdp_inner(
url: &str,
direct_page: bool,
headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> {
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<Self, String> {
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<TargetInfo> = 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<Value, String> {
// 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<String> = 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<CdpEvent>,
) -> 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<CdpEvent>,
) -> 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<String, String> {
let result = self.evaluate_simple("location.href").await?;
Ok(result.as_str().unwrap_or("").to_string())
}
pub async fn get_title(&self) -> Result<String, String> {
let result = self.evaluate_simple("document.title").await?;
Ok(result.as_str().unwrap_or("").to_string())
}
pub async fn get_content(&self) -> Result<String, String> {
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<Value>) -> Result<Value, String> {
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<Value, String> {
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<Value> {
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<N>` (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<Value> {
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<u32> {
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<String>) -> Option<PageInfo> {
let result: GetTargetsResult = self
.client
.send_command_typed("Target.getTargets", &json!({}), None)
.await
.ok()?;
let live: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(should_track_target)
.collect();
let mut opened: Option<PageInfo> = 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<TargetInfo> = result
.target_infos
.into_iter()
.filter(should_track_target)
.collect();
let live_ids: HashSet<String> = 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<Option<Value>, 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<N>` 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<u32, String> {
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<String> {
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<Value, String> {
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<Value, String> {
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<usize>) -> Result<Value, String> {
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<Vec<(String, String)>>,
) -> 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<Value> = 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<f64>,
) -> 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<String, String>,
) -> 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 <url>` (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<String, String> {
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<Value, String> {
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<u32>) -> Result<Value, String> {
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<u32> {
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<PageInfo> {
self.pages.clone()
}
pub fn visited_origins(&self) -> &HashSet<String> {
&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<CdpEvent>,
overall_timeout: tokio::time::Duration,
) -> Result<(), String> {
let pending = Arc::new(Mutex::new(HashSet::<String>::new()));
tokio::time::timeout(overall_timeout, async {
let mut idle_start: Option<tokio::time::Instant> = 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<CdpClient, String> {
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<BrowserManager, String> {
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<Duration> {
deadline.checked_duration_since(Instant::now())
}
async fn run_with_lightpanda_deadline<F, T>(
deadline: Instant,
operation: F,
timeout_context: &'static str,
) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
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<String, String> {
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::<u16>() {
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<N>`: {}",
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<String> = 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<String>, usize, Option<String>) {
let pos = target_ids.iter().position(|t| *t == remove_id).unwrap();
let removed_was_pinned = pinned == remove_id;
let mut pages: Vec<String> = 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<String>,
) -> &'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::<CdpEvent>(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::<CdpEvent>(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::<CdpEvent>(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::<CdpEvent>(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"));
}
}