diff --git a/README.md b/README.md index 530ca62..09055c1 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--action-policy ` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) | | `--confirm-actions ` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) | | `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) | +| `--engine ` | Browser engine: `chrome` (default), `lightpanda`; implies `--native` (or `AGENT_BROWSER_ENGINE` env) | | `--native` | [Experimental] Use native Rust daemon instead of Node.js (or `AGENT_BROWSER_NATIVE` env) | | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 438727b..ed8ea7c 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2118,6 +2118,7 @@ mod tests { confirm_actions: None, confirm_interactive: false, native: false, + engine: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index e5e2ee7..f45377d 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -234,6 +234,7 @@ pub struct DaemonOptions<'a> { pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub native: bool, + pub engine: Option<&'a str>, } fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { @@ -297,6 +298,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(ca) = opts.confirm_actions { cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca); } + if let Some(engine) = opts.engine { + cmd.env("AGENT_BROWSER_ENGINE", engine); + } } pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result { diff --git a/cli/src/flags.rs b/cli/src/flags.rs index b897e31..eaea9a4 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -42,6 +42,7 @@ pub struct Config { pub confirm_actions: Option, pub confirm_interactive: Option, pub native: Option, + pub engine: Option, } impl Config { @@ -84,6 +85,7 @@ impl Config { confirm_actions: other.confirm_actions.or(self.confirm_actions), confirm_interactive: other.confirm_interactive.or(self.confirm_interactive), native: other.native.or(self.native), + engine: other.engine.or(self.engine), } } } @@ -158,6 +160,7 @@ fn extract_config_path(args: &[String]) -> Option> { "--allowed-domains", "--action-policy", "--confirm-actions", + "--engine", ]; let mut i = 0; while i < args.len() { @@ -236,6 +239,7 @@ pub struct Flags { pub confirm_actions: Option, pub confirm_interactive: bool, pub native: bool, + pub engine: Option, // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -342,6 +346,7 @@ pub fn parse_flags(args: &[String]) -> Flags { confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE") || config.confirm_interactive.unwrap_or(false), native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false), + engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine), cli_executable_path: false, cli_extensions: false, cli_profile: false, @@ -567,6 +572,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--engine" => { + if let Some(s) = args.get(i + 1) { + flags.engine = Some(s.clone()); + i += 1; + } + } "--native" => { let (val, consumed) = parse_bool_arg(args, i); flags.native = val; @@ -628,6 +639,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--action-policy", "--confirm-actions", "--config", + "--engine", ]; let mut i = 0; diff --git a/cli/src/main.rs b/cli/src/main.rs index 3cb29e8..c4f8735 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -272,9 +272,13 @@ fn main() { } let args: Vec = env::args().skip(1).collect(); - let flags = parse_flags(&args); + let mut flags = parse_flags(&args); let clean = clean_args(&args); + if flags.engine.is_some() && !flags.native { + flags.native = true; + } + let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_version = args.iter().any(|a| a == "--version" || a == "-V"); @@ -413,6 +417,7 @@ fn main() { action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), native: flags.native, + engine: flags.engine.as_deref(), }; let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) { Ok(result) => result, @@ -706,7 +711,8 @@ fn main() { || flags.user_agent.is_some() || flags.allow_file_access || flags.color_scheme.is_some() - || flags.download_path.is_some()) + || flags.download_path.is_some() + || flags.engine.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { @@ -780,6 +786,10 @@ fn main() { launch_cmd["allowedDomains"] = json!(domains); } + if let Some(ref engine) = flags.engine { + launch_cmd["engine"] = json!(engine); + } + match send_command(launch_cmd, &flags.session) { Ok(resp) if !resp.success => { // Launch command failed (e.g., invalid state file, profile error) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 5a25eff..57f7ddd 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -726,6 +726,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 +744,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; @@ -936,6 +937,12 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result<(), String> { + if options + .extensions + .as_ref() + .map(|e| !e.is_empty()) + .unwrap_or(false) + { + 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(); @@ -105,37 +134,85 @@ impl WaitUntil { } } +pub enum BrowserProcess { + Chrome(ChromeProcess), + Lightpanda(LightpandaProcess), +} + +impl BrowserProcess { + pub fn kill(&mut self) { + match self { + BrowserProcess::Chrome(p) => p.kill(), + BrowserProcess::Lightpanda(p) => p.kill(), + } + } +} + pub struct BrowserManager { pub client: CdpClient, - chrome_process: Option, + browser_process: Option, pages: Vec, active_page_index: usize, default_timeout_ms: u64, } impl BrowserManager { - pub async fn launch(options: LaunchOptions) -> Result { - 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 { + 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 lp = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options)) + .await + .map_err(|e| format!("Lightpanda launch task failed: {}", e))??; + let url = lp.ws_url.clone(); + (url, BrowserProcess::Lightpanda(lp)) + } + _ => { + let chrome = tokio::task::spawn_blocking(move || launch_chrome(&options)) + .await + .map_err(|e| format!("Chrome launch task failed: {}", e))??; + let url = chrome.ws_url.clone(); + (url, BrowserProcess::Chrome(chrome)) + } + }; 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 +274,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, @@ -501,15 +578,13 @@ impl BrowserManager { } pub async fn close(&mut self) -> Result<(), String> { - // Close the browser via CDP if possible let _ = self .client .send_command_no_params("Browser.close", None) .await; - // Kill Chrome process if we own it - if let Some(ref mut chrome) = self.chrome_process { - chrome.kill(); + if let Some(ref mut process) = self.browser_process { + process.kill(); } Ok(()) @@ -538,7 +613,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 diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs new file mode 100644 index 0000000..a36a2ba --- /dev/null +++ b/cli/src/native/cdp/lightpanda.rs @@ -0,0 +1,300 @@ +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>, +} + +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(); + } +} + +pub struct LightpandaLaunchOptions { + pub executable_path: Option, + pub proxy: Option, + pub port: Option, +} + +impl Default for LightpandaLaunchOptions { + fn default() -> Self { + Self { + executable_path: None, + proxy: None, + port: None, + } + } +} + +pub fn find_lightpanda() -> Option { + // Check PATH via `which` + #[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)); + } + } + } + } + + // Common install locations + if let Some(home) = dirs::home_dir() { + let candidates = [ + home.join(".lightpanda/lightpanda"), + home.join(".local/bin/lightpanda"), + ]; + for c in &candidates { + if c.exists() { + return Some(c.clone()); + } + } + } + + // 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 { + let binary_path = match &options.executable_path { + Some(p) => PathBuf::from(p), + 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(p) => p, + None => TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?, + }; + let port_str = port.to_string(); + + 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()); + } + + // Disable inactivity timeout so the connection stays alive during long sessions + args.push("--timeout".to_string()); + args.push("0".to_string()); + + 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))?; + + // Lightpanda logs to stderr + 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), + }) +} + +/// 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(); + + 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 { + // Match "address = HOST:PORT" anywhere in the line + if let Some(idx) = line.find("address = ") { + let addr = line[idx + "address = ".len()..].trim().to_string(); + if !addr.is_empty() { + return Some(addr); + } + } + 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(|s| s.as_str()) + .collect::>() + .join("\n ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_address_standard() { + // Lightpanda outputs the address on a separate indented line + 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() { + // 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")); + } + + #[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()); + } +} diff --git a/cli/src/native/cdp/mod.rs b/cli/src/native/cdp/mod.rs index ab1c242..fd44a88 100644 --- a/cli/src/native/cdp/mod.rs +++ b/cli/src/native/cdp/mod.rs @@ -1,3 +1,4 @@ pub mod chrome; pub mod client; +pub mod lightpanda; pub mod types; diff --git a/cli/src/output.rs b/cli/src/output.rs index 8b0a9e0..8ae07cb 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2448,6 +2448,7 @@ Options: --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) + --engine Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE) --native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output @@ -2504,6 +2505,7 @@ Environment: AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts + AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright) Install (recommended, fastest - native Rust CLI): diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 53cc165..5ade413 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -80,6 +80,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent: actionPolicy--action-policystring confirmActions--confirm-actionsstring confirmInteractive--confirm-interactiveboolean + engine--enginestring (chrome, lightpanda) native--nativeboolean (experimental) headers--headersstring (JSON) @@ -187,6 +188,7 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_ACTION_POLICYPath to action policy JSON file.(none) AGENT_BROWSER_CONFIRM_ACTIONSComma-separated action categories requiring confirmation.(none) AGENT_BROWSER_CONFIRM_INTERACTIVEEnable interactive confirmation prompts (auto-denies if stdin is not a TTY).(disabled) + AGENT_BROWSER_ENGINEBrowser engine to use: chrome (default), lightpanda. Implies --native.chrome AGENT_BROWSER_NATIVEUse the experimental native Rust daemon instead of Node.js/Playwright.(disabled) diff --git a/docs/src/app/engines/chrome/page.mdx b/docs/src/app/engines/chrome/page.mdx new file mode 100644 index 0000000..63720c3 --- /dev/null +++ b/docs/src/app/engines/chrome/page.mdx @@ -0,0 +1,105 @@ +import { pageMetadata } from "@/lib/page-metadata" + +export const metadata = pageMetadata("engines/chrome") + +# Chrome + +Chrome (and Chromium) is the default browser engine. agent-browser discovers, launches, and manages the Chrome process automatically via the Chrome DevTools Protocol (CDP). + +## Binary Discovery + +When no `--executable-path` is provided, agent-browser searches for Chrome in this order: + + + + + + + + + + + + + + + + + + + +
PlatformLocations checked
macOS + /Applications/Google Chrome.app, + /Applications/Google Chrome Canary.app, + /Applications/Chromium.app, + Playwright Chromium cache +
Linux + google-chrome, + google-chrome-stable, + chromium-browser, + chromium in PATH, + Playwright Chromium cache +
Windows + %LOCALAPPDATA%\Google\Chrome\Application\chrome.exe, + C:\Program Files\Google\Chrome\Application\chrome.exe, + C:\Program Files (x86)\...\chrome.exe +
+ +If Chrome is not found, run `agent-browser install` to download Chromium via Playwright. + +## Usage + +Chrome is the default engine -- no `--engine` flag is needed: + +```bash +agent-browser open example.com +``` + +To be explicit: + +```bash +agent-browser --engine chrome open example.com +``` + +## Custom Binary + +Point to any Chromium-based browser with `--executable-path`: + +```bash +agent-browser --executable-path /path/to/chromium open example.com +``` + +Or via environment variable: + +```bash +export AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium +agent-browser open example.com +``` + +## Chrome-Specific Features + +These features are available only with Chrome: + + + + + + + + + + + + + +
FeatureFlag
Browser extensions--extension <path>
Persistent profiles--profile <path>
Storage state--state <path>
File URL access--allow-file-access
Headed mode--headed
Custom launch args--args <args>
+ +## Containers and CI + +In Docker, CI runners, or other sandboxed environments, Chrome's user namespace sandbox may need to be disabled: + +```bash +agent-browser --args "--no-sandbox" open example.com +``` + +agent-browser automatically adds `--no-sandbox` when it detects a container environment (Docker, Podman, running as root). diff --git a/docs/src/app/engines/lightpanda/page.mdx b/docs/src/app/engines/lightpanda/page.mdx new file mode 100644 index 0000000..8707074 --- /dev/null +++ b/docs/src/app/engines/lightpanda/page.mdx @@ -0,0 +1,97 @@ +import { pageMetadata } from "@/lib/page-metadata" + +export const metadata = pageMetadata("engines/lightpanda") + +# Lightpanda + +[Lightpanda](https://lightpanda.io/) is a headless browser engine built from scratch in Zig for machines. It starts instantly, uses 10x less memory than Chrome, and executes 10x faster. + +agent-browser manages Lightpanda the same way it manages Chrome -- spawning the process, connecting via CDP, and shutting it down. All downstream commands (snapshot, click, fill, screenshot, etc.) work through the same CDP protocol path. + +## Installation + +Install the Lightpanda binary before using it with agent-browser: + + + + + + + + + + + + + + + +
PlatformCommand
macOS (Apple Silicon)curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && chmod a+x ./lightpanda
Linux (x86_64)curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && chmod a+x ./lightpanda
+ +Move the binary somewhere in your `PATH` (e.g. `/usr/local/bin/lightpanda` or `~/.local/bin/lightpanda`). + +See the [Lightpanda installation docs](https://lightpanda.io/docs/open-source/installation) for more options. + +## Usage + +Use the `--engine` flag to select Lightpanda: + +```bash +agent-browser --engine lightpanda open example.com +agent-browser --engine lightpanda snapshot +agent-browser --engine lightpanda screenshot +``` + +Or set it as the default via environment variable: + +```bash +export AGENT_BROWSER_ENGINE=lightpanda +agent-browser open example.com +``` + +Or in your `agent-browser.json` config: + +```json +{ + "engine": "lightpanda" +} +``` + +## Custom Binary Path + +If the `lightpanda` binary is not in your `PATH`, use `--executable-path`: + +```bash +agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com +``` + +## Differences from Chrome + +Lightpanda is a purpose-built headless engine. Some Chrome-specific features are not available: + + + + + + + + + + + + + +
FeatureStatus
Extensions (--extension)Not supported
Persistent profiles (--profile)Not supported
Storage state (--state)Not supported
File access (--allow-file-access)Not supported
Headed mode (--headed)Not applicable (headless only)
ScreenshotsDepends on Lightpanda CDP support
+ +agent-browser returns a clear error if you combine `--engine lightpanda` with unsupported flags. + +## When to Use Lightpanda + +Lightpanda is a good fit for: + +- Fast web scraping and data extraction +- AI agent workflows where speed and low memory matter +- CI/CD environments with constrained resources +- High-volume parallel automation + +Use Chrome when you need full browser fidelity, extensions, or persistent profiles. diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index ca65766..28fb82a 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -40,6 +40,13 @@ export const navigation: NavSection[] = [ { name: "Native Mode (Experimental)", href: "/native-mode" }, ], }, + { + title: "Engines", + items: [ + { name: "Chrome", href: "/engines/chrome" }, + { name: "Lightpanda", href: "/engines/lightpanda" }, + ], + }, { title: null, items: [{ name: "Changelog", href: "/changelog" }], diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index f9a4602..86bf51e 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -14,6 +14,8 @@ export const PAGE_TITLES: Record = { profiler: "Profiler", ios: "iOS Simulator", security: "Security", + "engines/chrome": "Chrome", + "engines/lightpanda": "Lightpanda", "native-mode": "Native Mode (Experimental)", changelog: "Changelog", }; diff --git a/package.json b/package.json index 7ac747e..14c08eb 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "bench": "pnpm build && tsx test/benchmarks/run.ts", "bench:node": "pnpm build && tsx test/benchmarks/run.ts --node-only", "bench:native": "pnpm build && tsx test/benchmarks/run.ts --native-only", + "bench:engine": "pnpm build && tsx test/benchmarks/run.ts --engine", "postinstall": "node scripts/postinstall.js", "changeset": "changeset", "ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile", diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 8cd7b7a..680828d 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -502,6 +502,28 @@ agent-browser open example.com The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session. +## Browser Engine Selection + +Use `--engine` to choose a local browser engine. The default is `chrome`. + +```bash +# Use Lightpanda (fast headless browser, requires separate install) +agent-browser --engine lightpanda open example.com + +# Via environment variable +export AGENT_BROWSER_ENGINE=lightpanda +agent-browser open example.com + +# With custom binary path +agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com +``` + +Supported engines: +- `chrome` (default) -- Chrome/Chromium via CDP +- `lightpanda` -- Lightpanda headless browser via CDP (10x faster, 10x less memory than Chrome) + +Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation. + ## Ready-to-Use Templates | Template | Description | diff --git a/src/actions.ts b/src/actions.ts index 1e36f94..681a33e 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -605,6 +605,9 @@ async function handleLaunch( command: Command & { action: 'launch' }, browser: BrowserManager ): Promise { + if (command.engine === 'lightpanda') { + return errorResponse(command.id, 'Lightpanda engine requires --native mode'); + } await browser.launch(command); return successResponse(command.id, { launched: true }); } diff --git a/src/protocol.ts b/src/protocol.ts index e93da1d..a7be39d 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -56,6 +56,7 @@ const launchSchema = baseCommandSchema.extend({ allowedDomains: z.array(z.string()).optional(), actionPolicy: z.string().optional(), confirmActions: z.array(z.string()).optional(), + engine: z.enum(['chrome', 'lightpanda']).optional(), }); const navigateSchema = baseCommandSchema.extend({ diff --git a/src/types.ts b/src/types.ts index df18acf..ed9e1f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,7 @@ export interface LaunchCommand extends BaseCommand { allowedDomains?: string[]; actionPolicy?: string; confirmActions?: string[]; + engine?: 'chrome' | 'lightpanda'; // Auto-load state file for session persistence autoStateFilePath?: string; } diff --git a/test/benchmarks/engine-scenarios.ts b/test/benchmarks/engine-scenarios.ts new file mode 100644 index 0000000..e5e7b28 --- /dev/null +++ b/test/benchmarks/engine-scenarios.ts @@ -0,0 +1,317 @@ +import type { BenchmarkCommand, Scenario } from "./scenarios.js"; + +// --------------------------------------------------------------------------- +// HTML generators for realistic pages with complex DOM structures +// --------------------------------------------------------------------------- + +function generateArticlePage(): string { + const paragraphs = Array.from({ length: 30 }, (_, i) => { + const words = Array.from( + { length: 40 + (i % 5) * 10 }, + (_, w) => ["the", "quick", "browser", "engine", "renders", "content", "across", "multiple", "layout", "passes", "while", "handling", "style", "recalculations", "and", "DOM", "mutations"][w % 17], + ).join(" "); + return `

${words}

`; + }); + + const comments = Array.from( + { length: 40 }, + (_, i) => + `
` + + `
User ${i}
` + + `

This is comment number ${i + 1} with some discussion text.

` + + `
` + + `
`, + ); + + const sidebar = Array.from( + { length: 20 }, + (_, i) => + ``, + ); + + return [ + "Benchmark Article", + "", + ``, + '
', + "

Understanding Modern Browser Engine Architecture

", + '
Dr. Smith | | 15 min read
', + `
${Array.from({ length: 6 }, (_, i) => `tag-${i + 1}`).join("")}
`, + "

Introduction

", + ...paragraphs.slice(0, 5), + "

Core Concepts

", + ...paragraphs.slice(5, 12), + '
"Performance is not just about speed, it is about efficiency." - Anonymous
', + "

Implementation Details

", + ...paragraphs.slice(12, 20), + "

Subsection A

", + ...paragraphs.slice(20, 25), + "

Subsection B

", + ...paragraphs.slice(25), + "

Comments

", + '
', + ...comments, + "
", + '", + "", + ].join(""); +} + +function generateDataTablePage(): string { + const headerCells = [ + "ID", "Name", "Email", "Department", "Role", "Status", "Joined", "Last Active", + ]; + const header = `${headerCells.map((h) => `${h}`).join("")}`; + + const rows = Array.from({ length: 200 }, (_, i) => { + const dept = ["Engineering", "Design", "Marketing", "Sales", "Support"][i % 5]; + const role = ["Admin", "Manager", "Member", "Viewer"][i % 4]; + const status = ["Active", "Inactive", "Pending"][i % 3]; + return ( + `` + + `${i + 1}` + + `User ${i + 1}` + + `user${i + 1}@example.com` + + `${dept}` + + `${role}` + + `${status}` + + `2024-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}` + + `${i % 3 === 0 ? "Today" : i % 3 === 1 ? "Yesterday" : "Last week"}` + + `` + ); + }); + + return [ + "Benchmark Table", + "", + "

User Management Dashboard

", + '
', + '', + '', + '', + '', + 'Showing 200 users', + "
", + `${header}${rows.join("")}
`, + '", + "", + ].join(""); +} + +function generateNestedPage(): string { + function nest(depth: number, breadth: number, prefix: string): string { + if (depth === 0) { + return `Leaf node at ${prefix}`; + } + const children = Array.from( + { length: breadth }, + (_, i) => + `
` + + `
Section ${prefix}.${i + 1} (depth ${depth})
` + + `
${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${i + 1}`)}
` + + `
`, + ); + return children.join(""); + } + + return [ + "Benchmark Nested", + "", + "

Deeply Nested Document Structure

", + nest(7, 3, "root"), + "", + ].join(""); +} + +function generateDashboardPage(): string { + const cards = Array.from( + { length: 12 }, + (_, i) => + `
` + + `
Metric ${i + 1}
` + + `
${Math.floor(Math.random() * 10000)}
` + + `
${i % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%
` + + `
`, + ); + + const chartBars = Array.from( + { length: 24 }, + (_, i) => { + const h = 20 + (i * 7 + 13) % 80; + return `
${String(i).padStart(2, "0")}:00
`; + }, + ); + + const logRows = Array.from( + { length: 100 }, + (_, i) => { + const level = ["INFO", "WARN", "ERROR", "DEBUG"][i % 4]; + return ( + `` + + `${new Date(2025, 0, 1, i % 24, i % 60).toISOString()}` + + `${level}` + + `Service ${["auth", "api", "worker", "cache", "db"][i % 5]}` + + `Log message number ${i + 1}: operation completed in ${(Math.random() * 1000).toFixed(0)}ms` + + `` + ); + }, + ); + + return [ + "Benchmark Dashboard", + "", + '

Operations Dashboard

', + `
${cards.join("")}
`, + '
Hourly
Daily
Weekly
', + `

Request Volume

${chartBars.join("")}
`, + '
', + "

Recent Logs

", + `${logRows.join("")}
TimestampLevelServiceMessage
`, + "
", + "", + ].join(""); +} + +// --------------------------------------------------------------------------- +// Pre-build HTML strings and injection commands +// --------------------------------------------------------------------------- + +const ARTICLE_HTML = generateArticlePage(); +const TABLE_HTML = generateDataTablePage(); +const NESTED_HTML = generateNestedPage(); +const DASHBOARD_HTML = generateDashboardPage(); + +function injectCmd(id: string, html: string): BenchmarkCommand { + return { + id, + action: "evaluate", + script: `document.open(); document.write(${JSON.stringify(html)}); document.close(); 'ok'`, + }; +} + +function setupPage(html: string, tag: string): BenchmarkCommand[] { + return [ + { id: `${tag}-nav`, action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" }, + injectCmd(`${tag}-inject`, html), + ]; +} + +// --------------------------------------------------------------------------- +// Engine-specific scenarios: complex pages that stress real-world workloads +// --------------------------------------------------------------------------- + +export const engineScenarios: Scenario[] = [ + { + name: "article-snapshot", + description: "Snapshot a realistic article page (~800 DOM nodes, 30 paragraphs, 40 comments)", + setup: setupPage(ARTICLE_HTML, "art"), + commands: [{ id: "snap", action: "snapshot" }], + }, + { + name: "table-snapshot", + description: "Snapshot a data table with 200 rows and 8 columns", + setup: setupPage(TABLE_HTML, "tbl"), + commands: [{ id: "snap", action: "snapshot" }], + }, + { + name: "nested-snapshot", + description: "Snapshot a deeply nested DOM tree (7 levels, ~3000 nodes)", + setup: setupPage(NESTED_HTML, "nest"), + commands: [{ id: "snap", action: "snapshot" }], + }, + { + name: "dashboard-snap", + description: "Snapshot an operations dashboard with cards, chart, and 100 log rows", + setup: setupPage(DASHBOARD_HTML, "dash"), + commands: [{ id: "snap", action: "snapshot" }], + }, + { + name: "article-inject", + description: "Write a full article page into the DOM (measures parse + layout)", + setup: [ + { id: "ai-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" }, + ], + commands: [injectCmd("ai-write", ARTICLE_HTML)], + }, + { + name: "table-query", + description: "Evaluate a querySelectorAll across a large table", + setup: setupPage(TABLE_HTML, "tq"), + commands: [ + { + id: "query", + action: "evaluate", + script: "document.querySelectorAll('tr[data-row]').length + ' rows, ' + document.querySelectorAll('td').length + ' cells'", + }, + ], + }, + { + name: "dashboard-workflow", + description: "Full agent workflow on complex dashboard: snapshot, click, fill, eval, screenshot", + setup: setupPage(DASHBOARD_HTML, "dw"), + commands: [ + { id: "dw-snap", action: "snapshot" }, + { id: "dw-fill", action: "fill", selector: "#dash-search", value: "error logs" }, + { id: "dw-click", action: "click", selector: "#refresh" }, + { id: "dw-eval", action: "evaluate", script: "document.querySelectorAll('.card').length + ' cards'" }, + { id: "dw-ss", action: "screenshot" }, + ], + }, + { + name: "nested-eval", + description: "Recursive DOM traversal via evaluate on deeply nested tree", + setup: setupPage(NESTED_HTML, "ne"), + commands: [ + { + id: "walk", + action: "evaluate", + script: "(function(){let c=0;const w=n=>{c++;for(const ch of n.children)w(ch);};w(document.body);return c+' nodes';})()", + }, + ], + }, +]; diff --git a/test/benchmarks/pages/article.html b/test/benchmarks/pages/article.html new file mode 100644 index 0000000..1c207ea --- /dev/null +++ b/test/benchmarks/pages/article.html @@ -0,0 +1,253 @@ + + + + + +Understanding Modern Browser Engine Architecture + + + + + + +
+
+

Understanding Modern Browser Engine Architecture

+
+By Dr. Alexandra Chen +March 15, 2025 +18 min read +2,847 views +
+
+Browser EnginesPerformance +Web StandardsRendering +ArchitectureOpen Source +
+ +

Modern browser engines are among the most complex pieces of software ever created. They must parse HTML, CSS, and JavaScript, construct a DOM tree, compute styles, perform layout calculations, paint pixels, and composite layers -- all within milliseconds to maintain 60fps rendering.

+ +

This article explores the architecture of modern browser engines, examining how they process web content from raw bytes to rendered pixels on screen. We will trace the critical rendering path, examine optimization strategies, and understand why certain patterns lead to better performance.

+ +

The Critical Rendering Path

+ +

When a browser receives an HTML document, it begins a multi-stage pipeline known as the critical rendering path. Each stage transforms the document into progressively more structured representations until pixels are painted on screen.

+ +

The first stage involves parsing the HTML into a Document Object Model (DOM). The parser processes tokens sequentially, building a tree structure that represents the document's hierarchy. During this phase, the parser may encounter external resources like stylesheets and scripts that can block further processing.

+ +

CSS parsing happens in parallel where possible. The browser constructs the CSS Object Model (CSSOM), which represents all the style rules that apply to the document. This includes user-agent styles, author styles, and any inline styles specified directly on elements.

+ +

Once both the DOM and CSSOM are available, the browser combines them into a render tree. This tree contains only the elements that will be visible on screen -- elements with display: none are excluded, while pseudo-elements like ::before and ::after are added.

+ +

Layout (also called reflow) is the process of calculating the exact position and size of each element in the render tree. This is one of the most computationally expensive operations in the rendering pipeline, as changes to one element can cascade through the entire tree.

+ +
"The fastest code is code that doesn't run. The fastest layout is layout that doesn't need to happen." -- Chrome DevTools Team
+ +

DOM Construction and Tree Building

+ +

The DOM is a tree-structured representation of the HTML document. Each node in the tree corresponds to an element, text node, comment, or other construct in the HTML. The tree preserves the hierarchical relationships between elements, allowing efficient traversal and manipulation.

+ +

Modern parsers handle malformed HTML gracefully through error recovery algorithms specified in the HTML5 standard. This includes automatic closing of unclosed tags, adoption of misplaced elements, and reconstruction of the formatting element list.

+ +

Shadow DOM introduces additional complexity by creating encapsulated subtrees that can have their own scoped styles and behavior. Custom elements use shadow roots to attach shadow trees, which are rendered in place of the element's regular children.

+ +

Incremental DOM Updates

+ +

When JavaScript modifies the DOM, the browser must determine which parts of the rendering pipeline need to be re-executed. Modern engines use fine-grained invalidation to minimize the work required. A change to an element's text content, for example, may only require a repaint, while changing its width could trigger a full relayout of its subtree.

+ +

Mutation observers provide a way for JavaScript to respond to DOM changes without polling. The browser batches mutations and delivers them asynchronously, allowing multiple changes to be processed efficiently in a single callback.

+ +

Memory Management

+ +

DOM nodes are reference-counted objects that are garbage collected when no longer reachable. However, detached DOM trees -- subtrees that have been removed from the document but are still referenced by JavaScript -- represent a common source of memory leaks in web applications.

+ +

Browser engines use various strategies to minimize memory overhead: string interning for attribute names and common values, node pools for rapid allocation, and lazy initialization of rarely-accessed properties.

+ +

Style Resolution and Cascade

+ +

CSS style resolution involves matching each element against all applicable style rules and computing the final value for every CSS property. With thousands of rules and millions of elements on complex pages, this process must be highly optimized.

+ +

Modern engines use Bloom filters to quickly eliminate rules that cannot match an element, reducing the number of full selector matches required. Selector matching proceeds right-to-left, starting from the key selector (the rightmost part) and working backwards through ancestors.

+ +

The cascade algorithm resolves conflicts between competing declarations by considering origin, specificity, and source order. Custom properties (CSS variables) add another layer of complexity, as they must be resolved during the cascade before they can be used in property values.

+ +

Style sharing is an optimization where elements with identical computed styles share a single style data structure rather than each maintaining their own copy. This is particularly effective on pages with repetitive structures like lists and tables.

+ +
/* Example: These list items can share computed styles */
+.data-grid tr:nth-child(even) td {
+  background-color: #f8fafc;
+  padding: 8px 12px;
+  font-size: 14px;
+  border-bottom: 1px solid #e2e8f0;
+}
+ +

Layout Algorithms

+ +

Layout is the process of converting the styled render tree into a set of positioned boxes with concrete pixel dimensions. Different layout modes (block, inline, flex, grid, table) each have their own algorithm for determining element sizes and positions.

+ +

Flexbox layout involves multiple passes: first computing the flex basis of each item, then distributing free space according to flex-grow and flex-shrink factors, and finally positioning items along the cross axis. This multi-pass nature makes flex layout more expensive than simple block layout.

+ +

Grid layout is even more complex, supporting both explicit and implicit grid definitions, named areas, auto-placement, and spanning. The grid placement algorithm must resolve conflicts between explicitly-placed and auto-placed items while respecting sizing constraints.

+ +

Containing block queries are a frequent operation during layout. An element's containing block determines its available width for percentage calculations and establishes the coordinate system for positioned descendants. Finding the correct containing block requires walking up the tree, checking for elements that establish new containing blocks.

+ +

Fragmentation handles content that must be split across multiple pages or columns. The fragmentation algorithm inserts breaks at legal break points, avoiding orphans and widows while respecting the break-before, break-after, and break-inside properties.

+ +

Paint and Compositing

+ +

After layout, the browser must paint the visual representation of each element. This involves drawing backgrounds, borders, text, images, shadows, and other visual effects in the correct stacking order defined by the z-index property and stacking context rules.

+ +

Modern browsers use a layered compositing architecture. Elements that change frequently (animations, scrolling regions, video) are promoted to their own compositing layers. These layers can be updated independently and composited together on the GPU, avoiding expensive repaints of the entire page.

+ +

The compositor thread operates independently from the main thread, allowing smooth scrolling and animations even when JavaScript is executing. Touch events and scroll gestures are handled directly by the compositor, with the main thread notified asynchronously.

+ +

Paint operations are recorded into display lists -- serialized sequences of drawing commands. These display lists can be rasterized by worker threads on the CPU or directly by the GPU, depending on the content and the platform's capabilities.

+ +

Subpixel antialiasing, font hinting, and text shaping add complexity to text rendering. Each glyph must be positioned with fractional pixel precision, and the rendering must account for kerning pairs, ligatures, and complex scripts like Arabic and Devanagari that require contextual glyph substitution.

+ +

JavaScript Engine Integration

+ +

The JavaScript engine is tightly integrated with the browser's rendering pipeline. Script execution can trigger style recalculation, layout, and paint through DOM manipulation and CSSOM access. The browser must balance responsive script execution with maintaining smooth rendering.

+ +

Modern engines use just-in-time (JIT) compilation to achieve near-native performance for hot code paths. The compilation pipeline typically includes an interpreter for initial execution, a baseline compiler for warm functions, and an optimizing compiler for hot functions. Deoptimization handles cases where optimistic assumptions are invalidated.

+ +

Web Workers provide true parallelism by running JavaScript in separate threads with their own heap and message-passing communication. SharedArrayBuffer enables shared memory between workers, but requires careful synchronization to avoid data races.

+ +

The event loop orchestrates the interleaving of script execution, rendering, and I/O callbacks. Microtasks (promises, mutation observers) are processed between macrotasks, and rendering updates are synchronized with the display's refresh rate through requestAnimationFrame.

+ +

Conclusion

+ +

Browser engines represent decades of engineering effort to make the web fast, secure, and compatible. Understanding their architecture helps web developers write code that works with the browser rather than against it, leading to better performance and user experience.

+ +

As the web platform continues to evolve with new APIs, layout modes, and rendering capabilities, browser engines must adapt while maintaining backwards compatibility with billions of existing web pages. This tension between innovation and compatibility remains one of the greatest challenges in software engineering.

+ +
+

Comments (50)

+ +
+
+ + +
+ + + diff --git a/test/benchmarks/pages/dashboard.html b/test/benchmarks/pages/dashboard.html new file mode 100644 index 0000000..a6943fe --- /dev/null +++ b/test/benchmarks/pages/dashboard.html @@ -0,0 +1,248 @@ + + + + + +Operations Dashboard + + + + +
+

Operations Dashboard

+
+ + + +
+
+ +
+ +
+
+

Request Volume

+
+
Hourly
+
Daily
+
Weekly
+
+
+
+
+ +
+
+

Top Endpoints

+ + + +
EndpointRequestsAvg LatencyError Rate
+
+
+

Active Alerts

+ + + +
AlertSeverityServiceSince
+
+
+ +
+
+

Recent Logs

+
+
All
+
Errors
+
Warnings
+
+
+ + + +
TimestampLevelServiceMessageDuration
+ +
+ +
+

Service Status

+ + + +
ServiceStatusUptimeCPUMemoryRequests/minError RateLast Deploy
+
+ + + + + diff --git a/test/benchmarks/pages/ecommerce.html b/test/benchmarks/pages/ecommerce.html new file mode 100644 index 0000000..bf7cc4b --- /dev/null +++ b/test/benchmarks/pages/ecommerce.html @@ -0,0 +1,179 @@ + + + + + +TechStore - Electronics & Gadgets + + + + +
+Free shipping on orders over $99 +Customer Service: 1-800-TECH | Track Order | Help +
+ + + + + +
+

Spring Tech Sale

+

Up to 40% off on selected electronics. Limited time offer.

+ +
+ +
+
+

Flash Deals - Ends in 04:32:17

Extra 15% off with code SPRING15

+ +
+ +
Featured ProductsView All
+
+AllUnder $100 +$100 - $500$500+ +Top RatedNew +
+ + +
Best SellersView All
+
+ +
New ArrivalsView All
+
+ +
Customer Reviews
+
+
+ + + + + + + diff --git a/test/benchmarks/run.ts b/test/benchmarks/run.ts index 27481cd..9bc3b57 100644 --- a/test/benchmarks/run.ts +++ b/test/benchmarks/run.ts @@ -1,14 +1,128 @@ import { spawn, ChildProcess } from "child_process"; +import * as http from "http"; import * as net from "net"; import * as os from "os"; import * as path from "path"; import * as fs from "fs"; import { fileURLToPath } from "url"; import { scenarios, type BenchmarkCommand, type Scenario } from "./scenarios.js"; +import { engineScenarios } from "./engine-scenarios.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// --------------------------------------------------------------------------- +// Static file server for HTTP-served benchmarks +// --------------------------------------------------------------------------- + +const PAGES_DIR = path.join(__dirname, "pages"); + +const MIME_TYPES: Record = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", +}; + +function startFileServer(): Promise<{ server: http.Server; port: number }> { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost`); + let filePath = path.join(PAGES_DIR, url.pathname === "/" ? "article.html" : url.pathname); + + if (!filePath.startsWith(PAGES_DIR)) { + res.writeHead(403); + res.end(); + return; + } + + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, "index.html"); + } + + try { + const content = fs.readFileSync(filePath); + const ext = path.extname(filePath); + res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" }); + res.end(content); + } catch { + res.writeHead(404); + res.end("Not found"); + } + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Failed to get server address")); + return; + } + resolve({ server, port: addr.port }); + }); + + server.on("error", reject); + }); +} + +function stopFileServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +// --------------------------------------------------------------------------- +// Memory measurement via /proc or ps +// --------------------------------------------------------------------------- + +function getProcessMemoryKB(pid: number): number | null { + if (process.platform === "linux") { + try { + const status = fs.readFileSync(`/proc/${pid}/status`, "utf-8"); + const match = status.match(/VmRSS:\s+(\d+)\s+kB/); + if (match) return parseInt(match[1], 10); + } catch { /* */ } + } + + try { + const { execSync } = require("child_process"); + const output = execSync(`ps -o rss= -p ${pid}`, { encoding: "utf-8", timeout: 2000 }); + const kb = parseInt(output.trim(), 10); + if (!isNaN(kb)) return kb; + } catch { /* */ } + + return null; +} + +function sampleMemory(pids: number[], intervalMs: number): { stop: () => number } { + let peakKB = 0; + const timer = setInterval(() => { + for (const pid of pids) { + const kb = getProcessMemoryKB(pid); + if (kb && kb > peakKB) peakKB = kb; + } + }, intervalMs); + + return { + stop() { + clearInterval(timer); + for (const pid of pids) { + const kb = getProcessMemoryKB(pid); + if (kb && kb > peakKB) peakKB = kb; + } + return peakKB; + }, + }; +} + +function formatMemory(kb: number): string { + if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}GB`; + if (kb >= 1024) return `${(kb / 1024).toFixed(1)}MB`; + return `${kb}KB`; +} + // --------------------------------------------------------------------------- // Socket / daemon helpers // --------------------------------------------------------------------------- @@ -162,23 +276,29 @@ function spawnNodeDaemon(session: string): DaemonHandle { return { session, process: child }; } -function spawnNativeDaemon(session: string): DaemonHandle { +function spawnNativeDaemon(session: string, engine?: string): DaemonHandle { const binaryPath = getNativeBinaryPath(); + const env: Record = { + ...process.env as Record, + AGENT_BROWSER_DAEMON: "1", + AGENT_BROWSER_SESSION: session, + }; + if (engine) { + env.AGENT_BROWSER_ENGINE = engine; + } + const child = spawn(binaryPath, [], { - env: { - ...process.env, - AGENT_BROWSER_DAEMON: "1", - AGENT_BROWSER_SESSION: session, - }, + env, stdio: ["ignore", "ignore", "pipe"], detached: true, }); + const label = engine ? `native-${engine}` : "native-daemon"; child.stderr?.on("data", (chunk) => { const msg = chunk.toString().trim(); if (msg && process.env.BENCH_DEBUG) { - process.stderr.write(`[native-daemon] ${msg}\n`); + process.stderr.write(`[${label}] ${msg}\n`); } }); @@ -200,7 +320,12 @@ async function closeDaemon(handle: DaemonHandle): Promise { } function cleanupSockets(): void { - for (const session of ["bench-node", "bench-native"]) { + for (const session of [ + "bench-node", + "bench-native", + "bench-chrome", + "bench-lightpanda", + ]) { const sockPath = getSocketPath(session); const pidPath = sockPath.replace(/\.sock$/, ".pid"); try { @@ -272,6 +397,8 @@ interface ScenarioResult { name: string; nodeStats: Stats | null; nativeStats: Stats | null; + chromeStats: Stats | null; + lightpandaStats: Stats | null; } async function runScenario( @@ -284,6 +411,8 @@ async function runScenario( name: scenario.name, nodeStats: null, nativeStats: null, + chromeStats: null, + lightpandaStats: null, }; for (const [label, session] of Object.entries(sessions)) { @@ -308,7 +437,60 @@ async function runScenario( const stats = computeStats(timings); if (label === "node") result.nodeStats = stats; - else result.nativeStats = stats; + else if (label === "native") result.nativeStats = stats; + else if (label === "chrome") result.chromeStats = stats; + else if (label === "lightpanda") result.lightpandaStats = stats; + } + + return result; +} + +async function runScenarioWithErrorTolerance( + scenario: Scenario, + sessions: Record, + iterations: number, + warmup: number, +): Promise { + const result: ScenarioResult = { + name: scenario.name, + nodeStats: null, + nativeStats: null, + chromeStats: null, + lightpandaStats: null, + }; + + for (const [label, session] of Object.entries(sessions)) { + if (!session) continue; + + try { + if (scenario.setup) { + await runCommands(session, scenario.setup); + } + + for (let i = 0; i < warmup; i++) { + await timeCommands(session, scenario.commands); + } + + const timings: number[] = []; + for (let i = 0; i < iterations; i++) { + timings.push(await timeCommands(session, scenario.commands)); + } + + if (scenario.teardown) { + await runCommands(session, scenario.teardown); + } + + const stats = computeStats(timings); + if (label === "chrome") result.chromeStats = stats; + else if (label === "lightpanda") result.lightpandaStats = stats; + else if (label === "node") result.nodeStats = stats; + else if (label === "native") result.nativeStats = stats; + } catch (err) { + if (process.env.BENCH_DEBUG) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(` [${label}] scenario '${scenario.name}' failed: ${msg}\n`); + } + } } return result; @@ -326,17 +508,30 @@ function rpad(s: string, len: number): string { return s.padStart(len); } -function formatSpeedup(nodeUs: number, nativeUs: number): string { - if (nativeUs === 0 && nodeUs === 0) return " --"; - if (nativeUs === 0) return " >>>"; - const ratio = nodeUs / nativeUs; +function formatSpeedup(baselineUs: number, candidateUs: number): string { + if (candidateUs === 0 && baselineUs === 0) return " --"; + if (candidateUs === 0) return " >>>"; + const ratio = baselineUs / candidateUs; return `${ratio.toFixed(1)}x`; } -function printResults(results: ScenarioResult[], iterations: number, warmup: number): void { +type BenchmarkMode = "daemon" | "engine"; + +function printResults( + results: ScenarioResult[], + iterations: number, + warmup: number, + mode: BenchmarkMode = "daemon", +): void { + console.log(""); + + if (mode === "engine") { + printEngineResults(results, iterations, warmup); + return; + } + const bothPaths = results[0].nodeStats !== null && results[0].nativeStats !== null; - console.log(""); const header = bothPaths ? `agent-browser benchmark: node vs native (${iterations} iterations, ${warmup} warmup)` : `agent-browser benchmark (${iterations} iterations, ${warmup} warmup)`; @@ -423,33 +618,100 @@ function printResults(results: ScenarioResult[], iterations: number, warmup: num console.log(""); } -function writeJsonResults(results: ScenarioResult[], outputPath: string): void { +function printEngineResults( + results: ScenarioResult[], + iterations: number, + warmup: number, +): void { + const header = `agent-browser benchmark: chrome vs lightpanda (${iterations} iterations, ${warmup} warmup)`; + console.log(header); + console.log("=".repeat(header.length)); + console.log(""); + + const nameW = 22; + const colW = 18; + + console.log( + pad("Scenario", nameW) + + rpad("Chrome (avg)", colW) + + rpad("Lightpanda (avg)", colW) + + rpad("Speedup", 10), + ); + console.log("-".repeat(nameW + colW * 2 + 10)); + + let totalChromeUs = 0; + let totalLightpandaUs = 0; + let comparableCount = 0; + + for (const r of results) { + const chromeAvg = r.chromeStats ? formatDuration(r.chromeStats.avgUs) : "N/A"; + const lpAvg = r.lightpandaStats ? formatDuration(r.lightpandaStats.avgUs) : "N/A"; + let speedup = " --"; + + if (r.chromeStats && r.lightpandaStats) { + totalChromeUs += r.chromeStats.avgUs; + totalLightpandaUs += r.lightpandaStats.avgUs; + comparableCount++; + speedup = formatSpeedup(r.chromeStats.avgUs, r.lightpandaStats.avgUs); + } + + console.log( + pad(r.name, nameW) + + rpad(chromeAvg, colW) + + rpad(lpAvg, colW) + + rpad(speedup, 10), + ); + } + + console.log("-".repeat(nameW + colW * 2 + 10)); + + if (comparableCount > 0 && totalLightpandaUs > 0) { + const ratio = totalChromeUs / totalLightpandaUs; + const winner = ratio >= 1.0 + ? `lightpanda ${ratio.toFixed(1)}x faster` + : `chrome ${(1 / ratio).toFixed(1)}x faster`; + console.log(`Overall: ${winner}`); + } + + console.log(""); +} + +function writeJsonResults( + results: ScenarioResult[], + outputPath: string, + mode: BenchmarkMode = "daemon", +): void { const toMs = (us: number) => +(us / 1000).toFixed(2); - const json = results.map((r) => ({ - scenario: r.name, - node: r.nodeStats - ? { - avg_ms: toMs(r.nodeStats.avgUs), - min_ms: toMs(r.nodeStats.minUs), - max_ms: toMs(r.nodeStats.maxUs), - p50_ms: toMs(r.nodeStats.p50Us), - p95_ms: toMs(r.nodeStats.p95Us), - } - : null, - native: r.nativeStats - ? { - avg_ms: toMs(r.nativeStats.avgUs), - min_ms: toMs(r.nativeStats.minUs), - max_ms: toMs(r.nativeStats.maxUs), - p50_ms: toMs(r.nativeStats.p50Us), - p95_ms: toMs(r.nativeStats.p95Us), - } - : null, - speedup: - r.nodeStats && r.nativeStats && r.nativeStats.avgUs > 0 - ? +(r.nodeStats.avgUs / r.nativeStats.avgUs).toFixed(2) - : null, - })); + const statsToJson = (s: Stats) => ({ + avg_ms: toMs(s.avgUs), + min_ms: toMs(s.minUs), + max_ms: toMs(s.maxUs), + p50_ms: toMs(s.p50Us), + p95_ms: toMs(s.p95Us), + }); + + const json = results.map((r) => { + if (mode === "engine") { + return { + scenario: r.name, + chrome: r.chromeStats ? statsToJson(r.chromeStats) : null, + lightpanda: r.lightpandaStats ? statsToJson(r.lightpandaStats) : null, + speedup: + r.chromeStats && r.lightpandaStats && r.lightpandaStats.avgUs > 0 + ? +(r.chromeStats.avgUs / r.lightpandaStats.avgUs).toFixed(2) + : null, + }; + } + return { + scenario: r.name, + node: r.nodeStats ? statsToJson(r.nodeStats) : null, + native: r.nativeStats ? statsToJson(r.nativeStats) : null, + speedup: + r.nodeStats && r.nativeStats && r.nativeStats.avgUs > 0 + ? +(r.nodeStats.avgUs / r.nativeStats.avgUs).toFixed(2) + : null, + }; + }); fs.writeFileSync(outputPath, JSON.stringify(json, null, 2) + "\n"); console.log(`JSON results written to ${outputPath}`); } @@ -463,6 +725,7 @@ interface CliArgs { warmup: number; nodeOnly: boolean; nativeOnly: boolean; + engineMode: boolean; json: boolean; } @@ -473,6 +736,7 @@ function parseArgs(): CliArgs { warmup: 3, nodeOnly: false, nativeOnly: false, + engineMode: false, json: false, }; @@ -490,6 +754,9 @@ function parseArgs(): CliArgs { case "--native-only": result.nativeOnly = true; break; + case "--engine": + result.engineMode = true; + break; case "--json": result.json = true; break; @@ -506,13 +773,10 @@ function parseArgs(): CliArgs { // Main // --------------------------------------------------------------------------- -async function main(): Promise { - const args = parseArgs(); +async function runDaemonBenchmark(args: CliArgs): Promise { const runNode = !args.nativeOnly; const runNative = !args.nodeOnly; - cleanupSockets(); - console.log("Starting benchmark daemons..."); let nodeHandle: DaemonHandle | undefined; @@ -535,7 +799,6 @@ async function main(): Promise { if (runNode) sessions.node = "bench-node"; if (runNative) sessions.native = "bench-native"; - // Launch browsers on both daemons for (const session of Object.values(sessions)) { const resp = await sendCommand(session, { id: "launch", @@ -549,7 +812,6 @@ async function main(): Promise { console.log(" Browsers launched"); console.log(""); - // Run all scenarios const results: ScenarioResult[] = []; for (const scenario of scenarios) { process.stdout.write(` Running: ${scenario.name}...`); @@ -567,20 +829,22 @@ async function main(): Promise { } } - printResults(results, args.iterations, args.warmup); + printResults(results, args.iterations, args.warmup, "daemon"); if (args.json) { - writeJsonResults(results, path.join(getProjectRoot(), "test/benchmarks/results.json")); + writeJsonResults( + results, + path.join(getProjectRoot(), "test/benchmarks/results.json"), + "daemon", + ); } - // Close browsers for (const session of Object.values(sessions)) { await sendCommand(session, { id: "close", action: "close" }).catch(() => {}); } await sleep(300); - // CI gate: exit 1 if native is slower overall (total avg across all scenarios) if (runNode && runNative) { let totalNodeUs = 0; let totalNativeUs = 0; @@ -597,6 +861,207 @@ async function main(): Promise { } finally { if (nodeHandle) await closeDaemon(nodeHandle); if (nativeHandle) await closeDaemon(nativeHandle); + } +} + +function buildHttpScenarios(baseUrl: string): Scenario[] { + const pages = ["article.html", "dashboard.html", "ecommerce.html"]; + const httpScenarios: Scenario[] = []; + + for (const page of pages) { + const label = page.replace(".html", ""); + httpScenarios.push({ + name: `http-${label}`, + description: `Navigate to ${label} page over HTTP (full fetch + parse + layout)`, + commands: [ + { id: "nav", action: "navigate", url: `${baseUrl}/${page}`, waitUntil: "load" }, + ], + }); + } + + httpScenarios.push({ + name: "http-nav+snap", + description: "Navigate to article over HTTP then snapshot", + commands: [ + { id: "nav", action: "navigate", url: `${baseUrl}/article.html`, waitUntil: "load" }, + { id: "snap", action: "snapshot" }, + ], + }); + + // Multi-page throughput: cycle through all pages N times + const multiPageCmds: BenchmarkCommand[] = []; + for (let round = 0; round < 5; round++) { + for (const page of pages) { + multiPageCmds.push({ + id: `nav-${round}-${page}`, + action: "navigate", + url: `${baseUrl}/${page}`, + waitUntil: "load", + }); + } + } + httpScenarios.push({ + name: "http-multi-15pg", + description: "Navigate 15 pages in sequence (5 rounds x 3 pages)", + commands: multiPageCmds, + }); + + // Bulk navigation: 50 page loads of the article (closest to Lightpanda's 100-page benchmark) + const bulkCmds: BenchmarkCommand[] = []; + for (let i = 0; i < 50; i++) { + bulkCmds.push({ + id: `bulk-${i}`, + action: "navigate", + url: `${baseUrl}/${pages[i % pages.length]}`, + waitUntil: "load", + }); + } + httpScenarios.push({ + name: "http-bulk-50pg", + description: "Navigate 50 pages sequentially (throughput test)", + commands: bulkCmds, + }); + + return httpScenarios; +} + +async function runEngineBenchmark(args: CliArgs): Promise { + console.log("Starting local file server..."); + const { server, port } = await startFileServer(); + const baseUrl = `http://127.0.0.1:${port}`; + console.log(` Serving pages at ${baseUrl}`); + + console.log("Starting engine benchmark daemons..."); + + let chromeHandle: DaemonHandle | undefined; + let lightpandaHandle: DaemonHandle | undefined; + + try { + chromeHandle = spawnNativeDaemon("bench-chrome", "chrome"); + await waitForSocket("bench-chrome"); + console.log(" Chrome daemon ready"); + + lightpandaHandle = spawnNativeDaemon("bench-lightpanda", "lightpanda"); + await waitForSocket("bench-lightpanda"); + console.log(" Lightpanda daemon ready"); + + const sessions: Record = { + chrome: "bench-chrome", + lightpanda: "bench-lightpanda", + }; + + for (const [label, session] of Object.entries(sessions)) { + const resp = await sendCommand(session, { + id: "launch", + action: "launch", + headless: true, + }); + if (!(resp as { success?: boolean }).success) { + throw new Error( + `Failed to launch ${label} browser on ${session}: ${JSON.stringify(resp)}`, + ); + } + } + console.log(" Browsers launched"); + + // Collect PIDs for memory sampling + const chromePid = chromeHandle.process.pid; + const lpPid = lightpandaHandle.process.pid; + const pidsToSample: number[] = []; + if (chromePid) pidsToSample.push(chromePid); + if (lpPid) pidsToSample.push(lpPid); + + const memSampler = pidsToSample.length > 0 + ? sampleMemory(pidsToSample, 500) + : null; + + // Measure per-engine peak memory during the heavy scenarios + const chromeMemPids = chromePid ? [chromePid] : []; + const lpMemPids = lpPid ? [lpPid] : []; + + console.log(""); + + const httpScenarios = buildHttpScenarios(baseUrl); + const allScenarios = [...scenarios, ...engineScenarios, ...httpScenarios]; + const results: ScenarioResult[] = []; + for (const scenario of allScenarios) { + process.stdout.write(` Running: ${scenario.name}...`); + const result = await runScenarioWithErrorTolerance( + scenario, + sessions, + args.iterations, + args.warmup, + ); + results.push(result); + + const chromeAvg = result.chromeStats + ? formatDuration(result.chromeStats.avgUs) + : "N/A"; + const lpAvg = result.lightpandaStats + ? formatDuration(result.lightpandaStats.avgUs) + : "N/A"; + + if (result.chromeStats && result.lightpandaStats) { + const speedup = formatSpeedup( + result.chromeStats.avgUs, + result.lightpandaStats.avgUs, + ); + process.stdout.write(` chrome=${chromeAvg} lightpanda=${lpAvg} (${speedup})\n`); + } else { + process.stdout.write(` chrome=${chromeAvg} lightpanda=${lpAvg}\n`); + } + } + + // Final memory snapshot + const chromeMemKB = chromeMemPids.length > 0 ? getProcessMemoryKB(chromeMemPids[0]) : null; + const lpMemKB = lpMemPids.length > 0 ? getProcessMemoryKB(lpMemPids[0]) : null; + if (memSampler) memSampler.stop(); + + printResults(results, args.iterations, args.warmup, "engine"); + + if (chromeMemKB || lpMemKB) { + console.log("Memory (daemon RSS after benchmarks):"); + if (chromeMemKB) console.log(` Chrome daemon: ${formatMemory(chromeMemKB)}`); + if (lpMemKB) console.log(` Lightpanda daemon: ${formatMemory(lpMemKB)}`); + if (chromeMemKB && lpMemKB && lpMemKB > 0) { + const memRatio = chromeMemKB / lpMemKB; + console.log(` Ratio: chrome uses ${memRatio.toFixed(1)}x more memory`); + } + console.log(""); + } + + if (args.json) { + writeJsonResults( + results, + path.join(getProjectRoot(), "test/benchmarks/results-engine.json"), + "engine", + ); + } + + for (const session of Object.values(sessions)) { + await sendCommand(session, { id: "close", action: "close" }).catch(() => {}); + } + + await sleep(300); + } finally { + if (chromeHandle) await closeDaemon(chromeHandle); + if (lightpandaHandle) await closeDaemon(lightpandaHandle); + await stopFileServer(server); + } +} + +async function main(): Promise { + const args = parseArgs(); + + cleanupSockets(); + + try { + if (args.engineMode) { + await runEngineBenchmark(args); + } else { + await runDaemonBenchmark(args); + } + } finally { cleanupSockets(); } }