inspect (#736)
* inspect * fixes * improvements * fixes * fixes * improvements * fix null cdp url * fix rust reader loop * improvements * improvements * fixes
This commit is contained in:
@@ -131,6 +131,7 @@ agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL (for DevTools, debugging)
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box
|
||||
agent-browser get styles <sel> # Get computed styles
|
||||
@@ -283,6 +284,7 @@ agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors (uncaught JavaScript exceptions)
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
agent-browser state save <path> # Save auth state
|
||||
agent-browser state load <path> # Load auth state
|
||||
agent-browser state list # List saved state files
|
||||
|
||||
+20
-2
@@ -568,6 +568,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
// === Inspect ===
|
||||
"inspect" => Ok(json!({ "id": id, "action": "inspect" })),
|
||||
|
||||
// === Authentication Vault ===
|
||||
"auth" => {
|
||||
let sub = rest.first().map(|s| s.as_ref());
|
||||
@@ -1559,7 +1562,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
||||
|
||||
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
const VALID: &[&str] = &[
|
||||
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
|
||||
"text", "html", "value", "attr", "url", "title", "count", "box", "styles", "cdp-url",
|
||||
];
|
||||
|
||||
match rest.first().copied() {
|
||||
@@ -1596,6 +1599,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
Ok(json!({ "id": id, "action": "getattribute", "selector": sel, "attribute": attr }))
|
||||
}
|
||||
Some("url") => Ok(json!({ "id": id, "action": "url" })),
|
||||
Some("cdp-url") => Ok(json!({ "id": id, "action": "cdp_url" })),
|
||||
Some("title") => Ok(json!({ "id": id, "action": "title" })),
|
||||
Some("count") => {
|
||||
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1624,7 +1628,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "get".to_string(),
|
||||
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
|
||||
usage: "get <text|html|value|attr|url|title|count|box|styles|cdp-url> [args...]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -3807,4 +3811,18 @@ mod tests {
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// === Inspect / CDP URL ===
|
||||
|
||||
#[test]
|
||||
fn test_inspect() {
|
||||
let cmd = parse_command(&args("inspect"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_cdp_url() {
|
||||
let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "cdp_url");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::cdp::types::{
|
||||
use super::cookies;
|
||||
use super::diff;
|
||||
use super::element::RefMap;
|
||||
use super::inspect_server::InspectServer;
|
||||
use super::interaction;
|
||||
use super::network::{self, DomainFilter, EventTracker};
|
||||
use super::policy::{ActionPolicy, ConfirmActions, PolicyResult};
|
||||
@@ -96,6 +97,7 @@ pub struct DaemonState {
|
||||
pub har_recording: bool,
|
||||
pub har_entries: Vec<HarEntry>,
|
||||
pub confirm_actions: Option<ConfirmActions>,
|
||||
pub inspect_server: Option<InspectServer>,
|
||||
pub routes: Vec<RouteEntry>,
|
||||
pub tracked_requests: Vec<TrackedRequest>,
|
||||
pub request_tracking: bool,
|
||||
@@ -127,6 +129,7 @@ impl DaemonState {
|
||||
har_recording: false,
|
||||
har_entries: Vec::new(),
|
||||
confirm_actions: ConfirmActions::from_env(),
|
||||
inspect_server: None,
|
||||
routes: Vec::new(),
|
||||
tracked_requests: Vec::new(),
|
||||
request_tracking: false,
|
||||
@@ -567,6 +570,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
"launch" => handle_launch(cmd, state).await,
|
||||
"navigate" => handle_navigate(cmd, state).await,
|
||||
"url" => handle_url(state).await,
|
||||
"cdp_url" => handle_cdp_url(state),
|
||||
"inspect" => handle_inspect(state).await,
|
||||
"title" => handle_title(state).await,
|
||||
"content" => handle_content(state).await,
|
||||
"evaluate" => handle_evaluate(cmd, state).await,
|
||||
@@ -1170,6 +1175,50 @@ async fn handle_url(state: &DaemonState) -> Result<Value, String> {
|
||||
Ok(json!({ "url": url }))
|
||||
}
|
||||
|
||||
fn handle_cdp_url(state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
Ok(json!({ "cdpUrl": mgr.get_cdp_url() }))
|
||||
}
|
||||
|
||||
async fn handle_inspect(state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
|
||||
// Shut down any existing inspect server so we always target the current page
|
||||
if let Some(server) = state.inspect_server.take() {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
let target_id = mgr.active_target_id()?.to_string();
|
||||
let chrome_hp = mgr.chrome_host_port().to_string();
|
||||
let proxy_handle = mgr.client.inspect_handle();
|
||||
|
||||
let server = InspectServer::start(proxy_handle, target_id, chrome_hp).await?;
|
||||
let url = format!("http://127.0.0.1:{}", server.port());
|
||||
open_url_in_browser(&url);
|
||||
|
||||
state.inspect_server = Some(server);
|
||||
Ok(json!({ "opened": true, "url": url }))
|
||||
}
|
||||
|
||||
fn open_url_in_browser(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("cmd")
|
||||
.args(["/c", "start", "", url])
|
||||
.spawn();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"unsupported platform",
|
||||
));
|
||||
if let Err(e) = result {
|
||||
eprintln!("[inspect] Failed to open browser: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_title(state: &DaemonState) -> Result<Value, String> {
|
||||
if let Some(ref wb) = state.webdriver_backend {
|
||||
if state.browser.is_none() {
|
||||
@@ -1254,6 +1303,10 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
||||
state.safari_driver = None;
|
||||
state.backend_type = BackendType::Cdp;
|
||||
|
||||
if let Some(server) = state.inspect_server.take() {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
state.ref_map.clear();
|
||||
Ok(json!({ "closed": true }))
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ impl BrowserProcess {
|
||||
pub struct BrowserManager {
|
||||
pub client: CdpClient,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
ws_url: String,
|
||||
pages: Vec<PageInfo>,
|
||||
active_page_index: usize,
|
||||
default_timeout_ms: u64,
|
||||
@@ -223,6 +224,7 @@ impl BrowserManager {
|
||||
let mut manager = Self {
|
||||
client,
|
||||
browser_process: Some(process),
|
||||
ws_url,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 25_000,
|
||||
@@ -285,6 +287,7 @@ impl BrowserManager {
|
||||
let mut manager = Self {
|
||||
client,
|
||||
browser_process: None,
|
||||
ws_url,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
@@ -636,6 +639,27 @@ impl BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
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.active_page_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()
|
||||
|
||||
@@ -12,6 +12,14 @@ use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawCdpMessage {
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
@@ -26,6 +34,7 @@ pub struct CdpClient {
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -40,9 +49,11 @@ impl CdpClient {
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let (raw_tx, _) = broadcast::channel(512);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
@@ -53,8 +64,22 @@ impl CdpClient {
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
// Broadcast raw message for inspect proxy subscribers before typed parse,
|
||||
// so messages with negative IDs (used by the inspect proxy) are still delivered.
|
||||
if raw_tx_clone.receiver_count() > 0 {
|
||||
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
|
||||
let _ = raw_tx_clone.send(RawCdpMessage {
|
||||
text: msg.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
// Expected for inspect proxy messages with negative IDs
|
||||
// (CdpMessage.id is u64); handled via raw broadcast above.
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
@@ -81,6 +106,7 @@ impl CdpClient {
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
})
|
||||
}
|
||||
@@ -138,6 +164,21 @@ impl CdpClient {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to all raw incoming CDP messages (responses + events).
|
||||
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Create a lightweight handle for the inspect WebSocket proxy.
|
||||
/// Contains only what's needed to forward messages bidirectionally.
|
||||
pub fn inspect_handle(&self) -> InspectProxyHandle {
|
||||
InspectProxyHandle {
|
||||
ws_tx: self.ws_tx.clone(),
|
||||
raw_tx: self.raw_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -160,4 +201,46 @@ impl CdpClient {
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
|
||||
/// Send raw JSON through the WebSocket without tracking a response.
|
||||
/// Used by the inspect proxy to forward DevTools frontend messages.
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
type WsTx = Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Lightweight handle for the inspect WebSocket proxy, holding only
|
||||
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
|
||||
pub struct InspectProxyHandle {
|
||||
ws_tx: WsTx,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
}
|
||||
|
||||
impl InspectProxyHandle {
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,3 +1544,86 @@ async fn e2e_profile_cookie_persistence() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&profile_dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inspect / CDP URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_get_cdp_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "2", "action": "cdp_url" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let cdp_url = get_data(&resp)["cdpUrl"]
|
||||
.as_str()
|
||||
.expect("cdpUrl should be a string");
|
||||
assert!(
|
||||
cdp_url.starts_with("ws://"),
|
||||
"CDP URL should start with ws://, got: {}",
|
||||
cdp_url
|
||||
);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_inspect() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "inspect" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let data = get_data(&resp);
|
||||
assert_eq!(data["opened"], true);
|
||||
let url = data["url"]
|
||||
.as_str()
|
||||
.expect("inspect url should be a string");
|
||||
assert!(
|
||||
url.starts_with("http://127.0.0.1:"),
|
||||
"Inspect URL should be http://127.0.0.1:<port>, got: {}",
|
||||
url
|
||||
);
|
||||
|
||||
// Verify the HTTP redirect serves a 302 to the DevTools frontend
|
||||
let http_resp = reqwest::get(url).await;
|
||||
match http_resp {
|
||||
Ok(r) => {
|
||||
let final_url = r.url().to_string();
|
||||
assert!(
|
||||
final_url.contains("devtools/devtools_app.html"),
|
||||
"Redirect should point to DevTools frontend, got: {}",
|
||||
final_url
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("HTTP GET to inspect URL failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::InspectProxyHandle;
|
||||
|
||||
/// Counter for unique attach IDs so concurrent connections don't collide.
|
||||
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
|
||||
|
||||
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
|
||||
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
|
||||
/// `sessionId` so the DevTools frontend sees a page-level view
|
||||
pub struct InspectServer {
|
||||
port: u16,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectServer {
|
||||
/// Start the inspect proxy server.
|
||||
///
|
||||
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
|
||||
/// - `target_id`: the CDP target ID of the page to inspect
|
||||
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
|
||||
pub async fn start(
|
||||
proxy_handle: InspectProxyHandle,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
) -> Result<Self, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {}", e))?
|
||||
.port();
|
||||
|
||||
let proxy = Arc::new(proxy_handle);
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
proxy,
|
||||
target_id,
|
||||
chrome_host_port,
|
||||
port,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let proxy = proxy.clone();
|
||||
let tid = target_id.clone();
|
||||
let chp = chrome_host_port.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
|
||||
eprintln!("[inspect] connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
// Peek at the request line to determine routing WITHOUT consuming bytes.
|
||||
// This is critical: tokio_tungstenite::accept_async needs to read the full
|
||||
// HTTP upgrade request itself, so we must not consume anything for WS paths.
|
||||
let mut peek_buf = [0u8; 32];
|
||||
let n = stream
|
||||
.peek(&mut peek_buf)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peek = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if peek.starts_with("GET /ws") {
|
||||
return handle_ws_proxy(stream, proxy, target_id).await;
|
||||
}
|
||||
|
||||
if peek.starts_with("GET / ") {
|
||||
let buf_reader = BufReader::new(stream);
|
||||
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
|
||||
}
|
||||
|
||||
// Unknown request -- consume and respond 404
|
||||
let mut stream = stream;
|
||||
let mut discard = [0u8; 4096];
|
||||
let _ = stream.read(&mut discard).await;
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 8192;
|
||||
|
||||
async fn handle_http_redirect(
|
||||
buf_reader: BufReader<tokio::net::TcpStream>,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut br = buf_reader;
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
total_bytes += n;
|
||||
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let location = format!(
|
||||
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
|
||||
chrome_host_port, proxy_port
|
||||
);
|
||||
let body = format!(
|
||||
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
|
||||
url = location
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
location,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut stream = br.into_inner();
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_ws_proxy(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
|
||||
|
||||
// Create a dedicated CDP session for this DevTools connection.
|
||||
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
|
||||
// always trigger fresh initial state dumps from Chrome.
|
||||
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
|
||||
let attach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
|
||||
attach_id, target_id
|
||||
);
|
||||
|
||||
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
|
||||
// receivers only deliver messages to receivers that already exist).
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
|
||||
proxy
|
||||
.send_raw(attach_cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
|
||||
|
||||
// Wait for the attachToTarget response to extract the session ID
|
||||
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Ok(raw_msg) = raw_rx.recv().await {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
|
||||
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
|
||||
if let Some(sid) = val
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
{
|
||||
return Ok(sid.to_string());
|
||||
}
|
||||
return Err("attachToTarget failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("raw message channel closed".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
|
||||
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Chrome -> DevTools: forward messages matching our session, strip sessionId
|
||||
let mut chrome_to_devtools = tokio::spawn(async move {
|
||||
loop {
|
||||
let raw_msg = match raw_rx.recv().await {
|
||||
Ok(msg) => msg,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!(
|
||||
"[inspect] warning: dropped {} CDP messages (channel lag)",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stripped = strip_session_id(&raw_msg.text);
|
||||
|
||||
let mut tx = ws_tx_clone.lock().await;
|
||||
if tx.send(Message::Text(stripped)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools -> Chrome: inject sessionId and forward
|
||||
let proxy_for_send = proxy.clone();
|
||||
let session_id_for_send = session_id.clone();
|
||||
let mut devtools_to_chrome = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let injected = inject_session_id(&text, &session_id_for_send);
|
||||
if proxy_for_send.send_raw(injected).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut chrome_to_devtools => {
|
||||
devtools_to_chrome.abort();
|
||||
},
|
||||
_ = &mut devtools_to_chrome => {
|
||||
chrome_to_devtools.abort();
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up the CDP session so Chrome doesn't leak attached targets
|
||||
let detach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
|
||||
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
|
||||
session_id
|
||||
);
|
||||
let _ = proxy.send_raw(detach_cmd).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inject_session_id(json: &str, session_id: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_session_id(json: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.remove("sessionId");
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id() {
|
||||
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
|
||||
let result = inject_session_id(input, "abc123");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc123");
|
||||
assert_eq!(parsed["method"], "DOM.getDocument");
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id_empty_object() {
|
||||
let result = inject_session_id("{}", "abc");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_session_id() {
|
||||
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
|
||||
let result = strip_session_id(input);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert!(parsed.get("sessionId").is_none());
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_then_strip_roundtrip() {
|
||||
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
|
||||
let injected = inject_session_id(input, "sess1");
|
||||
let stripped = strip_session_id(&injected);
|
||||
let original: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
|
||||
assert_eq!(original, result);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
|
||||
+43
-1
@@ -100,6 +100,23 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
|
||||
if let Some(data) = &resp.data {
|
||||
// Inspect response (check before generic URL handler since it also has a "url" field)
|
||||
if action == Some("inspect") {
|
||||
let opened = data
|
||||
.get("opened")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if opened {
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
println!("{} Opened DevTools: {}", color::success_indicator(), url);
|
||||
} else {
|
||||
println!("{} Opened DevTools", color::success_indicator());
|
||||
}
|
||||
} else if let Some(err) = data.get("error").and_then(|v| v.as_str()) {
|
||||
eprintln!("Could not open DevTools: {}", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Navigation response
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
@@ -110,6 +127,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("{}", url);
|
||||
return;
|
||||
}
|
||||
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
||||
println!("{}", cdp_url);
|
||||
return;
|
||||
}
|
||||
// Diff responses -- route by action to avoid fragile shape probing
|
||||
if let Some(obj) = data.as_object() {
|
||||
match action {
|
||||
@@ -1447,6 +1468,25 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Inspect ===
|
||||
"inspect" => {
|
||||
r##"
|
||||
agent-browser inspect - Open Chrome DevTools for the active page
|
||||
|
||||
Starts a local WebSocket proxy and opens Chrome's DevTools frontend in your
|
||||
default browser. The proxy routes DevTools traffic through the daemon's
|
||||
existing CDP connection, so both DevTools and agent-browser commands work
|
||||
simultaneously.
|
||||
|
||||
Usage: agent-browser inspect
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser inspect # opens DevTools in your browser
|
||||
agent-browser click "Submit" # commands still work while DevTools is open
|
||||
"##
|
||||
}
|
||||
|
||||
// === Get ===
|
||||
"get" => {
|
||||
r##"
|
||||
@@ -1466,6 +1506,7 @@ Subcommands:
|
||||
count <selector> Count matching elements
|
||||
box <selector> Get bounding box (x, y, width, height)
|
||||
styles <selector> Get computed styles of elements
|
||||
cdp-url Get Chrome DevTools Protocol WebSocket URL
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -2351,7 +2392,7 @@ Navigation:
|
||||
reload Reload page
|
||||
|
||||
Get Info: agent-browser get <what> [selector]
|
||||
text, html, value, attr <name>, title, url, count, box, styles
|
||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||
|
||||
Check State: agent-browser is <what> <selector>
|
||||
visible, enabled, checked
|
||||
@@ -2392,6 +2433,7 @@ Debug:
|
||||
console [--clear] View console logs
|
||||
errors [--clear] View page errors
|
||||
highlight <sel> Highlight element
|
||||
inspect Open Chrome DevTools for the active page
|
||||
|
||||
Auth Vault:
|
||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||
|
||||
@@ -44,6 +44,7 @@ agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box
|
||||
agent-browser get styles <sel> # Get computed styles
|
||||
@@ -199,6 +200,7 @@ agent-browser console --clear # Clear console log
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear error log
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
```
|
||||
|
||||
## Auth vault
|
||||
|
||||
@@ -129,6 +129,7 @@ agent-browser scroll down 500 --selector "div.content" # Scroll within a specif
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
@@ -308,6 +309,7 @@ The `scale` parameter (3rd argument) sets `window.devicePixelRatio` without chan
|
||||
```bash
|
||||
agent-browser --headed open https://example.com
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
agent-browser record start demo.webm # Record session
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile (path optional)
|
||||
|
||||
@@ -58,6 +58,7 @@ agent-browser get value @e1 # Get input value
|
||||
agent-browser get attr @e1 href # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
agent-browser get count ".item" # Count matching elements
|
||||
agent-browser get box @e1 # Get bounding box
|
||||
agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.)
|
||||
@@ -246,6 +247,7 @@ agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for this session
|
||||
agent-browser trace start # Start recording trace
|
||||
agent-browser trace stop trace.zip # Stop and save trace
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'node:child_process';
|
||||
import type { Page, Frame } from 'playwright-core';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||
@@ -431,6 +432,10 @@ async function dispatchAction(command: Command, browser: BrowserManager): Promis
|
||||
return await handleReload(command, browser);
|
||||
case 'url':
|
||||
return await handleUrl(command, browser);
|
||||
case 'cdp_url':
|
||||
return handleCdpUrl(command, browser);
|
||||
case 'inspect':
|
||||
return await handleInspect(command, browser);
|
||||
case 'title':
|
||||
return await handleTitle(command, browser);
|
||||
case 'getattribute':
|
||||
@@ -1577,6 +1582,73 @@ async function handleUrl(
|
||||
return successResponse(command.id, { url: page.url() });
|
||||
}
|
||||
|
||||
function handleCdpUrl(command: Command & { action: 'cdp_url' }, browser: BrowserManager): Response {
|
||||
const cdpUrl = browser.getCdpUrl();
|
||||
if (!cdpUrl) {
|
||||
return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
|
||||
}
|
||||
return successResponse(command.id, { cdpUrl });
|
||||
}
|
||||
|
||||
async function handleInspect(
|
||||
command: Command & { action: 'inspect' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const cdpUrl = browser.getCdpUrl();
|
||||
if (!cdpUrl) {
|
||||
return errorResponse(command.id, 'CDP URL not available (browser may not be launched)');
|
||||
}
|
||||
|
||||
// Shut down any existing inspect server so we always target the current page
|
||||
browser.stopInspectServer();
|
||||
|
||||
const stripped = cdpUrl.replace(/^(wss?|https?):\/\//, '');
|
||||
const hostPort = stripped.split('/')[0];
|
||||
|
||||
// Get the target ID so the inspect server can create its own dedicated CDP session
|
||||
const page = browser.getPage();
|
||||
const context = page.context();
|
||||
const tmpCdp = await context.newCDPSession(page);
|
||||
let targetId = '';
|
||||
try {
|
||||
const info: any = await tmpCdp.send('Target.getTargetInfo' as any);
|
||||
targetId = info?.targetInfo?.targetId || '';
|
||||
} catch (err) {
|
||||
console.error('[inspect] getTargetInfo failed:', err);
|
||||
}
|
||||
await tmpCdp.detach();
|
||||
|
||||
if (!targetId) {
|
||||
return errorResponse(command.id, 'Could not determine target ID for active page');
|
||||
}
|
||||
|
||||
const { InspectServer } = await import('./inspect-server.js');
|
||||
const server = new InspectServer({
|
||||
chromeHostPort: hostPort,
|
||||
targetId,
|
||||
chromeWsUrl: cdpUrl,
|
||||
});
|
||||
await server.start();
|
||||
browser.setInspectServer(server);
|
||||
|
||||
const url = `http://127.0.0.1:${server.port}`;
|
||||
openUrlInBrowser(url);
|
||||
return successResponse(command.id, { opened: true, url });
|
||||
}
|
||||
|
||||
function openUrlInBrowser(url: string): void {
|
||||
const platform = process.platform;
|
||||
const cmd =
|
||||
platform === 'darwin'
|
||||
? `open "${url}"`
|
||||
: platform === 'win32'
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`;
|
||||
exec(cmd, (err) => {
|
||||
if (err) console.error('[inspect] Failed to open browser:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTitle(
|
||||
command: Command & { action: 'title' },
|
||||
browser: BrowserManager
|
||||
|
||||
@@ -19,6 +19,7 @@ import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import type { InspectServer } from './inspect-server.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||
@@ -96,6 +97,7 @@ interface PageError {
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
private cdpEndpoint: string | null = null; // stores port number or full URL
|
||||
private resolvedWsUrl: string | null = null;
|
||||
private isPersistentContext: boolean = false;
|
||||
private browserbaseSessionId: string | null = null;
|
||||
private browserbaseApiKey: string | null = null;
|
||||
@@ -119,6 +121,19 @@ export class BrowserManager {
|
||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
private inspectServer: InspectServer | null = null;
|
||||
|
||||
stopInspectServer(): void {
|
||||
if (this.inspectServer) {
|
||||
this.inspectServer.stop();
|
||||
this.inspectServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setInspectServer(server: InspectServer): void {
|
||||
this.stopInspectServer();
|
||||
this.inspectServer = server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -167,6 +182,18 @@ export class BrowserManager {
|
||||
return this.browser !== null || this.isPersistentContext;
|
||||
}
|
||||
|
||||
getCdpUrl(): string | null {
|
||||
if (this.resolvedWsUrl) return this.resolvedWsUrl;
|
||||
if (this.cdpEndpoint?.startsWith('ws://') || this.cdpEndpoint?.startsWith('wss://')) {
|
||||
return this.cdpEndpoint;
|
||||
}
|
||||
try {
|
||||
return (this.browser as any)?.wsEndpoint?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enhanced snapshot with refs and cache the ref map
|
||||
*/
|
||||
@@ -1402,6 +1429,7 @@ export class BrowserManager {
|
||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
});
|
||||
this.cdpEndpoint = null;
|
||||
this.resolvedWsUrl = null;
|
||||
|
||||
// Check for auto-load state file (supports encrypted files)
|
||||
let storageState:
|
||||
@@ -1557,6 +1585,23 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
this.cdpEndpoint = cdpEndpoint;
|
||||
|
||||
let resolvedWs: string | null = null;
|
||||
try {
|
||||
resolvedWs = (browser as any).wsEndpoint?.() ?? null;
|
||||
} catch (err) {
|
||||
console.error('[inspect] wsEndpoint() failed:', err);
|
||||
}
|
||||
if (!resolvedWs && (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://'))) {
|
||||
try {
|
||||
const resp = await fetch(`${cdpUrl}/json/version`);
|
||||
const info: any = await resp.json();
|
||||
resolvedWs = info.webSocketDebuggerUrl ?? null;
|
||||
} catch (err) {
|
||||
console.error('[inspect] /json/version fetch failed:', err);
|
||||
}
|
||||
}
|
||||
this.resolvedWsUrl = resolvedWs;
|
||||
|
||||
for (const context of contexts) {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
@@ -2471,6 +2516,8 @@ export class BrowserManager {
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.stopInspectServer();
|
||||
|
||||
// Stop recording if active (saves video)
|
||||
if (this.recordingContext) {
|
||||
await this.stopRecording();
|
||||
@@ -2551,6 +2598,7 @@ export class BrowserManager {
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpEndpoint = null;
|
||||
this.resolvedWsUrl = null;
|
||||
this.browserbaseSessionId = null;
|
||||
this.browserbaseApiKey = null;
|
||||
this.browserUseSessionId = null;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { injectSessionId, stripSessionId } from './inspect-server.js';
|
||||
|
||||
describe('injectSessionId', () => {
|
||||
it('should inject sessionId into a command', () => {
|
||||
const input = '{"id":1,"method":"DOM.getDocument"}';
|
||||
const result = JSON.parse(injectSessionId(input, 'abc123'));
|
||||
expect(result.sessionId).toBe('abc123');
|
||||
expect(result.method).toBe('DOM.getDocument');
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
|
||||
it('should inject sessionId into an empty object', () => {
|
||||
const result = JSON.parse(injectSessionId('{}', 'abc'));
|
||||
expect(result.sessionId).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripSessionId', () => {
|
||||
it('should remove sessionId from a message', () => {
|
||||
const input = '{"id":1,"result":{},"sessionId":"abc123"}';
|
||||
const result = JSON.parse(stripSessionId(input));
|
||||
expect(result.sessionId).toBeUndefined();
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject then strip roundtrip', () => {
|
||||
it('should return the original message after inject + strip', () => {
|
||||
const input = '{"id":42,"method":"Runtime.evaluate"}';
|
||||
const injected = injectSessionId(input, 'sess1');
|
||||
const stripped = stripSessionId(injected);
|
||||
expect(JSON.parse(stripped)).toEqual(JSON.parse(input));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import http from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
export interface InspectServerOptions {
|
||||
chromeHostPort: string;
|
||||
targetId: string;
|
||||
chromeWsUrl: string;
|
||||
}
|
||||
|
||||
let nextAttachId = -1000;
|
||||
|
||||
export function injectSessionId(json: string, sessionId: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
msg.sessionId = sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function stripSessionId(json: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
delete msg.sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
// The Node.js path opens its own WebSocket to Chrome rather than sharing
|
||||
// Playwright's internal connection. This avoids interfering with Playwright's
|
||||
// CDP session management. The Rust/native path takes the opposite approach,
|
||||
// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle.
|
||||
export class InspectServer {
|
||||
private httpServer: http.Server;
|
||||
private wss: WebSocketServer;
|
||||
private chromeWs: WebSocket | null = null;
|
||||
private sessions = new Map<string, WebSocket>();
|
||||
private pendingAttaches = new Map<number, (sessionId: string | null) => void>();
|
||||
private _port: number = 0;
|
||||
|
||||
constructor(private options: InspectServerOptions) {
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
|
||||
this.wss.on('connection', this.handleWsConnection.bind(this));
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.connectChrome();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer.listen(0, '127.0.0.1', () => {
|
||||
const addr = this.httpServer.address();
|
||||
if (addr && typeof addr !== 'string') {
|
||||
this._port = addr.port;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
this.httpServer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const [sessionId, devtoolsWs] of this.sessions) {
|
||||
this.detachSession(sessionId);
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.chromeWs?.close();
|
||||
this.chromeWs = null;
|
||||
this.wss.close();
|
||||
this.httpServer.close();
|
||||
}
|
||||
|
||||
private connectChrome(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.options.chromeWsUrl);
|
||||
ws.on('open', () => {
|
||||
this.chromeWs = ws;
|
||||
resolve();
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
if (!this.chromeWs) {
|
||||
reject(new Error(`Chrome WebSocket connection failed: ${err.message}`));
|
||||
} else {
|
||||
console.error('[inspect] Chrome WebSocket error:', err.message);
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
this.chromeWs = null;
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
});
|
||||
ws.on('message', (data) => this.handleChromeMessage(data));
|
||||
});
|
||||
}
|
||||
|
||||
private handleChromeMessage(data: unknown): void {
|
||||
try {
|
||||
const text = String(data);
|
||||
const msg = JSON.parse(text);
|
||||
|
||||
// Check if this is a response to a pending attachToTarget request
|
||||
if (msg.id != null && msg.id < 0) {
|
||||
const resolve = this.pendingAttaches.get(msg.id);
|
||||
if (resolve) {
|
||||
this.pendingAttaches.delete(msg.id);
|
||||
resolve(msg.result?.sessionId ?? null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route session-scoped messages to the correct DevTools client
|
||||
const sessionId: string | undefined = msg.sessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
const devtoolsWs = this.sessions.get(sessionId);
|
||||
if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
devtoolsWs.send(stripSessionId(text));
|
||||
} catch (err) {
|
||||
console.error('[inspect] Chrome message handling error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
if (req.url === '/' || req.url === '') {
|
||||
const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`;
|
||||
res.writeHead(302, { Location: location, 'Content-Type': 'text/html' });
|
||||
res.end(`<html><body>Redirecting to <a href="${location}">${location}</a></body></html>`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
private handleWsConnection(devtoolsWs: WebSocket): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) {
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const attachId = nextAttachId--;
|
||||
const attachMsg = JSON.stringify({
|
||||
id: attachId,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: this.options.targetId, flatten: true },
|
||||
});
|
||||
|
||||
// Track the session ID once attach completes; closed by close/error handlers
|
||||
// that are registered immediately (before the async attach resolves) so
|
||||
// early disconnects still trigger cleanup.
|
||||
let sessionId: string | null = null;
|
||||
|
||||
devtoolsWs.on('close', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
devtoolsWs.on('error', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
devtoolsWs.close();
|
||||
});
|
||||
|
||||
const messageBuffer: string[] = [];
|
||||
|
||||
devtoolsWs.on('message', (data) => {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const text = String(data);
|
||||
if (!sessionId) {
|
||||
messageBuffer.push(text);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.chromeWs.send(injectSessionId(text, sessionId));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const attachPromise = new Promise<string | null>((resolve) => {
|
||||
this.pendingAttaches.set(attachId, resolve);
|
||||
this.chromeWs!.send(attachMsg);
|
||||
setTimeout(() => {
|
||||
if (this.pendingAttaches.has(attachId)) {
|
||||
this.pendingAttaches.delete(attachId);
|
||||
resolve(null);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
attachPromise.then((sid) => {
|
||||
if (!sid) {
|
||||
console.error('[inspect] Failed to attach to target');
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (devtoolsWs.readyState !== WebSocket.OPEN) {
|
||||
this.detachSession(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionId = sid;
|
||||
this.sessions.set(sid, devtoolsWs);
|
||||
|
||||
for (const buffered of messageBuffer) {
|
||||
try {
|
||||
this.chromeWs!.send(injectSessionId(buffered, sid));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
}
|
||||
messageBuffer.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private detachSession(sessionId: string): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const detachId = nextAttachId--;
|
||||
const detachMsg = JSON.stringify({
|
||||
id: detachId,
|
||||
method: 'Target.detachFromTarget',
|
||||
params: { sessionId },
|
||||
});
|
||||
try {
|
||||
this.chromeWs.send(detachMsg);
|
||||
} catch (err) {
|
||||
console.error('[inspect] Failed to detach session:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,14 @@ export interface UrlCommand extends BaseCommand {
|
||||
action: 'url';
|
||||
}
|
||||
|
||||
export interface CdpUrlCommand extends BaseCommand {
|
||||
action: 'cdp_url';
|
||||
}
|
||||
|
||||
export interface InspectCommand extends BaseCommand {
|
||||
action: 'inspect';
|
||||
}
|
||||
|
||||
export interface TitleCommand extends BaseCommand {
|
||||
action: 'title';
|
||||
}
|
||||
@@ -946,6 +954,8 @@ export type Command =
|
||||
| ForwardCommand
|
||||
| ReloadCommand
|
||||
| UrlCommand
|
||||
| CdpUrlCommand
|
||||
| InspectCommand
|
||||
| TitleCommand
|
||||
| GetAttributeCommand
|
||||
| GetTextCommand
|
||||
|
||||
Reference in New Issue
Block a user