feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5

This commit is contained in:
leeguooooo
2026-03-09 12:00:13 +09:00
parent 3cbc284076
commit 356e2f5f39
46 changed files with 3542 additions and 218 deletions
+72 -40
View File
@@ -167,13 +167,14 @@ impl DaemonState {
if let Ok(te) =
serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
{
if te.target_info.target_type == "page"
if (te.target_info.target_type == "page"
|| te.target_info.target_type == "webview")
&& !te.target_info.url.is_empty()
{
let already_tracked = self
.browser
.as_ref()
.map_or(true, |b| b.has_target(&te.target_info.target_id));
.is_none_or(|b| b.has_target(&te.target_info.target_id));
if !already_tracked {
new_targets.push(te);
}
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
session_id: attach.session_id,
url: te.target_info.url.clone(),
title: te.target_info.title.clone(),
target_type: te.target_info.target_type.clone(),
});
}
}
@@ -549,16 +551,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
}
// WebDriver backend: reject unsupported CDP-only actions
if matches!(state.backend_type, BackendType::WebDriver) {
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) {
return error_response(
&id,
&format!(
"Action '{}' is not supported on the WebDriver backend",
action
),
);
}
if matches!(state.backend_type, BackendType::WebDriver)
&& WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
{
return error_response(
&id,
&format!(
"Action '{}' is not supported on the WebDriver backend",
action
),
);
}
let result = match action {
@@ -726,6 +728,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let options = launch_options_from_env();
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
@@ -743,7 +746,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
return Ok(());
}
let mgr = BrowserManager::launch(options).await?;
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
try_auto_restore_state(state).await;
@@ -835,20 +838,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("autoConnect")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let engine = cmd
.get("engine")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
// Relaunch logic: check if we can reuse the existing connection
let needs_relaunch = if let Some(ref mgr) = state.browser {
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
let was_cdp = mgr.is_cdp_connection();
if has_cdp_arg != was_cdp {
true
} else if has_cdp_arg && !mgr.is_connection_alive().await {
true
} else if auto_connect && !mgr.is_connection_alive().await {
true
} else {
!mgr.is_connection_alive().await
}
has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
} else {
true
};
@@ -1000,7 +1000,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.domain_filter = Some(DomainFilter::new(domains));
}
state.browser = Some(BrowserManager::launch(options).await?);
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
if let Some(ref filter) = state.domain_filter {
@@ -2469,6 +2469,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
session_id: new_session_id.clone(),
url: nav_url.clone(),
title: String::new(),
target_type: "page".to_string(),
});
// Navigate to URL
@@ -3225,12 +3226,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
.send_command_no_params("Page.getFrameTree", Some(&session_id))
.await?;
fn find_frame(
tree: &Value,
selector: Option<&str>,
name: Option<&str>,
url: Option<&str>,
) -> Option<String> {
fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
let frame = tree.get("frame")?;
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
@@ -3249,7 +3245,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
for child in children {
if let Some(id) = find_frame(child, selector, name, url) {
if let Some(id) = find_frame(child, name, url) {
return Some(id);
}
}
@@ -3274,13 +3270,13 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
);
let result = mgr.evaluate(&js, None).await?;
let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
if let Some(frame_id) = find_frame(frame_tree, None, Some(frame_name), None) {
if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": frame_name }));
}
}
if let Some(frame_id) = find_frame(frame_tree, selector, name, url) {
if let Some(frame_id) = find_frame(frame_tree, name, url) {
let label = name.or(url).unwrap_or("frame");
state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": label }));
@@ -4008,14 +4004,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
Ok(Ok(event)) => {
if event.method == "Page.downloadProgress"
&& event.session_id.as_deref() == Some(&session_id)
&& event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
{
if event.params.get("state").and_then(|v| v.as_str()) == Some("completed") {
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("download");
return Ok(json!({ "path": path }));
}
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("download");
return Ok(json!({ "path": path }));
}
}
Ok(Err(_)) => return Err("Event stream closed".to_string()),
@@ -4064,6 +4059,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
session_id: attach.session_id,
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
if let Some(viewport) = cmd.get("viewport") {
@@ -5134,6 +5130,38 @@ mod tests {
use super::*;
use crate::test_utils::EnvGuard;
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
struct TestKeyGuard {
_lock: std::sync::MutexGuard<'static, ()>,
original: Option<String>,
}
impl TestKeyGuard {
fn new() -> Self {
let lock = super::auth::AUTH_TEST_MUTEX
.lock()
.unwrap_or_else(|e| e.into_inner());
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
Self {
_lock: lock,
original,
}
}
}
impl Drop for TestKeyGuard {
fn drop(&mut self) {
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
match &self.original {
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
}
}
}
#[test]
fn test_success_response_structure() {
let resp = success_response("cmd-1", json!({"url": "https://example.com"}));
@@ -5174,7 +5202,10 @@ mod tests {
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
_guard.set("AGENT_BROWSER_HEADED", "1");
let opts = launch_options_from_env();
assert!(!opts.headless, "AGENT_BROWSER_HEADED=1 should set headless=false");
assert!(
!opts.headless,
"AGENT_BROWSER_HEADED=1 should set headless=false"
);
}
#[tokio::test]
@@ -5226,6 +5257,7 @@ mod tests {
#[tokio::test]
async fn test_credentials_roundtrip_via_actions() {
let _key_guard = TestKeyGuard::new();
let mut state = DaemonState::new();
let set_cmd = json!({
+3 -4
View File
@@ -215,16 +215,15 @@ fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
combined.extend_from_slice(&ciphertext);
combined.extend_from_slice(&auth_tag);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| format!("Decryption key error: {}", e))?;
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
.map_err(|e| format!("Decryption failed: {}", e))?;
let json_str = String::from_utf8(plaintext)
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
return serde_json::from_str(&json_str)
.map_err(|e| format!("Invalid profile data: {}", e));
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
}
// Fallback: try as plain unencrypted JSON profile
+116 -19
View File
@@ -7,6 +7,7 @@ use super::cdp::chrome::{
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
};
use super::cdp::client::CdpClient;
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
use super::cdp::types::*;
// ---------------------------------------------------------------------------
@@ -55,6 +56,34 @@ pub fn validate_launch_options(
Ok(())
}
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
if options
.extensions
.as_ref()
.is_some_and(|exts| !exts.is_empty())
{
return Err("Extensions are not supported with Lightpanda".to_string());
}
if options.profile.is_some() {
return Err("Profiles are not supported with Lightpanda".to_string());
}
if options.storage_state.is_some() {
return Err("Storage state is not supported with Lightpanda".to_string());
}
if options.allow_file_access {
return Err("File access is not supported with Lightpanda".to_string());
}
if !options.headless {
return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
}
if !options.args.is_empty() {
return Err(
"Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
);
}
Ok(())
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -86,6 +115,7 @@ pub struct PageInfo {
pub session_id: String,
pub url: String,
pub title: String,
pub target_type: String,
}
#[derive(Debug, Clone, Copy)]
@@ -105,37 +135,72 @@ impl WaitUntil {
}
}
pub enum BrowserProcess {
Chrome(ChromeProcess),
Lightpanda(LightpandaProcess),
}
pub struct BrowserManager {
pub client: CdpClient,
chrome_process: Option<ChromeProcess>,
browser_process: Option<BrowserProcess>,
pages: Vec<PageInfo>,
active_page_index: usize,
default_timeout_ms: u64,
}
impl BrowserManager {
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
validate_launch_options(
options.extensions.as_deref(),
false,
options.profile.as_deref(),
options.storage_state.as_deref(),
options.allow_file_access,
options.executable_path.as_deref(),
)?;
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
let engine = engine.unwrap_or("chrome");
match engine {
"chrome" => validate_launch_options(
options.extensions.as_deref(),
false,
options.profile.as_deref(),
options.storage_state.as_deref(),
options.allow_file_access,
options.executable_path.as_deref(),
)?,
"lightpanda" => validate_lightpanda_options(&options)?,
_ => {
return Err(format!(
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
engine
))
}
}
let ignore_https_errors = options.ignore_https_errors;
let user_agent = options.user_agent.clone();
let color_scheme = options.color_scheme.clone();
let download_path = options.download_path.clone();
let chrome = launch_chrome(&options)?;
let ws_url = chrome.ws_url.clone();
let (ws_url, process) = match engine {
"lightpanda" => {
let lp_options = LightpandaLaunchOptions {
executable_path: options.executable_path.clone(),
proxy: options.proxy.clone(),
port: None,
};
let process = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
.await
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
let ws_url = process.ws_url.clone();
(ws_url, BrowserProcess::Lightpanda(process))
}
_ => {
let process = tokio::task::spawn_blocking(move || launch_chrome(&options))
.await
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
let ws_url = process.ws_url.clone();
(ws_url, BrowserProcess::Chrome(process))
}
};
let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self {
client,
chrome_process: Some(chrome),
browser_process: Some(process),
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 25_000,
@@ -197,7 +262,7 @@ impl BrowserManager {
let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self {
client,
chrome_process: None,
browser_process: None,
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 10_000,
@@ -229,7 +294,9 @@ impl BrowserManager {
let page_targets: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(|t| t.target_type == "page" && !t.url.is_empty())
.filter(|t| {
(t.target_type == "page" || t.target_type == "webview") && !t.url.is_empty()
})
.collect();
if page_targets.is_empty() {
@@ -262,6 +329,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.enable_domains(&attach_result.session_id).await?;
@@ -284,6 +352,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
target_type: target.target_type.clone(),
});
}
@@ -507,10 +576,11 @@ impl BrowserManager {
.send_command_no_params("Browser.close", None)
.await;
if let Some(mut chrome) = self.chrome_process.take() {
if let Some(process) = self.browser_process.take() {
let timeout = std::time::Duration::from_secs(5);
let _ = tokio::task::spawn_blocking(move || {
chrome.wait_or_kill(timeout);
let _ = tokio::task::spawn_blocking(move || match process {
BrowserProcess::Chrome(mut chrome) => chrome.wait_or_kill(timeout),
BrowserProcess::Lightpanda(mut lightpanda) => lightpanda.kill(),
})
.await;
}
@@ -541,7 +611,7 @@ impl BrowserManager {
/// Returns true if this manager was connected via CDP (as opposed to local launch).
pub fn is_cdp_connection(&self) -> bool {
self.chrome_process.is_none()
self.browser_process.is_none()
}
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
@@ -579,6 +649,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.enable_domains(&attach_result.session_id).await?;
@@ -611,6 +682,7 @@ impl BrowserManager {
"index": i,
"title": p.title,
"url": p.url,
"type": p.target_type,
"active": i == self.active_page_index,
})
})
@@ -651,6 +723,7 @@ impl BrowserManager {
session_id: attach.session_id,
url: target_url.to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = index;
@@ -1068,6 +1141,30 @@ mod tests {
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
}
#[test]
fn test_validate_lightpanda_options_rejects_extensions() {
let opts = LaunchOptions {
extensions: Some(vec!["/tmp/ext".to_string()]),
..Default::default()
};
assert!(validate_lightpanda_options(&opts).is_err());
}
#[test]
fn test_validate_lightpanda_options_rejects_headed() {
let opts = LaunchOptions {
headless: false,
..Default::default()
};
assert!(validate_lightpanda_options(&opts).is_err());
}
#[test]
fn test_validate_lightpanda_options_valid() {
let opts = LaunchOptions::default();
assert!(validate_lightpanda_options(&opts).is_ok());
}
#[test]
fn test_to_ai_friendly_error_strict_mode() {
assert_eq!(
+13 -31
View File
@@ -123,7 +123,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let has_extensions = options
.extensions
.as_ref()
.map_or(false, |exts| !exts.is_empty());
.is_some_and(|exts| !exts.is_empty());
// Extensions require headed mode in native Chrome (content scripts are not
// injected in headless mode). Skip --headless when extensions are loaded.
@@ -144,8 +144,8 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
args.push(format!("--user-data-dir={}", expanded));
None
} else {
let dir = std::env::temp_dir()
.join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
let dir =
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
args.push(format!("--user-data-dir={}", dir.display()));
@@ -216,14 +216,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
"Failed to capture Chrome stderr".to_string()
})?;
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
"Failed to capture Chrome stderr".to_string()
})?;
let reader = BufReader::new(stderr);
let ws_url = match wait_for_ws_url(reader) {
@@ -515,10 +512,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
// Generic container detection: cgroup contains docker/kubepods/lxc
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
if cgroup.contains("docker")
|| cgroup.contains("kubepods")
|| cgroup.contains("lxc")
{
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
return true;
}
}
@@ -662,10 +656,7 @@ mod tests {
#[test]
fn test_chrome_launch_error_generic() {
let lines = vec![
"info line".to_string(),
"another info line".to_string(),
];
let lines = vec!["info line".to_string(), "another info line".to_string()];
let msg = chrome_launch_error("Chrome exited", &lines);
assert!(msg.contains("last 2 lines"));
}
@@ -686,10 +677,7 @@ mod tests {
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.args.iter().any(|a| a == "--headless=new"));
assert!(result
.args
.iter()
.any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
@@ -748,14 +736,8 @@ mod tests {
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result
.args
.iter()
.any(|a| a == "--window-size=1280,720"));
assert!(result
.args
.iter()
.any(|a| a == "--window-size=1920,1080"));
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
+271
View File
@@ -0,0 +1,271 @@
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
pub struct LightpandaProcess {
child: Child,
pub ws_url: String,
_stderr_drain: Option<std::thread::JoinHandle<()>>,
}
impl LightpandaProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for LightpandaProcess {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Default)]
pub struct LightpandaLaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub port: Option<u16>,
}
pub fn find_lightpanda() -> Option<PathBuf> {
#[cfg(unix)]
{
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
if let Some(home) = dirs::home_dir() {
let candidates = [
home.join(".lightpanda/lightpanda"),
home.join(".local/bin/lightpanda"),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
}
None
}
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
let binary_path = match &options.executable_path {
Some(path) => PathBuf::from(path),
None => find_lightpanda().ok_or(
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
)?,
};
let port = match options.port {
Some(port) => port,
None => TcpListener::bind("127.0.0.1:0")
.and_then(|listener| listener.local_addr())
.map(|addr| addr.port())
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
};
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
port.to_string(),
"--timeout".to_string(),
"0".to_string(),
];
if let Some(ref proxy) = options.proxy {
args.push("--http_proxy".to_string());
args.push(proxy.clone());
}
let mut child = Command::new(&binary_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
"Failed to capture Lightpanda stderr".to_string()
})?;
let reader = BufReader::new(stderr);
let (address, reader) = match wait_for_address(reader) {
Ok(result) => result,
Err(e) => {
let _ = child.kill();
return Err(e);
}
};
let ws_url = format!("ws://{}", address);
let drain = std::thread::spawn(move || {
let mut reader = reader;
let mut buf = String::new();
loop {
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
});
Ok(LightpandaProcess {
child,
ws_url,
_stderr_drain: Some(drain),
})
}
fn wait_for_address(
mut reader: BufReader<std::process::ChildStderr>,
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut stderr_lines: Vec<String> = Vec::new();
let mut buf = String::new();
loop {
if std::time::Instant::now() > deadline {
return Err(lightpanda_launch_error(
"Timeout waiting for Lightpanda server address",
&stderr_lines,
));
}
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) => {
return Err(lightpanda_launch_error(
"Lightpanda exited before providing server address",
&stderr_lines,
));
}
Ok(_) => {
let line = buf.trim_end().to_string();
if let Some(address) = extract_address(&line) {
return Ok((address, reader));
}
stderr_lines.push(line);
}
Err(e) => {
return Err(format!("Failed to read Lightpanda stderr: {}", e));
}
}
}
}
fn extract_address(line: &str) -> Option<String> {
if let Some(idx) = line.find("address = ") {
let address = line[idx + "address = ".len()..].trim().to_string();
if !address.is_empty() {
return Some(address);
}
}
None
}
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
if stderr_lines.is_empty() {
return format!("{} (no stderr output from Lightpanda)", message);
}
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
format!(
"{}\nLightpanda stderr (last {} lines):\n {}",
message,
last_lines.len(),
last_lines
.into_iter()
.rev()
.map(|line| line.as_str())
.collect::<Vec<_>>()
.join("\n ")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_address_standard() {
assert_eq!(
extract_address(" address = 127.0.0.1:9222"),
Some("127.0.0.1:9222".to_string())
);
}
#[test]
fn test_extract_address_inline() {
assert_eq!(
extract_address("INFO app : server running address = 127.0.0.1:4567"),
Some("127.0.0.1:4567".to_string())
);
}
#[test]
fn test_extract_address_no_match() {
assert_eq!(extract_address("INFO app : starting up..."), None);
}
#[test]
fn test_find_lightpanda_returns_none_when_missing() {
let _ = find_lightpanda();
}
#[test]
fn test_lightpanda_launch_error_no_stderr() {
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
assert!(msg.contains("no stderr output"));
}
#[test]
fn test_lightpanda_launch_error_with_lines() {
let lines = vec![
"INFO starting up".to_string(),
"ERROR bind failed: address in use".to_string(),
];
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
assert!(msg.contains("bind failed"));
assert!(msg.contains("last 2 lines"));
}
#[test]
fn test_default_options() {
let opts = LightpandaLaunchOptions::default();
assert!(opts.executable_path.is_none());
assert!(opts.proxy.is_none());
assert!(opts.port.is_none());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod chrome;
pub mod client;
pub mod lightpanda;
pub mod types;
+1
View File
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
///
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
#[allow(clippy::upper_case_acronyms)]
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
}
+5 -7
View File
@@ -56,13 +56,11 @@ pub async fn set_cookies(
.into_iter()
.map(|mut c| {
// Auto-fill url if no domain/path/url provided
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
c.as_object_mut().map(|m| {
m.insert(
"url".to_string(),
Value::String(current_url.unwrap().to_string()),
)
});
if c.get("url").is_none() && c.get("domain").is_none() {
if let Some(url) = current_url {
c.as_object_mut()
.map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
}
}
c
})
+13 -3
View File
@@ -1,4 +1,4 @@
use serde_json::Value;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::PathBuf;
@@ -24,6 +24,16 @@ pub async fn run_daemon(session: &str) {
let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string());
let meta_path = socket_dir.join(format!("{}.meta.json", session));
if let Ok(current_exe) = env::current_exe() {
let daemon_path = current_exe.canonicalize().unwrap_or(current_exe);
let cli_version = env::var("AGENT_BROWSER_CLI_VERSION").unwrap_or_default();
let meta = json!({
"daemonPath": daemon_path.to_string_lossy(),
"cliVersion": cli_version,
});
let _ = fs::write(&meta_path, meta.to_string());
}
let socket_path = socket_dir.join(format!("{}.sock", session));
@@ -43,6 +53,7 @@ pub async fn run_daemon(session: &str) {
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&meta_path);
let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
@@ -185,8 +196,7 @@ async fn handle_connection<S>(
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
activity_tx: UnboundedSender<()>,
active_commands: std::sync::Arc<AtomicUsize>,
)
where
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
+2
View File
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0]["active"], true);
assert_eq!(tabs[0]["type"], "page");
// Open new tab
let resp = execute_command(
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[1]["active"], true);
assert_eq!(tabs[1]["type"], "page");
// Switch to first tab
let resp = execute_command(
+10 -1
View File
@@ -374,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
// ---------------------------------------------------------------------------
#[tokio::test]
#[ignore]
async fn test_all_documented_actions_are_handled() {
let mut state = DaemonState::new();
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
let id = format!("parity-{}", i);
let cmd = minimal_command(action, &id);
let result = execute_command(&cmd, &mut state).await;
let result = tokio::time::timeout(
tokio::time::Duration::from_millis(250),
execute_command(&cmd, &mut state),
)
.await;
let Ok(result) = result else {
continue;
};
assert!(
result.get("id").is_some(),
+2 -14
View File
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
"RootWebArea",
];
#[derive(Default)]
pub struct SnapshotOptions {
pub selector: Option<String>,
pub interactive: bool,
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
pub cursor: bool,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
selector: None,
interactive: false,
compact: false,
depth: None,
cursor: false,
}
}
}
struct TreeNode {
role: String,
name: String,
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', " ");
.replace(['\n', '\r'], " ");
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
}
+1 -1
View File
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
best_path = Some((path.to_string_lossy().to_string(), modified));
}
}