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.
3047 lines
111 KiB
Rust
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"));
|
|
}
|
|
}
|