fix: harden Lightpanda startup timeouts (#762)
This commit is contained in:
+193
-16
@@ -1,12 +1,13 @@
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
|
|
||||||
use super::cdp::chrome::{
|
use super::cdp::chrome::{auto_connect_cdp, launch_chrome, ChromeProcess, LaunchOptions};
|
||||||
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
|
|
||||||
};
|
|
||||||
use super::cdp::client::CdpClient;
|
use super::cdp::client::CdpClient;
|
||||||
|
use super::cdp::discovery::discover_cdp_url;
|
||||||
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
||||||
use super::cdp::types::*;
|
use super::cdp::types::*;
|
||||||
|
|
||||||
@@ -167,6 +168,10 @@ pub struct BrowserManager {
|
|||||||
default_timeout_ms: u64,
|
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 {
|
impl BrowserManager {
|
||||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||||
let engine = engine.unwrap_or("chrome");
|
let engine = engine.unwrap_or("chrome");
|
||||||
@@ -205,9 +210,7 @@ impl BrowserManager {
|
|||||||
proxy: options.proxy.clone(),
|
proxy: options.proxy.clone(),
|
||||||
port: None,
|
port: None,
|
||||||
};
|
};
|
||||||
let lp = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
|
let lp = launch_lightpanda(&lp_options).await?;
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
|
|
||||||
let url = lp.ws_url.clone();
|
let url = lp.ws_url.clone();
|
||||||
(url, BrowserProcess::Lightpanda(lp))
|
(url, BrowserProcess::Lightpanda(lp))
|
||||||
}
|
}
|
||||||
@@ -220,18 +223,22 @@ impl BrowserManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = CdpClient::connect(&ws_url).await?;
|
let manager = if engine == "lightpanda" {
|
||||||
let mut manager = Self {
|
initialize_lightpanda_manager(ws_url, process).await?
|
||||||
client,
|
} else {
|
||||||
browser_process: Some(process),
|
let client = CdpClient::connect(&ws_url).await?;
|
||||||
ws_url,
|
let mut manager = Self {
|
||||||
pages: Vec::new(),
|
client,
|
||||||
active_page_index: 0,
|
browser_process: Some(process),
|
||||||
default_timeout_ms: 25_000,
|
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();
|
let session_id = manager.active_session_id()?.to_string();
|
||||||
|
|
||||||
if ignore_https_errors {
|
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<CdpClient, String> {
|
||||||
|
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<BrowserManager, String> {
|
||||||
|
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<Duration> {
|
||||||
|
deadline.checked_duration_since(Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_with_lightpanda_deadline<F, T>(
|
||||||
|
deadline: Instant,
|
||||||
|
operation: F,
|
||||||
|
timeout_context: &'static str,
|
||||||
|
) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: Future<Output = Result<T, String>>,
|
||||||
|
{
|
||||||
|
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<String, String> {
|
async fn resolve_cdp_url(input: &str) -> Result<String, String> {
|
||||||
if input.starts_with("ws://") || input.starts_with("wss://") {
|
if input.starts_with("ws://") || input.starts_with("wss://") {
|
||||||
return Ok(input.to_string());
|
return Ok(input.to_string());
|
||||||
@@ -1145,6 +1266,7 @@ async fn resolve_cdp_url(input: &str) -> Result<String, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_launch_options_extensions_and_cdp() {
|
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.";
|
"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);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::types::BrowserVersionInfo;
|
use super::discovery::discover_cdp_url;
|
||||||
|
|
||||||
pub struct ChromeProcess {
|
pub struct ChromeProcess {
|
||||||
child: Child,
|
child: Child,
|
||||||
@@ -386,28 +386,6 @@ pub fn find_chrome() -> Option<PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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)> {
|
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
|
||||||
let path = user_data_dir.join("DevToolsActivePort");
|
let path = user_data_dir.join("DevToolsActivePort");
|
||||||
let content = std::fs::read_to_string(&path).ok()?;
|
let content = std::fs::read_to_string(&path).ok()?;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::types::BrowserVersionInfo;
|
||||||
|
|
||||||
|
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+335
-154
@@ -1,13 +1,23 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
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 {
|
pub struct LightpandaProcess {
|
||||||
child: Child,
|
child: Child,
|
||||||
pub ws_url: String,
|
pub ws_url: String,
|
||||||
_stderr_drain: Option<std::thread::JoinHandle<()>>,
|
_log_drainers: Vec<std::thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LightpandaProcess {
|
impl LightpandaProcess {
|
||||||
@@ -30,8 +40,68 @@ pub struct LightpandaLaunchOptions {
|
|||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
|
||||||
|
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<Mutex<VecDeque<String>>>,
|
||||||
|
stderr: Arc<Mutex<VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
self.stdout
|
||||||
|
.lock()
|
||||||
|
.expect("stdout log buffer poisoned")
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_stderr(&self) -> Vec<String> {
|
||||||
|
self.stderr
|
||||||
|
.lock()
|
||||||
|
.expect("stderr log buffer poisoned")
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_bounded(buffer: &Mutex<VecDeque<String>>, 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<PathBuf> {
|
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||||
// Check PATH via `which`
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||||
@@ -61,7 +131,6 @@ pub fn find_lightpanda() -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common install locations
|
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
home.join(".lightpanda/lightpanda"),
|
home.join(".lightpanda/lightpanda"),
|
||||||
@@ -74,13 +143,12 @@ pub fn find_lightpanda() -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// npm package binary: @lightpanda/browser installs to node_modules/.bin
|
|
||||||
// Not checked here since the user would typically have it in PATH.
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
|
pub async fn launch_lightpanda(
|
||||||
|
options: &LightpandaLaunchOptions,
|
||||||
|
) -> Result<LightpandaProcess, String> {
|
||||||
let binary_path = match &options.executable_path {
|
let binary_path = match &options.executable_path {
|
||||||
Some(p) => PathBuf::from(p),
|
Some(p) => PathBuf::from(p),
|
||||||
None => find_lightpanda().ok_or(
|
None => find_lightpanda().ok_or(
|
||||||
@@ -95,31 +163,7 @@ pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<Lightpanda
|
|||||||
.map(|a| a.port())
|
.map(|a| a.port())
|
||||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||||
};
|
};
|
||||||
let port_str = port.to_string();
|
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
|
||||||
|
|
||||||
let mut args = vec![
|
|
||||||
"serve".to_string(),
|
|
||||||
"--host".to_string(),
|
|
||||||
"127.0.0.1".to_string(),
|
|
||||||
"--port".to_string(),
|
|
||||||
port_str,
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some(ref proxy) = options.proxy {
|
|
||||||
args.push("--http_proxy".to_string());
|
|
||||||
args.push(proxy.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the maximum inactivity timeout (1 week) so the connection stays alive
|
|
||||||
// during long sessions. Lightpanda treats 0 as "timeout immediately" rather
|
|
||||||
// than "no timeout".
|
|
||||||
args.push("--timeout".to_string());
|
|
||||||
args.push("604800".to_string());
|
|
||||||
|
|
||||||
// Lightpanda defaults to --log_level warn, which suppresses the "server
|
|
||||||
// running" info message we need to detect the listen address.
|
|
||||||
args.push("--log_level".to_string());
|
|
||||||
args.push("info".to_string());
|
|
||||||
|
|
||||||
let mut child = Command::new(&binary_path)
|
let mut child = Command::new(&binary_path)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
@@ -129,174 +173,273 @@ pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<Lightpanda
|
|||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||||
|
|
||||||
// Lightpanda logs to stderr
|
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
|
||||||
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) {
|
let ws_url =
|
||||||
Ok(result) => result,
|
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||||
Err(e) => {
|
.await
|
||||||
let _ = child.kill();
|
{
|
||||||
return Err(e);
|
Ok(url) => url,
|
||||||
}
|
Err(e) => {
|
||||||
};
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
let ws_url = format!("ws://{}", address);
|
return Err(e);
|
||||||
|
|
||||||
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 {
|
Ok(LightpandaProcess {
|
||||||
child,
|
child,
|
||||||
ws_url,
|
ws_url,
|
||||||
_stderr_drain: Some(drain),
|
_log_drainers: log_drainers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse Lightpanda's stderr for the server address.
|
fn start_log_drainers(
|
||||||
/// Lightpanda outputs lines like:
|
child: &mut Child,
|
||||||
/// INFO app : server running . . . address = 127.0.0.1:9222
|
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
|
||||||
///
|
let stdout = child.stdout.take().ok_or_else(|| {
|
||||||
/// Returns the address and the reader so the caller can keep the pipe alive.
|
let _ = child.kill();
|
||||||
fn wait_for_address(
|
"Failed to capture Lightpanda stdout".to_string()
|
||||||
mut reader: BufReader<std::process::ChildStderr>,
|
})?;
|
||||||
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
|
let stderr = child.stderr.take().ok_or_else(|| {
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
let _ = child.kill();
|
||||||
let mut stderr_lines: Vec<String> = Vec::new();
|
"Failed to capture Lightpanda stderr".to_string()
|
||||||
let mut buf = String::new();
|
})?;
|
||||||
|
|
||||||
|
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<R, F>(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<String, String> {
|
||||||
|
let deadline = std::time::Instant::now() + startup_timeout;
|
||||||
|
let mut last_probe_error = None;
|
||||||
|
|
||||||
loop {
|
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(
|
return Err(lightpanda_launch_error(
|
||||||
"Timeout waiting for Lightpanda server address",
|
&format!(
|
||||||
&stderr_lines,
|
"Lightpanda exited before CDP became ready (status: {})",
|
||||||
|
status
|
||||||
|
),
|
||||||
|
logs,
|
||||||
|
last_probe_error.as_deref(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
buf.clear();
|
|
||||||
match reader.read_line(&mut buf) {
|
match discover_cdp_url_with_request_timeout(port, LIGHTPANDA_DISCOVERY_TIMEOUT).await {
|
||||||
Ok(0) => {
|
Ok(ws_url) => return Ok(ws_url),
|
||||||
return Err(lightpanda_launch_error(
|
Err(err) => last_probe_error = Some(err),
|
||||||
"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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String> {
|
fn lightpanda_launch_error(
|
||||||
// Lightpanda uses logfmt (`address=...`) in release, pretty (`address = ...`) in debug.
|
message: &str,
|
||||||
for pattern in &["address=", "address = "] {
|
logs: &LaunchLogBuffer,
|
||||||
if let Some(idx) = line.find(pattern) {
|
last_probe_error: Option<&str>,
|
||||||
let addr = line[idx + pattern.len()..].trim().to_string();
|
) -> String {
|
||||||
// logfmt lines may have subsequent key=value pairs
|
let stdout_lines = logs.snapshot_stdout();
|
||||||
let addr = addr.split_whitespace().next().unwrap_or("").to_string();
|
let stderr_lines = logs.snapshot_stderr();
|
||||||
if !addr.is_empty() {
|
let mut details = Vec::new();
|
||||||
return Some(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
if let Some(err) = last_probe_error {
|
||||||
if stderr_lines.is_empty() {
|
details.push(format!("Last probe error: {}", err));
|
||||||
return format!("{} (no stderr output from Lightpanda)", message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
if !stderr_lines.is_empty() {
|
||||||
format!(
|
details.push(format!(
|
||||||
"{}\nLightpanda stderr (last {} lines):\n {}",
|
"Lightpanda stderr (last {} lines):\n {}",
|
||||||
message,
|
stderr_lines.len(),
|
||||||
last_lines.len(),
|
stderr_lines.join("\n ")
|
||||||
last_lines
|
));
|
||||||
.into_iter()
|
}
|
||||||
.rev()
|
|
||||||
.map(|s| s.as_str())
|
if !stdout_lines.is_empty() {
|
||||||
.collect::<Vec<_>>()
|
details.push(format!(
|
||||||
.join("\n ")
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener as TokioTcpListener;
|
||||||
|
|
||||||
#[test]
|
fn unused_port() -> u16 {
|
||||||
fn test_extract_address_pretty_debug_build() {
|
std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
assert_eq!(
|
.unwrap()
|
||||||
extract_address(" address = 127.0.0.1:9222"),
|
.local_addr()
|
||||||
Some("127.0.0.1:9222".to_string())
|
.unwrap()
|
||||||
);
|
.port()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
|
||||||
fn test_extract_address_logfmt_release_build() {
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
assert_eq!(
|
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||||
extract_address(
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
"$time=1234 $scope=app $level=info $msg=\"server running\" address=127.0.0.1:9222"
|
let mut buf = [0u8; 1024];
|
||||||
),
|
let _ = socket.read(&mut buf).await;
|
||||||
Some("127.0.0.1:9222".to_string())
|
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]
|
#[tokio::test]
|
||||||
fn test_extract_address_pretty_inline() {
|
async fn waits_for_ready_without_logs() {
|
||||||
assert_eq!(
|
let port = unused_port();
|
||||||
extract_address("INFO app : server running address = 127.0.0.1:4567"),
|
tokio::spawn(serve_json_version_once_after_delay(
|
||||||
Some("127.0.0.1:4567".to_string())
|
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]
|
#[tokio::test]
|
||||||
fn test_extract_address_no_match() {
|
async fn child_exit_surfaces_logs() {
|
||||||
assert_eq!(extract_address("INFO app : starting up..."), None);
|
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]
|
#[test]
|
||||||
fn test_find_lightpanda_returns_none_when_missing() {
|
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();
|
let _ = find_lightpanda();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lightpanda_launch_error_no_stderr() {
|
fn test_lightpanda_launch_error_no_logs() {
|
||||||
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
|
let logs = LaunchLogBuffer::default();
|
||||||
assert!(msg.contains("no stderr output"));
|
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
|
||||||
|
assert!(msg.contains("no stdout/stderr output"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lightpanda_launch_error_with_lines() {
|
fn test_lightpanda_launch_error_with_lines() {
|
||||||
let lines = vec![
|
let logs = LaunchLogBuffer::default();
|
||||||
"INFO starting up".to_string(),
|
logs.push_stdout("stdout line".to_string());
|
||||||
"ERROR bind failed: address in use".to_string(),
|
logs.push_stderr("stderr line".to_string());
|
||||||
];
|
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
|
||||||
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
|
assert!(msg.contains("stdout line"));
|
||||||
assert!(msg.contains("bind failed"));
|
assert!(msg.contains("stderr line"));
|
||||||
assert!(msg.contains("last 2 lines"));
|
assert!(msg.contains("Last probe error: connect failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -306,4 +449,42 @@ mod tests {
|
|||||||
assert!(opts.proxy.is_none());
|
assert!(opts.proxy.is_none());
|
||||||
assert!(opts.port.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(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod chrome;
|
pub mod chrome;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod discovery;
|
||||||
pub mod lightpanda;
|
pub mod lightpanda;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -86,6 +86,92 @@ async fn e2e_launch_navigate_evaluate_close() {
|
|||||||
assert_eq!(get_data(&resp)["closed"], true);
|
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
|
// Snapshot with refs and ref-based click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user