mirror of
https://github.com/actionbook/actionbook.git
synced 2026-09-19 03:28:06 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -410,6 +410,7 @@ mod tests {
|
||||
open_url: None,
|
||||
tab_id: None,
|
||||
cdp_endpoint: None,
|
||||
auto_connect: false,
|
||||
provider: None,
|
||||
header: vec![],
|
||||
session: None,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user