feat(cli): browser start --auto-connect to attach to a running Chrome (#545)

Adds `browser start --auto-connect` (env `ACTIONBOOK_AUTO_CONNECT`) to auto-discover and attach to a locally-running Chrome with remote debugging enabled. Discovery reads Chrome's DevToolsActivePort file, falls back to ports [9222, 9229], and returns a ws:// endpoint. Mutually exclusive with --cdp-endpoint (including config/env), --mode cloud, -p/--provider, --mode extension; skips local-session reuse so discovery always runs. Closing an auto-connect session releases the attachment without killing the external Chrome (locked by E2E).

New error codes: CHROME_AUTO_CONNECT_NOT_FOUND, CHROME_CDP_UNREACHABLE.

Closes ACT-953.
This commit is contained in:
Junliang Feng
2026-04-17 17:11:34 +08:00
committed by GitHub
parent d61366a9f0
commit 9401bbee24
8 changed files with 843 additions and 0 deletions
@@ -211,6 +211,7 @@ pub async fn execute(cmd: &Cmd, registry: &SharedRegistry) -> ActionResult {
// tab id is gone after debugger detach, so don't carry it through.
tab_id: None,
cdp_endpoint: effective_cdp_endpoint,
auto_connect: false,
provider: effective_provider,
header: effective_headers,
session: None,
+64
View File
@@ -74,6 +74,12 @@ pub struct Cmd {
/// Connect to existing CDP endpoint
#[arg(long)]
pub cdp_endpoint: Option<String>,
/// Auto-discover and connect to a locally running Chrome with remote debugging enabled.
/// Reads Chrome's DevToolsActivePort file, then probes ports 9222 and 9229.
/// Mutually exclusive with --cdp-endpoint, --provider, and --mode cloud/extension.
#[arg(long, env = "ACTIONBOOK_AUTO_CONNECT", conflicts_with = "cdp_endpoint")]
#[serde(default)]
pub auto_connect: bool,
/// Cloud browser provider (implies --mode cloud).
///
/// `-p <name>` is mutually exclusive with `--cdp-endpoint` and
@@ -225,6 +231,32 @@ pub async fn execute(cmd: &Cmd, registry: &SharedRegistry) -> ActionResult {
);
}
if cmd.auto_connect && (provider_name.is_some() || mode == Mode::Cloud) {
return ActionResult::fatal(
"INVALID_ARGUMENT",
"--auto-connect is not supported with --provider or --mode cloud",
);
}
if cmd.auto_connect && mode == Mode::Extension {
return ActionResult::fatal(
"INVALID_ARGUMENT",
"--auto-connect is not supported with --mode extension",
);
}
// --auto-connect is also incompatible with a CDP endpoint resolved from
// config or ACTIONBOOK_BROWSER_CDP_ENDPOINT (not just --cdp-endpoint on
// the CLI, which clap's conflicts_with catches).
if cmd.auto_connect && cdp_endpoint.is_some() {
return ActionResult::fatal_with_hint(
"INVALID_ARGUMENT",
"--auto-connect cannot be used when a CDP endpoint is configured".to_string(),
"remove cdp_endpoint from your config file or unset ACTIONBOOK_BROWSER_CDP_ENDPOINT, \
or use --cdp-endpoint directly without --auto-connect",
);
}
if provider_name.is_some() && matches!(cmd.mode, Some(Mode::Local) | Some(Mode::Extension)) {
return ActionResult::fatal_with_hint(
"INVALID_ARGUMENT",
@@ -487,6 +519,7 @@ pub async fn execute(cmd: &Cmd, registry: &SharedRegistry) -> ActionResult {
let mut reg = registry.lock().await;
if cdp_endpoint.is_none()
&& !cmd.auto_connect
&& effective_set_id.is_none()
&& mode == Mode::Local
&& let Some(existing) = reg.find_local_session_by_profile(profile_name, mode)
@@ -621,6 +654,34 @@ pub async fn execute(cmd: &Cmd, registry: &SharedRegistry) -> ActionResult {
#[cfg(windows)]
let mut chrome_job: Option<crate::daemon::chrome_reaper::ChromeJobObject> = None;
// ── Auto-connect: discover a locally running Chrome ──
// Resolve before the cdp_endpoint branch so the discovered URL can be
// treated identically to an explicit --cdp-endpoint (chrome_process = None,
// so `browser close` will not kill the external Chrome).
let auto_connect_ws: Option<String> = if cmd.auto_connect && cdp_endpoint.is_none() {
match browser::auto_discover_chrome().await {
Ok((ws_url, _port)) => Some(ws_url),
Err(crate::error::CliError::ChromeAutoConnectNotFound) => {
return fail_reserved_start_with_hint(
registry,
&session_id,
"CHROME_AUTO_CONNECT_NOT_FOUND",
"no running Chrome with remote debugging was found".to_string(),
"start Chrome with remote debugging enabled, e.g. \
`google-chrome --remote-debugging-port=9222`, then retry --auto-connect",
)
.await;
}
Err(e) => {
return fail_reserved_start(registry, &session_id, e.error_code(), e.to_string())
.await;
}
}
} else {
None
};
let cdp_endpoint = auto_connect_ws.as_deref().or(cdp_endpoint);
let (mut chrome_process, port, ws_url, mut targets) = if let Some(endpoint) = cdp_endpoint {
let (ws_url, port) = match browser::resolve_cdp_endpoint(endpoint).await {
Ok(value) => value,
@@ -2052,6 +2113,7 @@ mod provider_start_tests {
open_url: None,
tab_id: None,
cdp_endpoint: None,
auto_connect: false,
provider: Some("hyperbrowser".to_string()),
header: vec![],
session: None,
@@ -2114,6 +2176,7 @@ mod provider_start_tests {
open_url: None,
tab_id: None,
cdp_endpoint: None,
auto_connect: false,
provider: Some("hyperbrowser".to_string()),
header: vec![],
session: None,
@@ -2167,6 +2230,7 @@ mod provider_start_tests {
open_url: None,
tab_id: None,
cdp_endpoint: None,
auto_connect: false,
provider: Some("browseruse".to_string()),
header: vec![],
session: None,
+1
View File
@@ -410,6 +410,7 @@ mod tests {
open_url: None,
tab_id: None,
cdp_endpoint: None,
auto_connect: false,
provider: None,
header: vec![],
session: None,
+328
View File
@@ -276,3 +276,331 @@ fn parse_endpoint_port(endpoint: &str) -> Result<u16, CliError> {
CliError::InvalidArgument(format!("invalid endpoint port in {endpoint}: {port_str}"))
})
}
// ── Auto-connect: discover a locally running Chrome ───────────────────────────
/// Parse the port number from a `DevToolsActivePort` file's content.
///
/// The file's first line is the port number; the second line is a hash path.
/// Returns `None` if the content is empty, non-numeric, or malformed.
pub fn parse_devtools_active_port(content: &str) -> Option<u16> {
content.lines().next()?.trim().parse::<u16>().ok()
}
/// Return the platform-specific candidate paths for Chrome's `DevToolsActivePort` file.
///
/// `os` matches `std::env::consts::OS` values ("macos", "linux", "windows").
/// `home` is the user's home directory. `local_appdata` is `%LOCALAPPDATA%` on Windows.
pub fn devtools_active_port_candidates_for(
os: &str,
home: &std::path::Path,
local_appdata: Option<&std::path::Path>,
) -> Vec<std::path::PathBuf> {
match os {
"macos" => vec![home.join("Library/Application Support/Google/Chrome/DevToolsActivePort")],
"linux" => vec![home.join(".config/google-chrome/DevToolsActivePort")],
"windows" => local_appdata
.map(|p| {
// Use explicit Windows path separators so the result is correct
// on all host platforms (PathBuf::join uses the host separator).
let base = p.to_string_lossy();
vec![std::path::PathBuf::from(format!(
"{}\\Google\\Chrome\\User Data\\DevToolsActivePort",
base
))]
})
.unwrap_or_default(),
_ => vec![],
}
}
/// Probe a single port's `/json/version` endpoint with a quick timeout.
async fn probe_json_version(port: u16, timeout: Duration) -> Option<String> {
let url = format!("http://127.0.0.1:{port}/json/version");
let result = tokio::time::timeout(timeout, reqwest::get(&url)).await;
match result {
Ok(Ok(resp)) if resp.status().is_success() => {
resp.json::<serde_json::Value>().await.ok().and_then(|j| {
j.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
}
_ => None,
}
}
/// Discover a running Chrome by checking `DevToolsActivePort` file candidates and
/// then probing `probe_ports` directly.
///
/// Returns `(ws_url, port)` for the first reachable instance.
///
/// Error codes:
/// - `CHROME_CDP_UNREACHABLE`: a `DevToolsActivePort` file was found but the
/// indicated port is unreachable (Chrome may have crashed, leaving a stale file).
/// - `CHROME_AUTO_CONNECT_NOT_FOUND`: no file found and no probe port is listening
/// (Chrome is not running or was not started with `--remote-debugging-port`).
pub async fn auto_discover_chrome_from_candidates(
candidates: &[std::path::PathBuf],
probe_ports: &[u16],
timeout: Duration,
) -> Result<(String, u16), crate::error::CliError> {
let mut found_active_port_file = false;
// Phase 1: DevToolsActivePort file candidates (Chrome writes this on startup).
for path in candidates {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let port = match parse_devtools_active_port(&content) {
Some(p) => p,
None => continue,
};
found_active_port_file = true;
if let Some(ws_url) = probe_json_version(port, timeout).await {
return Ok((ws_url, port));
}
// Stale file — fall through to probe_ports below.
}
// Phase 2: probe well-known ports.
for &port in probe_ports {
if let Some(ws_url) = probe_json_version(port, timeout).await {
return Ok((ws_url, port));
}
}
if found_active_port_file {
Err(crate::error::CliError::ChromeCdpUnreachable(
"DevToolsActivePort file found but Chrome is unreachable on the indicated port"
.to_string(),
))
} else {
Err(crate::error::CliError::ChromeAutoConnectNotFound)
}
}
/// Discover a running Chrome using platform-default paths and ports [9222, 9229].
pub async fn auto_discover_chrome() -> Result<(String, u16), crate::error::CliError> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let local_appdata = dirs::data_local_dir();
let candidates =
devtools_active_port_candidates_for(std::env::consts::OS, &home, local_appdata.as_deref());
auto_discover_chrome_from_candidates(&candidates, &[9222, 9229], Duration::from_millis(500))
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::thread;
use tempfile::tempdir;
#[test]
fn parse_devtools_active_port_accepts_first_numeric_line() {
assert_eq!(
parse_devtools_active_port("9222\n/devtools/browser/abc123\n"),
Some(9222)
);
}
#[test]
fn parse_devtools_active_port_rejects_empty_content() {
assert_eq!(parse_devtools_active_port(""), None);
assert_eq!(parse_devtools_active_port("\n"), None);
}
#[test]
fn parse_devtools_active_port_rejects_junk_content() {
assert_eq!(
parse_devtools_active_port("/devtools/browser/abc123\n"),
None
);
}
#[test]
fn parse_devtools_active_port_rejects_non_numeric_port() {
assert_eq!(parse_devtools_active_port("not-a-port\nhash\n"), None);
}
#[test]
fn devtools_active_port_candidates_cover_default_platform_paths() {
let mac = devtools_active_port_candidates_for(
"macos",
Path::new("/Users/alice"),
Some(Path::new("/Users/alice/AppData/Local")),
);
assert_eq!(
mac,
vec![PathBuf::from(
"/Users/alice/Library/Application Support/Google/Chrome/DevToolsActivePort",
)]
);
let linux = devtools_active_port_candidates_for(
"linux",
Path::new("/home/alice"),
Some(Path::new("/home/alice/.local/share")),
);
assert_eq!(
linux,
vec![PathBuf::from(
"/home/alice/.config/google-chrome/DevToolsActivePort",
)]
);
let windows = devtools_active_port_candidates_for(
"windows",
Path::new("C:\\Users\\Alice"),
Some(Path::new("C:\\Users\\Alice\\AppData\\Local")),
);
assert_eq!(
windows,
vec![PathBuf::from(
"C:\\Users\\Alice\\AppData\\Local\\Google\\Chrome\\User Data\\DevToolsActivePort",
)]
);
}
enum MockVersionBehavior {
Ok { ws_url: &'static str },
Status500,
Timeout,
}
fn reserve_unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral port")
.local_addr()
.expect("local addr")
.port()
}
fn spawn_json_version_server(behavior: MockVersionBehavior) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let port = listener.local_addr().expect("local addr").port();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept /json/version request");
match behavior {
MockVersionBehavior::Ok { ws_url } => {
let mut req_buf = [0_u8; 1024];
let _ = stream.read(&mut req_buf);
let body = serde_json::json!({
"Browser": "Chrome/136.0.0.0",
"webSocketDebuggerUrl": ws_url,
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("write 200 response");
}
MockVersionBehavior::Status500 => {
let mut req_buf = [0_u8; 1024];
let _ = stream.read(&mut req_buf);
stream
.write_all(
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write 500 response");
}
MockVersionBehavior::Timeout => {
thread::sleep(Duration::from_millis(250));
}
}
});
(port, handle)
}
#[tokio::test]
async fn auto_discover_uses_json_version_probe_when_available() {
let (port, server) = spawn_json_version_server(MockVersionBehavior::Ok {
ws_url: "ws://127.0.0.1:9222/devtools/browser/mock-browser-id",
});
let result = auto_discover_chrome_from_candidates(&[], &[port], Duration::from_millis(50))
.await
.expect("auto-discover should succeed from /json/version");
assert_eq!(
result,
(
"ws://127.0.0.1:9222/devtools/browser/mock-browser-id".to_string(),
port,
)
);
server.join().expect("join mock server");
}
#[tokio::test]
async fn auto_discover_falls_through_http_500_and_timeout_to_next_probe() {
let (port_500, server_500) = spawn_json_version_server(MockVersionBehavior::Status500);
let (port_timeout, server_timeout) =
spawn_json_version_server(MockVersionBehavior::Timeout);
let (port_ok, server_ok) = spawn_json_version_server(MockVersionBehavior::Ok {
ws_url: "ws://127.0.0.1:9229/devtools/browser/fallback-id",
});
let result = auto_discover_chrome_from_candidates(
&[],
&[port_500, port_timeout, port_ok],
Duration::from_millis(50),
)
.await
.expect("auto-discover should fall through to a later successful probe");
assert_eq!(
result,
(
"ws://127.0.0.1:9229/devtools/browser/fallback-id".to_string(),
port_ok,
)
);
server_500.join().expect("join 500 mock server");
server_timeout.join().expect("join timeout mock server");
server_ok.join().expect("join success mock server");
}
#[tokio::test]
async fn auto_discover_returns_not_found_when_no_candidates_work() {
let port_a = reserve_unused_port();
let port_b = reserve_unused_port();
let err =
auto_discover_chrome_from_candidates(&[], &[port_a, port_b], Duration::from_millis(50))
.await
.expect_err("auto-discover should fail when no Chrome candidates are reachable");
assert_eq!(err.error_code(), "CHROME_AUTO_CONNECT_NOT_FOUND");
}
#[tokio::test]
async fn auto_discover_stale_devtools_active_port_falls_through_then_reports_unreachable() {
let temp = tempdir().expect("tempdir");
let stale_port = reserve_unused_port();
let path = temp.path().join("DevToolsActivePort");
std::fs::write(&path, format!("{stale_port}\n/devtools/browser/stale\n"))
.expect("write stale DevToolsActivePort");
let fallback_a = reserve_unused_port();
let fallback_b = reserve_unused_port();
let err = auto_discover_chrome_from_candidates(
&[path],
&[fallback_a, fallback_b],
Duration::from_millis(50),
)
.await
.expect_err("stale DevToolsActivePort should fall through, then fail as unreachable");
assert_eq!(err.error_code(), "CHROME_CDP_UNREACHABLE");
}
}
+11
View File
@@ -61,6 +61,10 @@ pub enum CliError {
ApiServerError(String),
#[error("internal error: {0}")]
Internal(String),
#[error("chrome auto-connect: no running Chrome with remote debugging found")]
ChromeAutoConnectNotFound,
#[error("chrome auto-connect: Chrome remote debugging unreachable: {0}")]
ChromeCdpUnreachable(String),
}
impl CliError {
@@ -95,6 +99,8 @@ impl CliError {
CliError::ApiRateLimited(_) => "API_RATE_LIMITED",
CliError::ApiServerError(_) => "API_SERVER_ERROR",
CliError::Internal(_) => "INTERNAL_ERROR",
CliError::ChromeAutoConnectNotFound => "CHROME_AUTO_CONNECT_NOT_FOUND",
CliError::ChromeCdpUnreachable(_) => "CHROME_CDP_UNREACHABLE",
}
}
@@ -134,6 +140,11 @@ impl CliError {
"the provider service returned a 5xx error — retry after a short delay or check the provider's status page"
.to_string()
}
CliError::ChromeAutoConnectNotFound => {
"start Chrome with remote debugging enabled, e.g. \
`google-chrome --remote-debugging-port=9222`, then retry with --auto-connect"
.to_string()
}
_ => String::new(),
}
}
+1
View File
@@ -216,6 +216,7 @@ async fn handle_browser(
open_url: None,
tab_id: None,
cdp_endpoint: None,
auto_connect: false,
provider: None,
header: vec![],
session: None,
+436
View File
@@ -0,0 +1,436 @@
//! E2E tests for `browser start --auto-connect`.
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use std::time::{Duration, Instant};
use crate::harness::{
SoloEnv, assert_error_envelope, assert_failure, assert_success, parse_json, skip, url_a,
};
struct ExternalChromeGuard {
child: std::process::Child,
home_root: tempfile::TempDir,
}
impl ExternalChromeGuard {
fn home_path(&self) -> &Path {
self.home_root.path()
}
fn is_alive(&mut self) -> bool {
self.child
.try_wait()
.expect("try_wait on external chrome")
.is_none()
}
}
impl Drop for ExternalChromeGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
struct SoloSessionGuard<'a> {
env: &'a SoloEnv,
session_id: Option<String>,
}
impl<'a> SoloSessionGuard<'a> {
fn new(env: &'a SoloEnv, session_id: String) -> Self {
Self {
env,
session_id: Some(session_id),
}
}
}
impl Drop for SoloSessionGuard<'_> {
fn drop(&mut self) {
if let Some(session_id) = self.session_id.take() {
let _ = self
.env
.headless(&["browser", "close", "--session", &session_id], 30);
}
}
}
fn default_chrome_user_data_dir(home_root: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
{
home_root.join("Library/Application Support/Google/Chrome")
}
#[cfg(target_os = "linux")]
{
home_root.join(".config/google-chrome")
}
#[cfg(windows)]
{
home_root.join("Google/Chrome/User Data")
}
}
fn find_chrome_executable() -> String {
#[cfg(windows)]
{
let program_files =
std::env::var("PROGRAMFILES").unwrap_or_else(|_| "C:\\Program Files".to_string());
let program_files_x86 = std::env::var("PROGRAMFILES(X86)")
.unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let local_appdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
let candidates = [
format!("{}\\Google\\Chrome\\Application\\chrome.exe", program_files),
format!(
"{}\\Google\\Chrome\\Application\\chrome.exe",
program_files_x86
),
format!("{}\\Google\\Chrome\\Application\\chrome.exe", local_appdata),
];
for c in &candidates {
if Path::new(c).exists() {
return c.clone();
}
}
if let Ok(output) = StdCommand::new("where").arg("chrome").output()
&& output.status.success()
{
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return path;
}
}
panic!("Chrome not found for auto-connect tests");
}
#[cfg(not(windows))]
{
let candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
];
for c in &candidates {
if Path::new(c).exists() {
return c.to_string();
}
if let Ok(output) = StdCommand::new("which").arg(c).output()
&& output.status.success()
{
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return path;
}
}
}
panic!("Chrome not found for auto-connect tests");
}
}
fn launch_external_chrome(port: u16) -> ExternalChromeGuard {
let chrome = find_chrome_executable();
let home_root = tempfile::tempdir().expect("create temp home for external chrome");
let user_data_dir = default_chrome_user_data_dir(home_root.path());
fs::create_dir_all(&user_data_dir).expect("create default Chrome user-data-dir");
let mut child = StdCommand::new(&chrome)
.args([
"--headless=new",
&format!("--remote-debugging-port={port}"),
"--no-first-run",
"--no-default-browser-check",
&format!("--user-data-dir={}", user_data_dir.to_string_lossy()),
])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("launch Chrome for auto-connect tests");
wait_for_json_version(port);
if let Some(status) = child.try_wait().expect("try_wait after launch") {
panic!("Chrome exited early while starting auto-connect test: {status}");
}
ExternalChromeGuard { child, home_root }
}
fn json_version_response(port: u16) -> Option<String> {
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("socket addr");
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(200)).ok()?;
stream
.write_all(b"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.ok()?;
let mut body = String::new();
stream.read_to_string(&mut body).ok()?;
Some(body)
}
fn json_version_is_reachable(port: u16) -> bool {
json_version_response(port)
.map(|response| response.contains("200 OK"))
.unwrap_or(false)
}
fn wait_for_json_version(port: u16) {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(15) {
if json_version_is_reachable(port) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("Chrome did not expose /json/version on port {port} within 15s");
}
fn auto_connect_env_vars(home_root: &Path) -> [(String, String); 2] {
let home = home_root.to_string_lossy().to_string();
let local_appdata = home_root.to_string_lossy().to_string();
[
("HOME".to_string(), home),
("LOCALAPPDATA".to_string(), local_appdata),
]
}
#[test]
#[cfg_attr(windows, ignore)]
fn auto_connect_attaches_to_running_chrome() {
if skip() {
return;
}
if json_version_is_reachable(9222) {
eprintln!("skipping auto-connect attach test because port 9222 is already in use");
return;
}
let mut chrome = launch_external_chrome(9222);
assert!(chrome.is_alive(), "spawned Chrome should be alive");
let env = SoloEnv::new();
let extra_env = auto_connect_env_vars(chrome.home_path());
let env_refs = [
("HOME", extra_env[0].1.as_str()),
("LOCALAPPDATA", extra_env[1].1.as_str()),
];
let start = env.headless_json_with_env(&["browser", "start", "--auto-connect"], &env_refs, 30);
assert_success(&start, "browser start --auto-connect");
let start_v = parse_json(&start);
let session_id = start_v["data"]["session"]["session_id"]
.as_str()
.expect("session id")
.to_string();
let tab_id = start_v["data"]["tab"]["tab_id"]
.as_str()
.expect("tab id")
.to_string();
let _guard = SoloSessionGuard::new(&env, session_id.clone());
let goto = env.headless_json_with_env(
&[
"browser",
"goto",
&url_a(),
"--session",
&session_id,
"--tab",
&tab_id,
],
&env_refs,
20,
);
assert_success(&goto, "goto after auto-connect");
let title = env.headless_json_with_env(
&[
"browser",
"title",
"--session",
&session_id,
"--tab",
&tab_id,
],
&env_refs,
10,
);
assert_success(&title, "title after auto-connect");
let title_v = parse_json(&title);
assert_eq!(title_v["data"]["title"], "Page A");
}
#[test]
#[cfg_attr(windows, ignore)]
fn auto_connect_without_running_chrome_returns_not_found() {
if skip() {
return;
}
if json_version_is_reachable(9222) || json_version_is_reachable(9229) {
eprintln!("skipping auto-connect negative test because probe ports are already in use");
return;
}
let env = SoloEnv::new();
let fake_home = tempfile::tempdir().expect("temp home for negative auto-connect test");
let extra_env = auto_connect_env_vars(fake_home.path());
let env_refs = [
("HOME", extra_env[0].1.as_str()),
("LOCALAPPDATA", extra_env[1].1.as_str()),
];
let out = env.headless_json_with_env(&["browser", "start", "--auto-connect"], &env_refs, 10);
assert_failure(&out, "auto-connect without running Chrome");
let v = parse_json(&out);
assert_error_envelope(&v, "CHROME_AUTO_CONNECT_NOT_FOUND");
assert!(
v["error"]["hint"]
.as_str()
.unwrap_or("")
.contains("remote debugging"),
"not-found hint should mention how to start Chrome with remote debugging"
);
}
#[test]
#[cfg_attr(windows, ignore)]
fn close_attached_auto_connect_session_does_not_kill_chrome() {
if skip() {
return;
}
if json_version_is_reachable(9222) {
eprintln!("skipping auto-connect close semantic test because port 9222 is already in use");
return;
}
let mut chrome = launch_external_chrome(9222);
assert!(
chrome.is_alive(),
"spawned Chrome should be alive before attach"
);
let env = SoloEnv::new();
let extra_env = auto_connect_env_vars(chrome.home_path());
let env_refs = [
("HOME", extra_env[0].1.as_str()),
("LOCALAPPDATA", extra_env[1].1.as_str()),
];
let start = env.headless_json_with_env(&["browser", "start", "--auto-connect"], &env_refs, 30);
assert_success(&start, "browser start --auto-connect");
let start_v = parse_json(&start);
let session_id = start_v["data"]["session"]["session_id"]
.as_str()
.expect("session id")
.to_string();
let close = env.headless_json_with_env(
&["browser", "close", "--session", &session_id],
&env_refs,
30,
);
assert_success(&close, "close attached auto-connect session");
assert!(
chrome.is_alive(),
"closing an attached auto-connect session must not kill external Chrome"
);
assert!(
json_version_is_reachable(9222),
"external Chrome should still expose /json/version after session close"
);
}
#[test]
#[cfg_attr(windows, ignore)]
fn auto_connect_ignores_existing_local_session_reuse() {
// Regression test for P1: --auto-connect must not silently reuse an existing
// local session just because the profile matches. It must always go through
// the auto-discover path and fail with NOT_FOUND when no external Chrome is
// reachable — never return the running local session.
if skip() {
return;
}
if json_version_is_reachable(9222) || json_version_is_reachable(9229) {
eprintln!("skipping: probe ports already in use, cannot isolate auto-connect discovery");
return;
}
let env = SoloEnv::new();
// Start a regular local session first.
let start1 = env.headless_json(&["browser", "start"], 30);
let v1 = parse_json(&start1);
if v1["status"].as_str() != Some("ok") {
eprintln!("skipping: local session start failed (Chrome may not be available)");
return;
}
let first_session_id = v1["data"]["session"]["session_id"]
.as_str()
.expect("session id from first start")
.to_string();
let _guard = SoloSessionGuard::new(&env, first_session_id.clone());
// Use a fake HOME so no DevToolsActivePort file is found.
// With no external Chrome on 9222/9229 either, auto-connect must fail with
// NOT_FOUND — not silently reuse the running local session.
let fake_home = tempfile::tempdir().expect("temp home for P1 regression test");
let extra_env = auto_connect_env_vars(fake_home.path());
let env_refs = [
("HOME", extra_env[0].1.as_str()),
("LOCALAPPDATA", extra_env[1].1.as_str()),
];
let out = env.headless_json_with_env(&["browser", "start", "--auto-connect"], &env_refs, 10);
assert_failure(
&out,
"--auto-connect with no external Chrome must fail, not reuse the local session",
);
let v = parse_json(&out);
assert_error_envelope(&v, "CHROME_AUTO_CONNECT_NOT_FOUND");
}
#[test]
fn auto_connect_conflicts_with_config_cdp_endpoint() {
// Regression test for P2: --auto-connect must be rejected when a CDP endpoint
// is provided via config or ACTIONBOOK_BROWSER_CDP_ENDPOINT, not silently
// bypassed.
if skip() {
return;
}
let env = SoloEnv::new();
let out = env.headless_json_with_env(
&["browser", "start", "--auto-connect"],
&[(
"ACTIONBOOK_BROWSER_CDP_ENDPOINT",
"ws://127.0.0.1:9999/devtools/browser/fake",
)],
10,
);
assert_failure(
&out,
"--auto-connect with ACTIONBOOK_BROWSER_CDP_ENDPOINT set should return INVALID_ARGUMENT",
);
let v = parse_json(&out);
assert_error_envelope(&v, "INVALID_ARGUMENT");
}
+1
View File
@@ -9,6 +9,7 @@
//! Run with:
//! RUN_E2E_TESTS=true cargo test --test e2e -- --test-threads=1 --nocapture
mod auto_connect;
mod batch_snapshot;
mod bridge;
mod browser_lifecycle;