diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index f1df167..64f11e2 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -1,12 +1,13 @@ use serde_json::{json, Value}; use std::collections::HashSet; +use std::future::Future; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::{broadcast, Mutex}; -use super::cdp::chrome::{ - auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions, -}; +use super::cdp::chrome::{auto_connect_cdp, launch_chrome, ChromeProcess, LaunchOptions}; use super::cdp::client::CdpClient; +use super::cdp::discovery::discover_cdp_url; use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess}; use super::cdp::types::*; @@ -167,6 +168,10 @@ pub struct BrowserManager { default_timeout_ms: u64, } +const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100); +const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10); + impl BrowserManager { pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result { let engine = engine.unwrap_or("chrome"); @@ -205,9 +210,7 @@ impl BrowserManager { proxy: options.proxy.clone(), port: None, }; - let lp = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options)) - .await - .map_err(|e| format!("Lightpanda launch task failed: {}", e))??; + let lp = launch_lightpanda(&lp_options).await?; let url = lp.ws_url.clone(); (url, BrowserProcess::Lightpanda(lp)) } @@ -220,18 +223,22 @@ impl BrowserManager { } }; - let client = CdpClient::connect(&ws_url).await?; - let mut manager = Self { - client, - browser_process: Some(process), - ws_url, - pages: Vec::new(), - active_page_index: 0, - default_timeout_ms: 25_000, + let manager = if engine == "lightpanda" { + initialize_lightpanda_manager(ws_url, process).await? + } else { + let client = CdpClient::connect(&ws_url).await?; + let mut manager = Self { + client, + browser_process: Some(process), + ws_url, + pages: Vec::new(), + active_page_index: 0, + default_timeout_ms: 25_000, + }; + manager.discover_and_attach_targets().await?; + manager }; - manager.discover_and_attach_targets().await?; - let session_id = manager.active_session_id()?.to_string(); if ignore_https_errors { @@ -1119,6 +1126,120 @@ impl BrowserManager { } } +async fn connect_cdp_with_retry( + ws_url: &str, + total_timeout: Duration, + poll_interval: Duration, +) -> Result { + let deadline = Instant::now() + total_timeout; + + loop { + match CdpClient::connect(ws_url).await { + Ok(client) => return Ok(client), + Err(err) => { + if Instant::now() >= deadline { + return Err(err); + } + } + } + + tokio::time::sleep(poll_interval).await; + } +} + +async fn initialize_lightpanda_manager( + ws_url: String, + process: BrowserProcess, +) -> Result { + let deadline = Instant::now() + LIGHTPANDA_TARGET_INIT_TIMEOUT; + let mut process = Some(process); + + loop { + let client = match connect_cdp_with_retry( + &ws_url, + LIGHTPANDA_CDP_CONNECT_TIMEOUT, + LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL, + ) + .await + { + Ok(client) => client, + Err(err) => { + if Instant::now() >= deadline { + return Err(lightpanda_target_init_timeout(Some(&err))); + } + tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; + continue; + } + }; + + let mut manager = BrowserManager { + client, + browser_process: None, + ws_url: ws_url.clone(), + pages: Vec::new(), + active_page_index: 0, + default_timeout_ms: 25_000, + }; + + match discover_and_attach_lightpanda_targets(&mut manager, deadline).await { + Ok(()) => { + manager.browser_process = process.take(); + return Ok(manager); + } + Err(err) => { + if Instant::now() >= deadline { + return Err(lightpanda_target_init_timeout(Some(&err))); + } + tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; + } + } + } +} + +async fn discover_and_attach_lightpanda_targets( + manager: &mut BrowserManager, + deadline: Instant, +) -> Result<(), String> { + run_with_lightpanda_deadline( + deadline, + manager.discover_and_attach_targets(), + "Target domain initialization attempt exceeded the remaining startup deadline", + ) + .await +} + +fn remaining_until(deadline: Instant) -> Option { + deadline.checked_duration_since(Instant::now()) +} + +async fn run_with_lightpanda_deadline( + deadline: Instant, + operation: F, + timeout_context: &'static str, +) -> Result +where + F: Future>, +{ + let remaining = remaining_until(deadline) + .ok_or_else(|| lightpanda_target_init_timeout(Some("deadline expired before retry")))?; + + match tokio::time::timeout(remaining, operation).await { + Ok(result) => result, + Err(_) => Err(lightpanda_target_init_timeout(Some(timeout_context))), + } +} + +fn lightpanda_target_init_timeout(last_error: Option<&str>) -> String { + let mut message = format!( + "Timed out after {}ms waiting for Lightpanda Target domain to initialize", + LIGHTPANDA_TARGET_INIT_TIMEOUT.as_millis(), + ); + if let Some(last_error) = last_error { + message.push_str(&format!("\nLast error: {}", last_error)); + } + message +} + async fn resolve_cdp_url(input: &str) -> Result { if input.starts_with("ws://") || input.starts_with("wss://") { return Ok(input.to_string()); @@ -1145,6 +1266,7 @@ async fn resolve_cdp_url(input: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use tokio::time::sleep; #[test] fn test_validate_launch_options_extensions_and_cdp() { @@ -1251,4 +1373,59 @@ mod tests { "Element not found. Verify the selector is correct and the element exists in the DOM."; assert_eq!(to_ai_friendly_error("No element found for css 'x'"), mapped); } + + #[test] + fn test_remaining_until_returns_none_for_past_deadline() { + let deadline = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("past instant should be representable"); + assert!(remaining_until(deadline).is_none()); + } + + #[tokio::test] + async fn test_run_with_lightpanda_deadline_enforces_timeout() { + let deadline = Instant::now() + Duration::from_millis(25); + let err = tokio::time::timeout( + Duration::from_secs(1), + run_with_lightpanda_deadline( + deadline, + async { + sleep(Duration::from_millis(100)).await; + Ok::<(), String>(()) + }, + "Target domain initialization attempt exceeded the remaining startup deadline", + ), + ) + .await + .expect("outer timeout should not fire") + .unwrap_err(); + + assert!(err.contains( + "Timed out after 10000ms waiting for Lightpanda Target domain to initialize" + )); + assert!(err.contains("remaining startup deadline")); + } + + #[tokio::test] + async fn test_run_with_lightpanda_deadline_returns_operation_error() { + let deadline = Instant::now() + Duration::from_secs(1); + let err = run_with_lightpanda_deadline( + deadline, + async { Err::<(), String>("Target.getTargets failed".to_string()) }, + "unused timeout context", + ) + .await + .unwrap_err(); + + assert_eq!(err, "Target.getTargets failed"); + } + + #[test] + fn test_lightpanda_target_init_timeout_includes_last_error() { + let err = lightpanda_target_init_timeout(Some("Target.setDiscoverTargets failed")); + assert!(err.contains( + "Timed out after 10000ms waiting for Lightpanda Target domain to initialize" + )); + assert!(err.contains("Target.setDiscoverTargets failed")); + } } diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 8edbbb2..2075450 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; -use super::types::BrowserVersionInfo; +use super::discovery::discover_cdp_url; pub struct ChromeProcess { child: Child, @@ -386,28 +386,6 @@ pub fn find_chrome() -> Option { None } -pub async fn discover_cdp_url(port: u16) -> Result { - let url = format!("http://127.0.0.1:{}/json/version", port); - - let body = tokio::time::timeout(Duration::from_secs(2), async { - reqwest_get_string(&url).await - }) - .await - .map_err(|_| format!("Timeout connecting to CDP on port {}", port))? - .map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?; - - let info: BrowserVersionInfo = serde_json::from_str(&body) - .map_err(|e| format!("Invalid /json/version response: {}", e))?; - - info.web_socket_debugger_url - .ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port)) -} - -async fn reqwest_get_string(url: &str) -> Result { - let resp = reqwest::get(url).await.map_err(|e| e.to_string())?; - resp.text().await.map_err(|e| e.to_string()) -} - pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> { let path = user_data_dir.join("DevToolsActivePort"); let content = std::fs::read_to_string(&path).ok()?; diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs new file mode 100644 index 0000000..f8fac20 --- /dev/null +++ b/cli/src/native/cdp/discovery.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use super::types::BrowserVersionInfo; + +pub async fn discover_cdp_url(port: u16) -> Result { + discover_cdp_url_with_request_timeout(port, Duration::from_secs(2)).await +} + +pub async fn discover_cdp_url_with_request_timeout( + port: u16, + request_timeout: Duration, +) -> Result { + let url = format!("http://127.0.0.1:{}/json/version", port); + + let body = tokio::time::timeout(request_timeout, async { reqwest_get_string(&url).await }) + .await + .map_err(|_| format!("Timeout connecting to CDP on port {}", port))? + .map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?; + + let info: BrowserVersionInfo = serde_json::from_str(&body) + .map_err(|e| format!("Invalid /json/version response: {}", e))?; + + info.web_socket_debugger_url + .ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port)) +} + +async fn reqwest_get_string(url: &str) -> Result { + let resp = reqwest::get(url).await.map_err(|e| e.to_string())?; + resp.text().await.map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + async fn spawn_json_server(body: &'static str) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + (port, handle) + } + + #[tokio::test] + async fn discovers_ws_url_from_json_version() { + let (port, server) = + spawn_json_server(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#).await; + + let ws_url = discover_cdp_url(port).await.unwrap(); + assert_eq!(ws_url, "ws://127.0.0.1:1234/"); + server.await.unwrap(); + } + + #[tokio::test] + async fn invalid_json_returns_parse_error() { + let (port, server) = spawn_json_server("not-json").await; + + let err = discover_cdp_url(port).await.unwrap_err(); + assert!(err.contains("Invalid /json/version response")); + server.await.unwrap(); + } +} diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index 92cd545..60ef6e4 100644 --- a/cli/src/native/cdp/lightpanda.rs +++ b/cli/src/native/cdp/lightpanda.rs @@ -1,13 +1,23 @@ +use std::collections::VecDeque; use std::io::{BufRead, BufReader}; use std::net::TcpListener; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::time::Duration; +use super::discovery::discover_cdp_url_with_request_timeout; + +const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100); +const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500); +const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum +const MAX_LOG_LINES: usize = 40; + pub struct LightpandaProcess { child: Child, pub ws_url: String, - _stderr_drain: Option>, + _log_drainers: Vec>, } impl LightpandaProcess { @@ -30,8 +40,68 @@ pub struct LightpandaLaunchOptions { pub port: Option, } +fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec { + 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(), + LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(), + ]; + + if let Some(proxy) = proxy { + args.push("--http_proxy".to_string()); + args.push(proxy.to_string()); + } + + args +} + +#[derive(Clone, Default)] +struct LaunchLogBuffer { + stdout: Arc>>, + stderr: Arc>>, +} + +impl LaunchLogBuffer { + fn push_stdout(&self, line: String) { + push_bounded(&self.stdout, line); + } + + fn push_stderr(&self, line: String) { + push_bounded(&self.stderr, line); + } + + fn snapshot_stdout(&self) -> Vec { + self.stdout + .lock() + .expect("stdout log buffer poisoned") + .iter() + .cloned() + .collect() + } + + fn snapshot_stderr(&self) -> Vec { + self.stderr + .lock() + .expect("stderr log buffer poisoned") + .iter() + .cloned() + .collect() + } +} + +fn push_bounded(buffer: &Mutex>, line: String) { + let mut guard = buffer.lock().expect("log buffer poisoned"); + if guard.len() >= MAX_LOG_LINES { + guard.pop_front(); + } + guard.push_back(line); +} + pub fn find_lightpanda() -> Option { - // Check PATH via `which` #[cfg(unix)] { if let Ok(output) = Command::new("which").arg("lightpanda").output() { @@ -61,7 +131,6 @@ pub fn find_lightpanda() -> Option { } } - // Common install locations if let Some(home) = dirs::home_dir() { let candidates = [ home.join(".lightpanda/lightpanda"), @@ -74,13 +143,12 @@ pub fn find_lightpanda() -> Option { } } - // npm package binary: @lightpanda/browser installs to node_modules/.bin - // Not checked here since the user would typically have it in PATH. - None } -pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result { +pub async fn launch_lightpanda( + options: &LightpandaLaunchOptions, +) -> Result { let binary_path = match &options.executable_path { Some(p) => PathBuf::from(p), None => find_lightpanda().ok_or( @@ -95,31 +163,7 @@ pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result 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(_) => {} + let ws_url = + match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT) + .await + { + Ok(url) => url, + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); } - } - }); + }; Ok(LightpandaProcess { child, ws_url, - _stderr_drain: Some(drain), + _log_drainers: log_drainers, }) } -/// Parse Lightpanda's stderr for the server address. -/// Lightpanda outputs lines like: -/// INFO app : server running . . . address = 127.0.0.1:9222 -/// -/// Returns the address and the reader so the caller can keep the pipe alive. -fn wait_for_address( - mut reader: BufReader, -) -> Result<(String, BufReader), String> { - let deadline = std::time::Instant::now() + Duration::from_secs(30); - let mut stderr_lines: Vec = Vec::new(); - let mut buf = String::new(); +fn start_log_drainers( + child: &mut Child, +) -> Result<(LaunchLogBuffer, Vec>), String> { + let stdout = child.stdout.take().ok_or_else(|| { + let _ = child.kill(); + "Failed to capture Lightpanda stdout".to_string() + })?; + let stderr = child.stderr.take().ok_or_else(|| { + let _ = child.kill(); + "Failed to capture Lightpanda stderr".to_string() + })?; + + let logs = LaunchLogBuffer::default(); + let stdout_logs = logs.clone(); + let stderr_logs = logs.clone(); + + let stdout_handle = + std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line))); + let stderr_handle = + std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line))); + + Ok((logs, vec![stdout_handle, stderr_handle])) +} + +fn drain_reader(reader: R, mut push: F) +where + R: std::io::Read, + F: FnMut(String), +{ + for line in BufReader::new(reader).lines() { + match line { + Ok(line) => push(line), + Err(_) => break, + } + } +} + +async fn wait_for_lightpanda_ready( + child: &mut Child, + port: u16, + logs: &LaunchLogBuffer, + startup_timeout: Duration, +) -> Result { + let deadline = std::time::Instant::now() + startup_timeout; + let mut last_probe_error = None; loop { - if std::time::Instant::now() > deadline { + if let Ok(Some(status)) = child.try_wait() { + // Give the drainer threads a brief window to flush the last log lines + // before we snapshot them. This is best-effort: lines written just + // before exit may still be missing, but the most useful output (early + // startup errors) will already be in the buffer. + tokio::time::sleep(Duration::from_millis(25)).await; return Err(lightpanda_launch_error( - "Timeout waiting for Lightpanda server address", - &stderr_lines, + &format!( + "Lightpanda exited before CDP became ready (status: {})", + status + ), + logs, + last_probe_error.as_deref(), )); } - 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)); - } + + match discover_cdp_url_with_request_timeout(port, LIGHTPANDA_DISCOVERY_TIMEOUT).await { + Ok(ws_url) => return Ok(ws_url), + Err(err) => last_probe_error = Some(err), } + + if std::time::Instant::now() >= deadline { + return Err(lightpanda_launch_error( + &format!( + "Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}", + startup_timeout.as_millis(), + port + ), + logs, + last_probe_error.as_deref(), + )); + } + + tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await; } } -fn extract_address(line: &str) -> Option { - // Lightpanda uses logfmt (`address=...`) in release, pretty (`address = ...`) in debug. - for pattern in &["address=", "address = "] { - if let Some(idx) = line.find(pattern) { - let addr = line[idx + pattern.len()..].trim().to_string(); - // logfmt lines may have subsequent key=value pairs - let addr = addr.split_whitespace().next().unwrap_or("").to_string(); - if !addr.is_empty() { - return Some(addr); - } - } - } - None -} +fn lightpanda_launch_error( + message: &str, + logs: &LaunchLogBuffer, + last_probe_error: Option<&str>, +) -> String { + let stdout_lines = logs.snapshot_stdout(); + let stderr_lines = logs.snapshot_stderr(); + let mut details = Vec::new(); -fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String { - if stderr_lines.is_empty() { - return format!("{} (no stderr output from Lightpanda)", message); + if let Some(err) = last_probe_error { + details.push(format!("Last probe error: {}", err)); } - 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(|s| s.as_str()) - .collect::>() - .join("\n ") - ) + if !stderr_lines.is_empty() { + details.push(format!( + "Lightpanda stderr (last {} lines):\n {}", + stderr_lines.len(), + stderr_lines.join("\n ") + )); + } + + if !stdout_lines.is_empty() { + details.push(format!( + "Lightpanda stdout (last {} lines):\n {}", + stdout_lines.len(), + stdout_lines.join("\n ") + )); + } + + if details.is_empty() { + format!("{} (no stdout/stderr output from Lightpanda)", message) + } else { + format!("{}\n{}", message, details.join("\n")) + } } #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener as TokioTcpListener; - #[test] - fn test_extract_address_pretty_debug_build() { - assert_eq!( - extract_address(" address = 127.0.0.1:9222"), - Some("127.0.0.1:9222".to_string()) - ); + fn unused_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() } - #[test] - fn test_extract_address_logfmt_release_build() { - assert_eq!( - extract_address( - "$time=1234 $scope=app $level=info $msg=\"server running\" address=127.0.0.1:9222" - ), - Some("127.0.0.1:9222".to_string()) + async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}", + body.len(), + body ); + socket.write_all(response.as_bytes()).await.unwrap(); } - #[test] - fn test_extract_address_pretty_inline() { - assert_eq!( - extract_address("INFO app : server running address = 127.0.0.1:4567"), - Some("127.0.0.1:4567".to_string()) - ); + #[tokio::test] + async fn waits_for_ready_without_logs() { + let port = unused_port(); + tokio::spawn(serve_json_version_once_after_delay( + port, + 150, + r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#, + )); + + let mut child = Command::new("/bin/sh") + .args(["-c", "sleep 5"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let (logs, _drainers) = start_log_drainers(&mut child).unwrap(); + let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT) + .await + .unwrap(); + + assert_eq!(ws_url, "ws://127.0.0.1:9222/"); + let _ = child.kill(); + let _ = child.wait(); } - #[test] - fn test_extract_address_no_match() { - assert_eq!(extract_address("INFO app : starting up..."), None); + #[tokio::test] + async fn child_exit_surfaces_logs() { + let port = unused_port(); + let mut child = Command::new("/bin/sh") + .args(["-c", "echo boom >&2; sleep 0.1; exit 23"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let (logs, _drainers) = start_log_drainers(&mut child).unwrap(); + let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT) + .await + .unwrap_err(); + + assert!(err.contains("Lightpanda exited before CDP became ready")); + assert!(err.contains("boom")); + } + + #[tokio::test] + async fn timeout_reports_last_probe_error() { + let port = unused_port(); + let mut child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let timeout = Duration::from_millis(300); + let (logs, _drainers) = start_log_drainers(&mut child).unwrap(); + let err = tokio::time::timeout( + Duration::from_secs(2), + wait_for_lightpanda_ready(&mut child, port, &logs, timeout), + ) + .await + .expect("ready wait should return before outer timeout") + .unwrap_err(); + + assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint")); + assert!( + err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP") + ); + + let _ = child.kill(); + let _ = child.wait(); } #[test] fn test_find_lightpanda_returns_none_when_missing() { - // On most CI/dev machines Lightpanda won't be installed - // Just verify the function doesn't panic let _ = find_lightpanda(); } #[test] - fn test_lightpanda_launch_error_no_stderr() { - let msg = lightpanda_launch_error("Lightpanda exited", &[]); - assert!(msg.contains("no stderr output")); + fn test_lightpanda_launch_error_no_logs() { + let logs = LaunchLogBuffer::default(); + let msg = lightpanda_launch_error("Lightpanda exited", &logs, None); + assert!(msg.contains("no stdout/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")); + let logs = LaunchLogBuffer::default(); + logs.push_stdout("stdout line".to_string()); + logs.push_stderr("stderr line".to_string()); + let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed")); + assert!(msg.contains("stdout line")); + assert!(msg.contains("stderr line")); + assert!(msg.contains("Last probe error: connect failed")); } #[test] @@ -306,4 +449,42 @@ mod tests { assert!(opts.proxy.is_none()); assert!(opts.port.is_none()); } + + #[test] + fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() { + let args = build_lightpanda_serve_args(9222, None); + + assert_eq!( + args, + vec![ + "serve".to_string(), + "--host".to_string(), + "127.0.0.1".to_string(), + "--port".to_string(), + "9222".to_string(), + "--timeout".to_string(), + "604800".to_string(), + ] + ); + } + + #[test] + fn test_build_lightpanda_serve_args_with_proxy() { + let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080")); + + assert_eq!( + args, + vec![ + "serve".to_string(), + "--host".to_string(), + "127.0.0.1".to_string(), + "--port".to_string(), + "9333".to_string(), + "--timeout".to_string(), + "604800".to_string(), + "--http_proxy".to_string(), + "http://127.0.0.1:8080".to_string(), + ] + ); + } } diff --git a/cli/src/native/cdp/mod.rs b/cli/src/native/cdp/mod.rs index fd44a88..2f09737 100644 --- a/cli/src/native/cdp/mod.rs +++ b/cli/src/native/cdp/mod.rs @@ -1,4 +1,5 @@ pub mod chrome; pub mod client; +pub mod discovery; pub mod lightpanda; pub mod types; diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 456729c..0a99dad 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -86,6 +86,92 @@ async fn e2e_launch_navigate_evaluate_close() { assert_eq!(get_data(&resp)["closed"], true); } +#[tokio::test] +#[ignore] +async fn e2e_lightpanda_launch_can_open_page() { + let lightpanda_bin = match std::env::var("LIGHTPANDA_BIN") { + Ok(path) if !path.is_empty() => path, + _ => return, + }; + + let mut state = DaemonState::new(); + + let resp = tokio::time::timeout( + tokio::time::Duration::from_secs(20), + execute_command( + &json!({ + "id": "1", + "action": "launch", + "headless": true, + "engine": "lightpanda", + "executablePath": lightpanda_bin, + }), + &mut state, + ), + ) + .await + .expect("Lightpanda launch should not hang"); + + assert_success(&resp); + assert_eq!(get_data(&resp)["launched"], true); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + assert_eq!(get_data(&resp)["url"], "https://example.com/"); + assert_eq!(get_data(&resp)["title"], "Example Domain"); + + let resp = execute_command(&json!({ "id": "3", "action": "close" }), &mut state).await; + assert_success(&resp); + assert_eq!(get_data(&resp)["closed"], true); +} + +#[tokio::test] +#[ignore] +async fn e2e_lightpanda_auto_launch_can_open_page() { + let lightpanda_bin = match std::env::var("LIGHTPANDA_BIN") { + Ok(path) if !path.is_empty() => path, + _ => return, + }; + + let prev_engine = std::env::var("AGENT_BROWSER_ENGINE").ok(); + let prev_path = std::env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(); + std::env::set_var("AGENT_BROWSER_ENGINE", "lightpanda"); + std::env::set_var("AGENT_BROWSER_EXECUTABLE_PATH", &lightpanda_bin); + + let mut state = DaemonState::new(); + + let resp = tokio::time::timeout( + tokio::time::Duration::from_secs(20), + execute_command( + &json!({ "id": "1", "action": "navigate", "url": "https://example.com" }), + &mut state, + ), + ) + .await + .expect("Lightpanda auto-launch should not hang"); + + match prev_engine { + Some(value) => std::env::set_var("AGENT_BROWSER_ENGINE", value), + None => std::env::remove_var("AGENT_BROWSER_ENGINE"), + } + match prev_path { + Some(value) => std::env::set_var("AGENT_BROWSER_EXECUTABLE_PATH", value), + None => std::env::remove_var("AGENT_BROWSER_EXECUTABLE_PATH"), + } + + assert_success(&resp); + assert_eq!(get_data(&resp)["url"], "https://example.com/"); + assert_eq!(get_data(&resp)["title"], "Example Domain"); + + let resp = execute_command(&json!({ "id": "2", "action": "close" }), &mut state).await; + assert_success(&resp); + assert_eq!(get_data(&resp)["closed"], true); +} + // --------------------------------------------------------------------------- // Snapshot with refs and ref-based click // ---------------------------------------------------------------------------