lightpanda (#646)
* lightpanda * lightpanda benchmarks * improvements * fixes * improvements
This commit is contained in:
@@ -495,6 +495,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|
||||
| `--action-policy <path>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
|
||||
| `--confirm-actions <list>` | 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 <name>` | 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 <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
|
||||
| `--debug` | Debug output |
|
||||
|
||||
@@ -2118,6 +2118,7 @@ mod tests {
|
||||
confirm_actions: None,
|
||||
confirm_interactive: false,
|
||||
native: false,
|
||||
engine: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DaemonResult, String> {
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct Config {
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: Option<bool>,
|
||||
pub native: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<String>> {
|
||||
"--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<String>,
|
||||
pub confirm_interactive: bool,
|
||||
pub native: bool,
|
||||
pub engine: Option<String>,
|
||||
|
||||
// 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<String> {
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--config",
|
||||
"--engine",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
|
||||
+12
-2
@@ -272,9 +272,13 @@ fn main() {
|
||||
}
|
||||
|
||||
let args: Vec<String> = 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)
|
||||
|
||||
@@ -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<Value, St
|
||||
}
|
||||
}
|
||||
|
||||
let engine = cmd
|
||||
.get("engine")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
|
||||
|
||||
let options = LaunchOptions {
|
||||
headless,
|
||||
executable_path: cmd
|
||||
@@ -1000,7 +1007,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.domain_filter = Some(DomainFilter::new(domains));
|
||||
}
|
||||
|
||||
state.browser = Some(BrowserManager::launch(options).await?);
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
|
||||
if let Some(ref filter) = state.domain_filter {
|
||||
|
||||
+94
-19
@@ -7,6 +7,7 @@ use super::cdp::chrome::{
|
||||
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
|
||||
};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
||||
use super::cdp::types::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +56,34 @@ pub fn validate_launch_options(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates that Chrome-only options are not used with Lightpanda.
|
||||
fn validate_lightpanda_options(options: &LaunchOptions) -> 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<ChromeProcess>,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
pages: Vec<PageInfo>,
|
||||
active_page_index: usize,
|
||||
default_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl BrowserManager {
|
||||
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
|
||||
validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?;
|
||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||
let engine = engine.unwrap_or("chrome");
|
||||
|
||||
match engine {
|
||||
"chrome" => {
|
||||
validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?;
|
||||
}
|
||||
"lightpanda" => {
|
||||
validate_lightpanda_options(&options)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
|
||||
engine
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ignore_https_errors = options.ignore_https_errors;
|
||||
let user_agent = options.user_agent.clone();
|
||||
let color_scheme = options.color_scheme.clone();
|
||||
let download_path = options.download_path.clone();
|
||||
|
||||
let chrome = launch_chrome(&options)?;
|
||||
let ws_url = chrome.ws_url.clone();
|
||||
let (ws_url, process) = match engine {
|
||||
"lightpanda" => {
|
||||
let lp_options = LightpandaLaunchOptions {
|
||||
executable_path: options.executable_path.clone(),
|
||||
proxy: options.proxy.clone(),
|
||||
port: None,
|
||||
};
|
||||
let 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
|
||||
|
||||
@@ -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<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Default for LightpandaLaunchOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
executable_path: None,
|
||||
proxy: None,
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
// 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<LightpandaProcess, String> {
|
||||
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<std::process::ChildStderr>,
|
||||
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Timeout waiting for Lightpanda server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) => {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Lightpanda exited before providing server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = buf.trim_end().to_string();
|
||||
if let Some(address) = extract_address(&line) {
|
||||
return Ok((address, reader));
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to read Lightpanda stderr: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_address(line: &str) -> Option<String> {
|
||||
// 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::<Vec<_>>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
|
||||
@@ -2448,6 +2448,7 @@ Options:
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> 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 <name> 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 <path> 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):
|
||||
|
||||
@@ -80,6 +80,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
|
||||
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
|
||||
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>engine</code></td><td><code>--engine</code></td><td>string (<code>chrome</code>, <code>lightpanda</code>)</td></tr>
|
||||
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
|
||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
</tbody>
|
||||
@@ -187,6 +188,7 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>. Implies <code>--native</code>.</td><td><code>chrome</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>Locations checked</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td>
|
||||
<code>/Applications/Google Chrome.app</code>,
|
||||
<code>/Applications/Google Chrome Canary.app</code>,
|
||||
<code>/Applications/Chromium.app</code>,
|
||||
Playwright Chromium cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td>
|
||||
<code>google-chrome</code>,
|
||||
<code>google-chrome-stable</code>,
|
||||
<code>chromium-browser</code>,
|
||||
<code>chromium</code> in PATH,
|
||||
Playwright Chromium cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td>
|
||||
<code>%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe</code>,
|
||||
<code>C:\Program Files\Google\Chrome\Application\chrome.exe</code>,
|
||||
<code>C:\Program Files (x86)\...\chrome.exe</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Flag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Browser extensions</td><td><code>--extension <path></code></td></tr>
|
||||
<tr><td>Persistent profiles</td><td><code>--profile <path></code></td></tr>
|
||||
<tr><td>Storage state</td><td><code>--state <path></code></td></tr>
|
||||
<tr><td>File URL access</td><td><code>--allow-file-access</code></td></tr>
|
||||
<tr><td>Headed mode</td><td><code>--headed</code></td></tr>
|
||||
<tr><td>Custom launch args</td><td><code>--args <args></code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## 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).
|
||||
@@ -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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>Command</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>macOS (Apple Silicon)</td>
|
||||
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && chmod a+x ./lightpanda</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux (x86_64)</td>
|
||||
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && chmod a+x ./lightpanda</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Extensions (<code>--extension</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Persistent profiles (<code>--profile</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Storage state (<code>--state</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>File access (<code>--allow-file-access</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Headed mode (<code>--headed</code>)</td><td>Not applicable (headless only)</td></tr>
|
||||
<tr><td>Screenshots</td><td>Depends on Lightpanda CDP support</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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.
|
||||
@@ -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" }],
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
profiler: "Profiler",
|
||||
ios: "iOS Simulator",
|
||||
security: "Security",
|
||||
"engines/chrome": "Chrome",
|
||||
"engines/lightpanda": "Lightpanda",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -605,6 +605,9 @@ async function handleLaunch(
|
||||
command: Command & { action: 'launch' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
if (command.engine === 'lightpanda') {
|
||||
return errorResponse(command.id, 'Lightpanda engine requires --native mode');
|
||||
}
|
||||
await browser.launch(command);
|
||||
return successResponse(command.id, { launched: true });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `<p class="article-p">${words}</p>`;
|
||||
});
|
||||
|
||||
const comments = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) =>
|
||||
`<div class="comment" data-id="${i}">` +
|
||||
`<div class="comment-header"><span class="author">User ${i}</span><time>2025-01-${String(i % 28 + 1).padStart(2, "0")}</time></div>` +
|
||||
`<div class="comment-body"><p>This is comment number ${i + 1} with some discussion text.</p></div>` +
|
||||
`<div class="comment-actions"><button class="reply-btn">Reply</button><button class="like-btn">Like</button></div>` +
|
||||
`</div>`,
|
||||
);
|
||||
|
||||
const sidebar = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) =>
|
||||
`<li class="sidebar-item"><a href="#section-${i}">Related Article ${i + 1}: A Longer Title Here</a></li>`,
|
||||
);
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Article</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;padding:0;display:grid;grid-template-columns:1fr 300px;gap:20px;max-width:1200px;margin:0 auto}",
|
||||
".article{padding:20px}.sidebar{padding:20px;border-left:1px solid #ddd}",
|
||||
".comment{border:1px solid #eee;padding:12px;margin:8px 0;border-radius:4px}",
|
||||
".comment-header{display:flex;justify-content:space-between;font-size:14px;color:#666}",
|
||||
".nav{display:flex;gap:16px;padding:12px 20px;background:#f5f5f5;grid-column:1/-1}",
|
||||
".tag{display:inline-block;padding:2px 8px;background:#e0e7ff;border-radius:12px;font-size:12px;margin:2px}",
|
||||
"</style></head><body>",
|
||||
`<nav class="nav">${Array.from({ length: 8 }, (_, i) => `<a href="#nav-${i}">Section ${i + 1}</a>`).join("")}</nav>`,
|
||||
'<div class="article">',
|
||||
"<h1>Understanding Modern Browser Engine Architecture</h1>",
|
||||
'<div class="meta"><span class="author">Dr. Smith</span> | <time>2025-03-15</time> | <span>15 min read</span></div>',
|
||||
`<div class="tags">${Array.from({ length: 6 }, (_, i) => `<span class="tag">tag-${i + 1}</span>`).join("")}</div>`,
|
||||
"<h2>Introduction</h2>",
|
||||
...paragraphs.slice(0, 5),
|
||||
"<h2>Core Concepts</h2>",
|
||||
...paragraphs.slice(5, 12),
|
||||
'<blockquote>"Performance is not just about speed, it is about efficiency." - Anonymous</blockquote>',
|
||||
"<h2>Implementation Details</h2>",
|
||||
...paragraphs.slice(12, 20),
|
||||
"<h3>Subsection A</h3>",
|
||||
...paragraphs.slice(20, 25),
|
||||
"<h3>Subsection B</h3>",
|
||||
...paragraphs.slice(25),
|
||||
"<h2>Comments</h2>",
|
||||
'<div class="comments">',
|
||||
...comments,
|
||||
"</div></div>",
|
||||
'<div class="sidebar">',
|
||||
"<h3>Related Articles</h3>",
|
||||
`<ul>${sidebar.join("")}</ul>`,
|
||||
"<h3>Archives</h3>",
|
||||
`<ul>${Array.from({ length: 12 }, (_, i) => `<li><a href="#month-${i}">Month ${i + 1}, 2025</a></li>`).join("")}</ul>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDataTablePage(): string {
|
||||
const headerCells = [
|
||||
"ID", "Name", "Email", "Department", "Role", "Status", "Joined", "Last Active",
|
||||
];
|
||||
const header = `<tr>${headerCells.map((h) => `<th>${h}</th>`).join("")}</tr>`;
|
||||
|
||||
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 (
|
||||
`<tr data-row="${i}">` +
|
||||
`<td>${i + 1}</td>` +
|
||||
`<td><a href="#user-${i}">User ${i + 1}</a></td>` +
|
||||
`<td>user${i + 1}@example.com</td>` +
|
||||
`<td>${dept}</td>` +
|
||||
`<td><span class="badge badge-${role.toLowerCase()}">${role}</span></td>` +
|
||||
`<td><span class="status status-${status.toLowerCase()}">${status}</span></td>` +
|
||||
`<td>2024-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}</td>` +
|
||||
`<td>${i % 3 === 0 ? "Today" : i % 3 === 1 ? "Yesterday" : "Last week"}</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Table</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
"table{width:100%;border-collapse:collapse}",
|
||||
"th,td{padding:8px 12px;border:1px solid #ddd;text-align:left}",
|
||||
"th{background:#f5f5f5;font-weight:600;position:sticky;top:0}",
|
||||
"tr:nth-child(even){background:#fafafa}",
|
||||
".badge{padding:2px 8px;border-radius:4px;font-size:12px}",
|
||||
".toolbar{display:flex;gap:12px;margin-bottom:16px;align-items:center}",
|
||||
"input,select,button{padding:6px 12px;border:1px solid #ccc;border-radius:4px}",
|
||||
"</style></head><body>",
|
||||
"<h1>User Management Dashboard</h1>",
|
||||
'<div class="toolbar">',
|
||||
'<input id="search" type="text" placeholder="Search users...">',
|
||||
'<select id="dept-filter"><option value="">All Departments</option><option value="eng">Engineering</option><option value="des">Design</option></select>',
|
||||
'<select id="status-filter"><option value="">All Statuses</option><option value="active">Active</option><option value="inactive">Inactive</option></select>',
|
||||
'<button id="add-user">Add User</button>',
|
||||
'<span id="count">Showing 200 users</span>',
|
||||
"</div>",
|
||||
`<table><thead>${header}</thead><tbody>${rows.join("")}</tbody></table>`,
|
||||
'<div class="pagination">',
|
||||
...Array.from({ length: 10 }, (_, i) => `<button class="page-btn" data-page="${i + 1}">${i + 1}</button>`),
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateNestedPage(): string {
|
||||
function nest(depth: number, breadth: number, prefix: string): string {
|
||||
if (depth === 0) {
|
||||
return `<span class="leaf" data-path="${prefix}">Leaf node at ${prefix}</span>`;
|
||||
}
|
||||
const children = Array.from(
|
||||
{ length: breadth },
|
||||
(_, i) =>
|
||||
`<div class="node depth-${depth}" data-depth="${depth}" data-idx="${i}">` +
|
||||
`<div class="node-header"><strong>Section ${prefix}.${i + 1}</strong> <em>(depth ${depth})</em></div>` +
|
||||
`<div class="node-content">${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${i + 1}`)}</div>` +
|
||||
`</div>`,
|
||||
);
|
||||
return children.join("");
|
||||
}
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Nested</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
".node{border-left:2px solid #ddd;padding-left:16px;margin:4px 0}",
|
||||
".node-header{padding:4px 0;cursor:pointer}",
|
||||
".leaf{display:block;padding:2px 8px;background:#f0f9ff;margin:2px 0;border-radius:2px}",
|
||||
"</style></head><body>",
|
||||
"<h1>Deeply Nested Document Structure</h1>",
|
||||
nest(7, 3, "root"),
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDashboardPage(): string {
|
||||
const cards = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) =>
|
||||
`<div class="card" data-card="${i}">` +
|
||||
`<div class="card-title">Metric ${i + 1}</div>` +
|
||||
`<div class="card-value">${Math.floor(Math.random() * 10000)}</div>` +
|
||||
`<div class="card-trend ${i % 2 === 0 ? "up" : "down"}">${i % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%</div>` +
|
||||
`</div>`,
|
||||
);
|
||||
|
||||
const chartBars = Array.from(
|
||||
{ length: 24 },
|
||||
(_, i) => {
|
||||
const h = 20 + (i * 7 + 13) % 80;
|
||||
return `<div class="bar" style="height:${h}%" data-hour="${i}"><span class="bar-label">${String(i).padStart(2, "0")}:00</span></div>`;
|
||||
},
|
||||
);
|
||||
|
||||
const logRows = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => {
|
||||
const level = ["INFO", "WARN", "ERROR", "DEBUG"][i % 4];
|
||||
return (
|
||||
`<tr class="log-${level.toLowerCase()}" data-log="${i}">` +
|
||||
`<td>${new Date(2025, 0, 1, i % 24, i % 60).toISOString()}</td>` +
|
||||
`<td><span class="level level-${level.toLowerCase()}">${level}</span></td>` +
|
||||
`<td>Service ${["auth", "api", "worker", "cache", "db"][i % 5]}</td>` +
|
||||
`<td>Log message number ${i + 1}: operation completed in ${(Math.random() * 1000).toFixed(0)}ms</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Dashboard</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;background:#f5f5f5}",
|
||||
".header{background:#1a1a2e;color:white;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}",
|
||||
".grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;padding:24px}",
|
||||
".card{background:white;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".card-value{font-size:28px;font-weight:700;margin:8px 0}",
|
||||
".card-trend.up{color:#16a34a}.card-trend.down{color:#dc2626}",
|
||||
".chart-area{background:white;margin:0 24px;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".bars{display:flex;align-items:flex-end;gap:4px;height:200px}",
|
||||
".bar{background:#3b82f6;flex:1;border-radius:2px 2px 0 0;position:relative;min-width:8px}",
|
||||
".log-table{margin:24px;background:white;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);overflow:hidden}",
|
||||
"table{width:100%;border-collapse:collapse;font-size:13px}",
|
||||
"th,td{padding:6px 12px;border-bottom:1px solid #eee;text-align:left}",
|
||||
"th{background:#f9fafb;font-weight:600}",
|
||||
".tabs{display:flex;gap:0;margin:24px 24px 0}",
|
||||
".tab{padding:8px 20px;background:#e5e7eb;cursor:pointer;border-radius:6px 6px 0 0}",
|
||||
".tab.active{background:white}",
|
||||
"</style></head><body>",
|
||||
'<div class="header"><h1>Operations Dashboard</h1><div><input id="dash-search" placeholder="Search..." type="text"><button id="refresh">Refresh</button></div></div>',
|
||||
`<div class="grid">${cards.join("")}</div>`,
|
||||
'<div class="tabs"><div class="tab active">Hourly</div><div class="tab">Daily</div><div class="tab">Weekly</div></div>',
|
||||
`<div class="chart-area"><h3>Request Volume</h3><div class="bars">${chartBars.join("")}</div></div>`,
|
||||
'<div class="log-table">',
|
||||
"<h3 style='padding:16px 12px 0'>Recent Logs</h3>",
|
||||
`<table><thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th></tr></thead><tbody>${logRows.join("")}</tbody></table>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].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';})()",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Understanding Modern Browser Engine Architecture</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a2e; background: #fff; }
|
||||
.nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; background: #1a1a2e; color: #fff; position: sticky; top: 0; z-index: 100; }
|
||||
.nav a { color: #94a3b8; text-decoration: none; font-size: 14px; transition: color 0.2s; }
|
||||
.nav a:hover { color: #fff; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 40px; max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
|
||||
.article { min-width: 0; }
|
||||
.article h1 { font-size: 2.2rem; line-height: 1.2; margin-bottom: 16px; }
|
||||
.meta { display: flex; gap: 16px; color: #64748b; font-size: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e2e8f0; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 32px; }
|
||||
.tag { display: inline-block; padding: 4px 12px; background: #e0e7ff; color: #3730a3; border-radius: 16px; font-size: 12px; font-weight: 500; }
|
||||
.article h2 { font-size: 1.5rem; margin: 32px 0 16px; padding-top: 24px; border-top: 1px solid #f1f5f9; }
|
||||
.article h3 { font-size: 1.2rem; margin: 24px 0 12px; }
|
||||
.article p { margin-bottom: 16px; color: #374151; }
|
||||
.article blockquote { margin: 24px 0; padding: 16px 24px; border-left: 4px solid #6366f1; background: #f8fafc; font-style: italic; color: #475569; border-radius: 0 8px 8px 0; }
|
||||
.article pre { background: #1e293b; color: #e2e8f0; padding: 16px 20px; border-radius: 8px; overflow-x: auto; margin: 16px 0; font-size: 14px; line-height: 1.5; }
|
||||
.article code { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
.article img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
|
||||
.figure { margin: 24px 0; text-align: center; }
|
||||
.figure figcaption { font-size: 13px; color: #64748b; margin-top: 8px; }
|
||||
.comments { margin-top: 40px; }
|
||||
.comments h2 { border-top: 2px solid #e2e8f0; }
|
||||
.comment { padding: 16px; margin: 12px 0; border: 1px solid #e2e8f0; border-radius: 8px; transition: box-shadow 0.2s; }
|
||||
.comment:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.comment-author { font-weight: 600; font-size: 14px; }
|
||||
.comment-date { font-size: 12px; color: #94a3b8; }
|
||||
.comment-body { font-size: 14px; color: #475569; }
|
||||
.comment-actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||
.comment-actions button { background: none; border: none; color: #6366f1; font-size: 13px; cursor: pointer; padding: 2px 0; }
|
||||
.sidebar { position: sticky; top: 72px; align-self: start; }
|
||||
.sidebar section { margin-bottom: 32px; }
|
||||
.sidebar h3 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 12px; }
|
||||
.sidebar ul { list-style: none; }
|
||||
.sidebar li { margin-bottom: 8px; }
|
||||
.sidebar a { color: #3730a3; text-decoration: none; font-size: 14px; }
|
||||
.sidebar a:hover { text-decoration: underline; }
|
||||
.toc a { display: block; padding: 4px 0; border-left: 2px solid transparent; padding-left: 12px; }
|
||||
.toc a:hover { border-left-color: #6366f1; }
|
||||
.sidebar .widget { background: #f8fafc; padding: 16px; border-radius: 8px; }
|
||||
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<strong style="font-size:18px;color:#fff">TechBlog</strong>
|
||||
<a href="#">Home</a><a href="#">Articles</a><a href="#">Tutorials</a><a href="#">About</a>
|
||||
<a href="#">Open Source</a><a href="#">Newsletter</a><a href="#">Contact</a>
|
||||
<div style="flex:1"></div>
|
||||
<a href="#">Sign In</a>
|
||||
</nav>
|
||||
|
||||
<div class="layout">
|
||||
<main class="article">
|
||||
<h1>Understanding Modern Browser Engine Architecture</h1>
|
||||
<div class="meta">
|
||||
<span>By <strong>Dr. Alexandra Chen</strong></span>
|
||||
<span>March 15, 2025</span>
|
||||
<span>18 min read</span>
|
||||
<span>2,847 views</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="tag">Browser Engines</span><span class="tag">Performance</span>
|
||||
<span class="tag">Web Standards</span><span class="tag">Rendering</span>
|
||||
<span class="tag">Architecture</span><span class="tag">Open Source</span>
|
||||
</div>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>The Critical Rendering Path</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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 <code>display: none</code> are excluded, while pseudo-elements like <code>::before</code> and <code>::after</code> are added.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<blockquote>"The fastest code is code that doesn't run. The fastest layout is layout that doesn't need to happen." -- Chrome DevTools Team</blockquote>
|
||||
|
||||
<h2>DOM Construction and Tree Building</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Incremental DOM Updates</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Memory Management</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Style Resolution and Cascade</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<pre><code>/* 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;
|
||||
}</code></pre>
|
||||
|
||||
<h2>Layout Algorithms</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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 <code>break-before</code>, <code>break-after</code>, and <code>break-inside</code> properties.</p>
|
||||
|
||||
<h2>Paint and Compositing</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>JavaScript Engine Integration</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<div class="comments">
|
||||
<h2>Comments (50)</h2>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.querySelector('.comments');
|
||||
var names = ['Alex Morgan', 'Jamie Rivera', 'Sam Patel', 'Taylor Kim', 'Jordan Lee',
|
||||
'Casey Wu', 'Riley Chen', 'Morgan Davis', 'Avery Singh', 'Quinn Zhao'];
|
||||
for (var i = 0; i < 50; i++) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
div.dataset.id = i;
|
||||
var d = new Date(2025, 2, 15 - Math.floor(i / 5));
|
||||
div.innerHTML = '<div class="comment-header"><span class="comment-author">' + names[i % 10] +
|
||||
'</span><span class="comment-date">' + d.toLocaleDateString() + '</span></div>' +
|
||||
'<div class="comment-body"><p>' + (i % 3 === 0 ?
|
||||
'Great article! The section on compositing layers was particularly insightful. I have been struggling with janky scroll performance and this explains why promoting elements to their own layer helps.' :
|
||||
i % 3 === 1 ?
|
||||
'Thanks for the detailed breakdown. One thing I would add is that the style invalidation strategy varies significantly between engines. Blink uses a different approach from WebKit for descendant invalidation.' :
|
||||
'This is exactly the kind of deep dive I was looking for. The paint and compositing section cleared up several misconceptions I had about how GPU acceleration works in practice.') +
|
||||
'</p></div><div class="comment-actions"><button>Reply</button><button>Like (' + (Math.floor(Math.random() * 30)) + ')</button></div>';
|
||||
container.appendChild(div);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section>
|
||||
<h3>Table of Contents</h3>
|
||||
<ul class="toc">
|
||||
<li><a href="#crp">The Critical Rendering Path</a></li>
|
||||
<li><a href="#dom">DOM Construction and Tree Building</a></li>
|
||||
<li><a href="#styles">Style Resolution and Cascade</a></li>
|
||||
<li><a href="#layout">Layout Algorithms</a></li>
|
||||
<li><a href="#paint">Paint and Compositing</a></li>
|
||||
<li><a href="#js">JavaScript Engine Integration</a></li>
|
||||
<li><a href="#conclusion">Conclusion</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Related Articles</h3>
|
||||
<ul>
|
||||
<li><a href="#">How V8 Optimizes JavaScript Execution</a></li>
|
||||
<li><a href="#">CSS Grid Layout: A Complete Guide</a></li>
|
||||
<li><a href="#">Web Performance Metrics That Matter</a></li>
|
||||
<li><a href="#">Understanding the Event Loop</a></li>
|
||||
<li><a href="#">Debugging Layout Thrashing</a></li>
|
||||
<li><a href="#">Service Workers and Caching Strategies</a></li>
|
||||
<li><a href="#">WebAssembly: Beyond JavaScript</a></li>
|
||||
<li><a href="#">Rendering Performance Case Studies</a></li>
|
||||
<li><a href="#">Accessibility Tree Deep Dive</a></li>
|
||||
<li><a href="#">Cross-Browser Compatibility Patterns</a></li>
|
||||
<li><a href="#">Progressive Web Apps in 2025</a></li>
|
||||
<li><a href="#">HTTP/3 and QUIC Explained</a></li>
|
||||
<li><a href="#">Container Queries Guide</a></li>
|
||||
<li><a href="#">CSS Houdini: Low-Level APIs</a></li>
|
||||
<li><a href="#">Browser DevTools Advanced Tips</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Archives</h3>
|
||||
<ul>
|
||||
<li><a href="#">March 2025</a></li><li><a href="#">February 2025</a></li>
|
||||
<li><a href="#">January 2025</a></li><li><a href="#">December 2024</a></li>
|
||||
<li><a href="#">November 2024</a></li><li><a href="#">October 2024</a></li>
|
||||
<li><a href="#">September 2024</a></li><li><a href="#">August 2024</a></li>
|
||||
<li><a href="#">July 2024</a></li><li><a href="#">June 2024</a></li>
|
||||
<li><a href="#">May 2024</a></li><li><a href="#">April 2024</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="widget">
|
||||
<h3>Newsletter</h3>
|
||||
<p style="font-size:13px;color:#475569;margin-bottom:8px">Get weekly browser engineering insights</p>
|
||||
<input type="email" placeholder="you@example.com" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;margin-bottom:8px">
|
||||
<button style="width:100%;padding:8px;background:#6366f1;color:#fff;border:none;border-radius:4px;cursor:pointer">Subscribe</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Operations Dashboard</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f1f5f9; color: #0f172a; }
|
||||
.header { background: #0f172a; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 18px; }
|
||||
.header-actions { display: flex; gap: 12px; align-items: center; }
|
||||
.header input { padding: 6px 12px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 6px; font-size: 13px; width: 200px; }
|
||||
.header button { padding: 6px 16px; background: #6366f1; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 24px; }
|
||||
.card { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.card-label { font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-value { font-size: 32px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.card-trend { font-size: 14px; font-weight: 500; }
|
||||
.card-trend.up { color: #16a34a; }
|
||||
.card-trend.down { color: #dc2626; }
|
||||
.card-sparkline { height: 40px; display: flex; align-items: flex-end; gap: 2px; margin-top: 8px; }
|
||||
.card-sparkline .bar { flex: 1; background: #e0e7ff; border-radius: 2px; min-width: 3px; transition: background 0.2s; }
|
||||
.card-sparkline .bar:last-child { background: #6366f1; }
|
||||
.section { margin: 0 24px 24px; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
.section-header { padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-header h2 { font-size: 16px; }
|
||||
.tabs { display: flex; gap: 0; }
|
||||
.tab { padding: 6px 16px; font-size: 13px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; }
|
||||
.tab:first-child { border-radius: 6px 0 0 6px; }
|
||||
.tab:last-child { border-radius: 0 6px 6px 0; }
|
||||
.tab.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.chart { padding: 20px; height: 240px; display: flex; align-items: flex-end; gap: 4px; }
|
||||
.chart .bar { flex: 1; background: #6366f1; border-radius: 4px 4px 0 0; position: relative; min-width: 6px; transition: opacity 0.2s; }
|
||||
.chart .bar:hover { opacity: 0.8; }
|
||||
.chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #94a3b8; white-space: nowrap; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th { background: #f8fafc; padding: 10px 16px; text-align: left; font-weight: 600; color: #475569; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; }
|
||||
tbody td { padding: 8px 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-info { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-warn { background: #fef3c7; color: #b45309; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-debug { background: #f1f5f9; color: #475569; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f1f5f9; color: #64748b; }
|
||||
.badge-pending { background: #fef3c7; color: #b45309; }
|
||||
.pagination { display: flex; justify-content: center; gap: 4px; padding: 16px; }
|
||||
.pagination button { width: 32px; height: 32px; border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.pagination button.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 0 24px 24px; }
|
||||
@media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } .two-col { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Operations Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<input id="search" type="text" placeholder="Search...">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button style="background:#334155">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="metrics-grid"></div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Request Volume</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">Hourly</div>
|
||||
<div class="tab">Daily</div>
|
||||
<div class="tab">Weekly</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Top Endpoints</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Endpoint</th><th>Requests</th><th>Avg Latency</th><th>Error Rate</th></tr></thead>
|
||||
<tbody id="endpoints-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Active Alerts</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Alert</th><th>Severity</th><th>Service</th><th>Since</th></tr></thead>
|
||||
<tbody id="alerts-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Logs</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">All</div>
|
||||
<div class="tab">Errors</div>
|
||||
<div class="tab">Warnings</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th><th>Duration</th></tr></thead>
|
||||
<tbody id="logs-table"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h2>Service Status</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Uptime</th><th>CPU</th><th>Memory</th><th>Requests/min</th><th>Error Rate</th><th>Last Deploy</th></tr></thead>
|
||||
<tbody id="services-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Metric cards
|
||||
var metrics = [
|
||||
{ label: 'Total Requests', value: '1,284,392', trend: '+12.5%', up: true },
|
||||
{ label: 'Avg Response Time', value: '142ms', trend: '-8.3%', up: true },
|
||||
{ label: 'Error Rate', value: '0.42%', trend: '+0.12%', up: false },
|
||||
{ label: 'Active Users', value: '3,847', trend: '+5.1%', up: true },
|
||||
{ label: 'Throughput', value: '892/s', trend: '+3.7%', up: true },
|
||||
{ label: 'P99 Latency', value: '487ms', trend: '+15ms', up: false },
|
||||
{ label: 'CPU Usage', value: '67%', trend: '-2.4%', up: true },
|
||||
{ label: 'Memory Usage', value: '4.2GB', trend: '+180MB', up: false },
|
||||
{ label: 'Cache Hit Rate', value: '94.7%', trend: '+1.2%', up: true },
|
||||
{ label: 'Queue Depth', value: '234', trend: '-45', up: true },
|
||||
{ label: 'Open Connections', value: '12,483', trend: '+892', up: false },
|
||||
{ label: 'Deployments Today', value: '7', trend: '+2', up: true },
|
||||
];
|
||||
var grid = document.getElementById('metrics-grid');
|
||||
metrics.forEach(function(m) {
|
||||
var sparkBars = '';
|
||||
for (var s = 0; s < 12; s++) {
|
||||
var h = 20 + Math.floor(Math.random() * 80);
|
||||
sparkBars += '<div class="bar" style="height:' + h + '%"></div>';
|
||||
}
|
||||
grid.innerHTML += '<div class="card"><div class="card-label">' + m.label +
|
||||
'</div><div class="card-value">' + m.value +
|
||||
'</div><div class="card-trend ' + (m.up ? 'up' : 'down') + '">' +
|
||||
(m.up ? '+' : '') + m.trend +
|
||||
'</div><div class="card-sparkline">' + sparkBars + '</div></div>';
|
||||
});
|
||||
|
||||
// Chart bars
|
||||
var chart = document.getElementById('chart');
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var height = 15 + ((h * 17 + 7) % 85);
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
bar.style.height = height + '%';
|
||||
bar.innerHTML = '<span class="bar-label">' + String(h).padStart(2, '0') + ':00</span>';
|
||||
chart.appendChild(bar);
|
||||
}
|
||||
|
||||
// Endpoints table
|
||||
var endpoints = document.getElementById('endpoints-table');
|
||||
var paths = ['/api/users', '/api/auth', '/api/products', '/api/orders', '/api/search',
|
||||
'/api/analytics', '/api/notifications', '/api/payments', '/api/inventory', '/api/reports',
|
||||
'/api/settings', '/api/uploads', '/api/webhooks', '/api/health', '/api/metrics'];
|
||||
paths.forEach(function(p, i) {
|
||||
endpoints.innerHTML += '<tr><td><code>' + p + '</code></td><td>' +
|
||||
(50000 - i * 3000) + '</td><td>' + (45 + i * 12) + 'ms</td><td>' +
|
||||
(0.1 + i * 0.08).toFixed(2) + '%</td></tr>';
|
||||
});
|
||||
|
||||
// Alerts table
|
||||
var alerts = document.getElementById('alerts-table');
|
||||
var alertData = [
|
||||
['High error rate on /api/payments', 'error', 'payments'],
|
||||
['Memory usage above 85%', 'warn', 'api-gateway'],
|
||||
['Slow queries detected', 'warn', 'database'],
|
||||
['SSL certificate expiring in 7 days', 'info', 'infrastructure'],
|
||||
['Disk usage above 80%', 'warn', 'storage'],
|
||||
['Connection pool exhaustion', 'error', 'database'],
|
||||
['Rate limit threshold reached', 'warn', 'api-gateway'],
|
||||
['Deployment rollback detected', 'info', 'ci-cd'],
|
||||
];
|
||||
alertData.forEach(function(a, i) {
|
||||
var badge = a[1] === 'error' ? 'badge-error' : a[1] === 'warn' ? 'badge-warn' : 'badge-info';
|
||||
alerts.innerHTML += '<tr><td>' + a[0] + '</td><td><span class="badge ' + badge + '">' +
|
||||
a[1].toUpperCase() + '</span></td><td>' + a[2] + '</td><td>' + (i * 15 + 5) + 'm ago</td></tr>';
|
||||
});
|
||||
|
||||
// Logs table
|
||||
var logs = document.getElementById('logs-table');
|
||||
var services = ['auth', 'api-gateway', 'worker', 'cache', 'database', 'payments', 'search', 'notifications'];
|
||||
var levels = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
|
||||
var messages = [
|
||||
'Request processed successfully',
|
||||
'Connection pool running low',
|
||||
'Failed to connect to upstream service',
|
||||
'Cache miss for key session:',
|
||||
'Query execution exceeded threshold',
|
||||
'Payment webhook received',
|
||||
'Search index rebuild started',
|
||||
'Rate limit applied to client',
|
||||
'Health check passed',
|
||||
'Background job completed',
|
||||
];
|
||||
for (var i = 0; i < 200; i++) {
|
||||
var level = levels[i % 4];
|
||||
var badge = level === 'ERROR' ? 'badge-error' : level === 'WARN' ? 'badge-warn' :
|
||||
level === 'DEBUG' ? 'badge-debug' : 'badge-info';
|
||||
var ts = new Date(2025, 2, 15, 23 - Math.floor(i / 8), 59 - (i % 60));
|
||||
logs.innerHTML += '<tr><td style="white-space:nowrap">' + ts.toISOString().replace('T', ' ').substring(0, 19) +
|
||||
'</td><td><span class="badge ' + badge + '">' + level +
|
||||
'</span></td><td>' + services[i % 8] +
|
||||
'</td><td>' + messages[i % 10] + ' #' + (i + 1) +
|
||||
'</td><td>' + (Math.floor(Math.random() * 500) + 10) + 'ms</td></tr>';
|
||||
}
|
||||
|
||||
// Pagination
|
||||
var pag = document.getElementById('pagination');
|
||||
for (var p = 1; p <= 10; p++) {
|
||||
pag.innerHTML += '<button class="' + (p === 1 ? 'active' : '') + '">' + p + '</button>';
|
||||
}
|
||||
|
||||
// Services table
|
||||
var svcTable = document.getElementById('services-table');
|
||||
var svcNames = ['api-gateway', 'auth-service', 'payment-processor', 'search-engine',
|
||||
'notification-hub', 'analytics-pipeline', 'cache-layer', 'worker-pool',
|
||||
'storage-service', 'cdn-origin', 'queue-processor', 'ml-inference'];
|
||||
svcNames.forEach(function(name, i) {
|
||||
var status = i < 9 ? 'active' : i === 9 ? 'pending' : 'inactive';
|
||||
var badge = status === 'active' ? 'badge-active' : status === 'pending' ? 'badge-pending' : 'badge-inactive';
|
||||
svcTable.innerHTML += '<tr><td><strong>' + name + '</strong></td>' +
|
||||
'<td><span class="badge ' + badge + '">' + status + '</span></td>' +
|
||||
'<td>' + (99.9 - i * 0.05).toFixed(2) + '%</td>' +
|
||||
'<td>' + (30 + i * 5) + '%</td>' +
|
||||
'<td>' + (512 + i * 128) + 'MB</td>' +
|
||||
'<td>' + (2000 - i * 150) + '</td>' +
|
||||
'<td>' + (0.1 + i * 0.04).toFixed(2) + '%</td>' +
|
||||
'<td>' + (i + 1) + 'h ago</td></tr>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TechStore - Electronics & Gadgets</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #fff; color: #0f172a; }
|
||||
.topbar { background: #0f172a; color: #94a3b8; font-size: 12px; padding: 6px 24px; display: flex; justify-content: space-between; }
|
||||
.navbar { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; background: #fff; z-index: 100; }
|
||||
.navbar .logo { font-size: 22px; font-weight: 800; color: #6366f1; }
|
||||
.navbar .search { flex: 1; max-width: 500px; position: relative; }
|
||||
.navbar .search input { width: 100%; padding: 10px 16px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 14px; }
|
||||
.navbar .search input:focus { border-color: #6366f1; outline: none; }
|
||||
.nav-links { display: flex; gap: 20px; }
|
||||
.nav-links a { text-decoration: none; color: #475569; font-size: 14px; }
|
||||
.nav-actions { display: flex; gap: 16px; align-items: center; }
|
||||
.nav-actions button { background: none; border: none; font-size: 14px; cursor: pointer; color: #475569; }
|
||||
.cart-badge { background: #6366f1; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 4px; }
|
||||
.categories { display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
||||
.categories a { padding: 10px 16px; font-size: 13px; color: #64748b; text-decoration: none; white-space: nowrap; border-bottom: 2px solid transparent; }
|
||||
.categories a:hover, .categories a.active { color: #6366f1; border-bottom-color: #6366f1; }
|
||||
.hero { background: linear-gradient(135deg, #312e81, #6366f1); color: #fff; padding: 60px 24px; text-align: center; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.hero p { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
|
||||
.hero button { padding: 12px 32px; background: #fff; color: #6366f1; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
|
||||
.section-title { font-size: 1.5rem; font-weight: 700; margin: 32px 0 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-title a { font-size: 14px; color: #6366f1; text-decoration: none; }
|
||||
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
|
||||
.product { border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; }
|
||||
.product:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
|
||||
.product-img { height: 200px; display: flex; align-items: center; justify-content: center; font-size: 48px; }
|
||||
.product-info { padding: 16px; }
|
||||
.product-brand { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.product-name { font-size: 15px; font-weight: 600; margin: 4px 0 8px; line-height: 1.3; }
|
||||
.product-price { font-size: 20px; font-weight: 700; color: #0f172a; }
|
||||
.product-original { font-size: 14px; color: #94a3b8; text-decoration: line-through; margin-left: 8px; }
|
||||
.product-rating { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 13px; color: #64748b; }
|
||||
.stars { color: #f59e0b; }
|
||||
.product-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.product-actions button { flex: 1; padding: 8px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||
.btn-primary { background: #6366f1; color: #fff; }
|
||||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.filter { padding: 6px 16px; border: 1px solid #e2e8f0; border-radius: 20px; font-size: 13px; background: #fff; cursor: pointer; }
|
||||
.filter.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.deals-banner { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 12px; padding: 20px 24px; margin: 24px 0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.deals-banner h3 { color: #b45309; }
|
||||
.reviews { margin: 24px 0; }
|
||||
.review { padding: 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.review-author { font-weight: 600; font-size: 14px; }
|
||||
.review-date { font-size: 12px; color: #94a3b8; }
|
||||
.review-body { font-size: 14px; color: #475569; }
|
||||
.footer { background: #0f172a; color: #94a3b8; padding: 48px 24px; margin-top: 48px; }
|
||||
.footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; max-width: 1280px; margin: 0 auto; }
|
||||
.footer h4 { color: #fff; margin-bottom: 16px; font-size: 14px; }
|
||||
.footer a { display: block; color: #94a3b8; text-decoration: none; font-size: 13px; margin-bottom: 8px; }
|
||||
.footer-bottom { border-top: 1px solid #1e293b; padding-top: 24px; margin-top: 32px; text-align: center; font-size: 13px; max-width: 1280px; margin-left: auto; margin-right: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span>Free shipping on orders over $99</span>
|
||||
<span>Customer Service: 1-800-TECH | Track Order | Help</span>
|
||||
</div>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="logo">TechStore</div>
|
||||
<div class="search"><input type="text" placeholder="Search products, brands, categories..."></div>
|
||||
<div class="nav-actions">
|
||||
<button>Account</button>
|
||||
<button>Wishlist</button>
|
||||
<button>Cart <span class="cart-badge">3</span></button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="categories">
|
||||
<a href="#" class="active">All</a><a href="#">Laptops</a><a href="#">Phones</a>
|
||||
<a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a>
|
||||
<a href="#">Monitors</a><a href="#">Storage</a><a href="#">Networking</a>
|
||||
<a href="#">Accessories</a><a href="#">Deals</a><a href="#">New Arrivals</a>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h2>Spring Tech Sale</h2>
|
||||
<p>Up to 40% off on selected electronics. Limited time offer.</p>
|
||||
<button>Shop Now</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="deals-banner">
|
||||
<div><h3>Flash Deals - Ends in 04:32:17</h3><p style="font-size:13px;color:#92400e">Extra 15% off with code SPRING15</p></div>
|
||||
<button class="btn-primary" style="padding:10px 24px;border-radius:8px;border:none;cursor:pointer">View All Deals</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title"><span>Featured Products</span><a href="#">View All</a></div>
|
||||
<div class="filters">
|
||||
<span class="filter active">All</span><span class="filter">Under $100</span>
|
||||
<span class="filter">$100 - $500</span><span class="filter">$500+</span>
|
||||
<span class="filter">Top Rated</span><span class="filter">New</span>
|
||||
</div>
|
||||
<div class="product-grid" id="featured-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Best Sellers</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="bestsellers-grid"></div>
|
||||
|
||||
<div class="section-title"><span>New Arrivals</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="newarrivals-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Customer Reviews</span></div>
|
||||
<div class="reviews" id="reviews"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-grid">
|
||||
<div><h4>Shop</h4><a href="#">Laptops</a><a href="#">Phones</a><a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a><a href="#">Monitors</a><a href="#">Accessories</a></div>
|
||||
<div><h4>Support</h4><a href="#">Help Center</a><a href="#">Returns</a><a href="#">Warranty</a><a href="#">Contact Us</a><a href="#">Track Order</a><a href="#">Shipping Info</a></div>
|
||||
<div><h4>Company</h4><a href="#">About Us</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Blog</a><a href="#">Sustainability</a><a href="#">Investor Relations</a></div>
|
||||
<div><h4>Connect</h4><a href="#">Newsletter</a><a href="#">Social Media</a><a href="#">Affiliate Program</a><a href="#">Partner With Us</a><a href="#">Developer API</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">2025 TechStore Inc. All rights reserved. | Privacy Policy | Terms of Service | Cookie Settings</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var brands = ['Apple', 'Samsung', 'Sony', 'Bose', 'Dell', 'Lenovo', 'LG', 'ASUS', 'Logitech', 'Canon', 'Nikon', 'JBL'];
|
||||
var categories = ['Laptop', 'Phone', 'Tablet', 'Headphones', 'Camera', 'Monitor', 'Speaker', 'Keyboard'];
|
||||
var colors = ['#dbeafe', '#fce7f3', '#d1fae5', '#fef3c7', '#e0e7ff', '#f1f5f9', '#fef2f2', '#f0fdf4'];
|
||||
|
||||
function makeProduct(i) {
|
||||
var brand = brands[i % brands.length];
|
||||
var cat = categories[i % categories.length];
|
||||
var price = 49 + (i * 73) % 1500;
|
||||
var original = Math.round(price * 1.25);
|
||||
var rating = (3.5 + (i % 15) * 0.1).toFixed(1);
|
||||
var reviews = 50 + (i * 37) % 2000;
|
||||
var stars = '';
|
||||
for (var s = 0; s < 5; s++) stars += s < Math.round(parseFloat(rating)) ? '*' : ' ';
|
||||
return '<div class="product"><div class="product-img" style="background:' + colors[i % 8] + '">' +
|
||||
cat.charAt(0).toUpperCase() + '</div><div class="product-info">' +
|
||||
'<div class="product-brand">' + brand + '</div>' +
|
||||
'<div class="product-name">' + brand + ' ' + cat + ' Pro ' + (2024 + (i % 3)) + ' Edition</div>' +
|
||||
'<div><span class="product-price">$' + price + '</span><span class="product-original">$' + original + '</span></div>' +
|
||||
'<div class="product-rating"><span class="stars">' + stars + '</span> ' + rating + ' (' + reviews + ')</div>' +
|
||||
'<div class="product-actions"><button class="btn-primary">Add to Cart</button><button class="btn-secondary">Compare</button></div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
var featured = document.getElementById('featured-grid');
|
||||
for (var i = 0; i < 16; i++) featured.innerHTML += makeProduct(i);
|
||||
|
||||
var bestsellers = document.getElementById('bestsellers-grid');
|
||||
for (var i = 16; i < 32; i++) bestsellers.innerHTML += makeProduct(i);
|
||||
|
||||
var newarrivals = document.getElementById('newarrivals-grid');
|
||||
for (var i = 32; i < 48; i++) newarrivals.innerHTML += makeProduct(i);
|
||||
|
||||
var reviewsEl = document.getElementById('reviews');
|
||||
var names = ['Alice M.', 'Bob K.', 'Carol S.', 'David L.', 'Eva R.', 'Frank W.', 'Grace H.', 'Henry P.'];
|
||||
var reviewTexts = [
|
||||
'Excellent product, arrived faster than expected. Build quality is outstanding.',
|
||||
'Good value for money. Would recommend to anyone looking for a reliable device.',
|
||||
'Decent product but the battery life could be better. Works well otherwise.',
|
||||
'Amazing quality! This is my third purchase from this brand and they never disappoint.',
|
||||
];
|
||||
for (var i = 0; i < 20; i++) {
|
||||
reviewsEl.innerHTML += '<div class="review"><div class="review-header"><div><span class="review-author">' +
|
||||
names[i % 8] + '</span></div><span class="review-date">March ' + (15 - i % 15) + ', 2025</span></div>' +
|
||||
'<div class="review-body">' + reviewTexts[i % 4] + '</div></div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+516
-51
@@ -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<string, string> = {
|
||||
".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<void> {
|
||||
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<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
iterations: number,
|
||||
warmup: number,
|
||||
): Promise<ScenarioResult> {
|
||||
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<void> {
|
||||
const args = parseArgs();
|
||||
async function runDaemonBenchmark(args: CliArgs): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
} 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<void> {
|
||||
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<string, string> = {
|
||||
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<void> {
|
||||
const args = parseArgs();
|
||||
|
||||
cleanupSockets();
|
||||
|
||||
try {
|
||||
if (args.engineMode) {
|
||||
await runEngineBenchmark(args);
|
||||
} else {
|
||||
await runDaemonBenchmark(args);
|
||||
}
|
||||
} finally {
|
||||
cleanupSockets();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user