Files
chrome-use/cli/src/native/browser.rs
T
leeguooooo 23ab4ce68f fix(relay): don't hijack a user tab on open; surface keydown/keyup + canvas hint
Dogfooding a canvas game over the extension relay surfaced three issues:

1. (serious) A fresh relay session's first `open` navigated one of the USER's
   existing tabs instead of opening its own — in testing it replaced a
   half-filled form with the target site. On connect the daemon passively
   attaches to the user's tabs and pinned one as active; navigate() then drove
   it. Now: on the relay (agent_group set), if the active tab isn't one this
   session created, navigate() opens its own tab in the session's group first.
   Off the relay (a browser we launched) reusing the active tab stays correct.
   Pure helper active_index_is_owned() + regression tests.

2. (discoverability) `keydown <key>` / `keyup <key>` (hold-to-move, essential
   for games/shortcuts) already existed as commands+daemon handlers but were
   absent from --help and the skill, so they were undiscoverable. Documented in
   --help, the core skill, and the canvas-app hint.

3. (UX) Canvas/WebGL pages expose almost no a11y tree, so `snapshot` is empty
   and agents get stuck hunting refs. snapshot now detects a viewport-dominating
   canvas with a sparse tree and prints a hint pointing at the screenshot +
   coordinate-click + keydown/keyup path.

Verified live over the relay: `open` now lands the game in its own new tab with
the user's tabs (incl. the Rakuten recovery form) untouched; the canvas hint
fires on the game page; `close` cleans up only the session's own tab.
2026-06-13 23:27:17 +09:00

2919 lines
105 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
}
/// 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))
}
};
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?;
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()
}
/// 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).
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.
let gone: Vec<String> = self
.pages
.iter()
.map(|p| p.target_id.clone())
.filter(|tid| !live_ids.contains(tid))
.collect();
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 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"));
}
}