feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5
This commit is contained in:
@@ -64,12 +64,20 @@ jobs:
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ Default session isolation policy:
|
||||
| --- | --- | --- |
|
||||
| `--parallel <name>` | Isolate runtime channel for concurrent AI tasks | Stateless/no-login parallel jobs |
|
||||
| `--session-name <name>` | Persist cookies/localStorage across restarts | Login/auth continuity |
|
||||
| `--engine <name>` | Choose local browser engine (`chrome`, `lightpanda`) | Native-only engine experiments |
|
||||
|
||||
### Daemon Lifecycle
|
||||
|
||||
@@ -83,6 +84,32 @@ agent-browser --resident open https://example.com
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
### Browser Engine Selection
|
||||
|
||||
`chrome` remains the default engine. If you want to try [Lightpanda](https://lightpanda.io/docs/open-source/installation), use `--engine lightpanda`; this automatically routes through the native daemon.
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open https://example.com
|
||||
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Lightpanda is headless-only and does not support `--extension`, `--state`, `--profile`, or `--allow-file-access`.
|
||||
|
||||
### Headed Mode
|
||||
|
||||
Use `--headed` when you want a visible browser window:
|
||||
|
||||
```bash
|
||||
agent-browser --headed open https://example.com
|
||||
|
||||
AGENT_BROWSER_HEADED=1 agent-browser open https://example.com
|
||||
AGENT_BROWSER_HEADED=true agent-browser open https://example.com
|
||||
```
|
||||
|
||||
In this fork, local launches default to headed mode unless headless is explicitly requested. Extension launches also stay headed by default so the stealth/runtime policy remains stable.
|
||||
|
||||
### Default: Auto Group Agent Tabs (CDP + Plugin)
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.16.3-fork.4"
|
||||
version = "0.16.3-fork.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.16.3-fork.4"
|
||||
version = "0.16.3-fork.5"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+3
-3
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
|
||||
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
@@ -202,7 +202,7 @@ fn resolve_ref(
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.map_or(false, |t| t.contains(ref_type))
|
||||
.is_some_and(|t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
@@ -339,7 +339,7 @@ fn generate_domain(
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
|
||||
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
|
||||
@@ -2075,6 +2075,8 @@ mod tests {
|
||||
allow_file_access: false,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
native: false,
|
||||
engine: None,
|
||||
session_name: None,
|
||||
parallel: None,
|
||||
cli_executable_path: false,
|
||||
@@ -2087,6 +2089,8 @@ mod tests {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
|
||||
+61
-25
@@ -396,11 +396,18 @@ pub fn ensure_daemon(
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
native: bool,
|
||||
engine: Option<&str>,
|
||||
download_path: Option<&str>,
|
||||
tab_group: Option<&str>,
|
||||
tab_group_plugin_id: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
let daemon_path = resolve_daemon_path()?;
|
||||
let daemon_path = if native {
|
||||
let exe = env::current_exe().map_err(|e| e.to_string())?;
|
||||
exe.canonicalize().unwrap_or(exe)
|
||||
} else {
|
||||
resolve_daemon_path()?
|
||||
};
|
||||
|
||||
// Project policy: the default runtime channel is a singleton control plane.
|
||||
// Before touching it, reap all non-default channels to avoid stale daemon reuse.
|
||||
@@ -482,16 +489,21 @@ pub fn ensure_daemon(
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path)
|
||||
.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path);
|
||||
cmd
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -544,6 +556,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -573,7 +588,13 @@ pub fn ensure_daemon(
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -581,18 +602,24 @@ pub fn ensure_daemon(
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||
// and automatically quotes arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path)
|
||||
.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
// On Windows, call node directly. Command::new handles PATH
|
||||
// resolution (node.exe or node.cmd) and automatically quotes
|
||||
// arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path);
|
||||
cmd
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -645,6 +672,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -670,7 +700,13 @@ pub fn ensure_daemon(
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct Config {
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
pub auto_connect: Option<bool>,
|
||||
pub native: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
@@ -72,6 +74,8 @@ impl Config {
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
auto_connect: other.auto_connect.or(self.auto_connect),
|
||||
native: other.native.or(self.native),
|
||||
engine: other.engine.or(self.engine),
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
@@ -152,6 +156,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--engine",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -222,6 +227,9 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub native: bool,
|
||||
/// Browser engine for native local launches. `chrome` is the default.
|
||||
pub engine: Option<String>,
|
||||
// Defaults to "default" when unset in default runtime mode.
|
||||
// In --parallel mode, defaults to None unless explicitly provided on CLI.
|
||||
pub session_name: Option<String>,
|
||||
@@ -251,6 +259,8 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_native: bool,
|
||||
pub cli_engine: bool,
|
||||
pub cli_tab_group: bool,
|
||||
pub cli_tab_group_plugin_id: bool,
|
||||
pub cli_session_name: bool,
|
||||
@@ -314,6 +324,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.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),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -348,6 +360,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
cli_session_name: false,
|
||||
@@ -488,6 +502,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--native" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.native = val;
|
||||
flags.cli_native = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--engine" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.engine = Some(s.clone());
|
||||
flags.cli_engine = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
@@ -595,6 +624,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--native",
|
||||
"--annotate",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
@@ -621,6 +651,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--config",
|
||||
"--engine",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -1244,6 +1275,12 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_engine() {
|
||||
let cleaned = clean_args(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_with_config_flag() {
|
||||
use std::io::Write;
|
||||
@@ -1353,6 +1390,57 @@ mod tests {
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_false() {
|
||||
let flags = parse_flags(&args("--native false open example.com"));
|
||||
assert!(!flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_flag() {
|
||||
let flags = parse_flags(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_from_env() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_ENGINE"]);
|
||||
env::set_var("AGENT_BROWSER_ENGINE", "lightpanda");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(!flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--native open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_from_env_sets_native_without_cli_marker() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_NATIVE"]);
|
||||
env::set_var("AGENT_BROWSER_NATIVE", "1");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(!flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_native() {
|
||||
let config: Config = serde_json::from_str(r#"{"native": true}"#).unwrap();
|
||||
assert_eq!(config.native, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_engine() {
|
||||
let config: Config = serde_json::from_str(r#"{"engine": "lightpanda"}"#).unwrap();
|
||||
assert_eq!(config.engine.as_deref(), Some("lightpanda"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
|
||||
+29
-2
@@ -3,6 +3,7 @@ mod commands;
|
||||
mod connection;
|
||||
mod flags;
|
||||
mod install;
|
||||
mod native;
|
||||
mod output;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
@@ -86,6 +87,17 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
|
||||
}
|
||||
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(native::daemon::run_daemon(&session));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore SIGPIPE to prevent panic when piping to head/tail
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
@@ -93,9 +105,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");
|
||||
|
||||
@@ -262,6 +278,8 @@ fn main() {
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.native,
|
||||
flags.engine.as_deref(),
|
||||
flags.download_path.as_deref(),
|
||||
flags.tab_group.as_deref(),
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
@@ -316,6 +334,8 @@ fn main() {
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_native.then_some("--native"),
|
||||
flags.cli_engine.then_some("--engine"),
|
||||
flags.cli_tab_group.then_some("--tab-group"),
|
||||
flags
|
||||
.cli_tab_group_plugin_id
|
||||
@@ -407,6 +427,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -505,6 +528,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -655,7 +681,8 @@ fn main() {
|
||||
|| flags.allow_file_access
|
||||
|| flags.debug
|
||||
|| 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()
|
||||
&& !attached_to_existing_browser
|
||||
|
||||
+72
-40
@@ -167,13 +167,14 @@ impl DaemonState {
|
||||
if let Ok(te) =
|
||||
serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
|
||||
{
|
||||
if te.target_info.target_type == "page"
|
||||
if (te.target_info.target_type == "page"
|
||||
|| te.target_info.target_type == "webview")
|
||||
&& !te.target_info.url.is_empty()
|
||||
{
|
||||
let already_tracked = self
|
||||
.browser
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.has_target(&te.target_info.target_id));
|
||||
.is_none_or(|b| b.has_target(&te.target_info.target_id));
|
||||
if !already_tracked {
|
||||
new_targets.push(te);
|
||||
}
|
||||
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
session_id: attach.session_id,
|
||||
url: te.target_info.url.clone(),
|
||||
title: te.target_info.title.clone(),
|
||||
target_type: te.target_info.target_type.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,16 +551,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
}
|
||||
|
||||
// WebDriver backend: reject unsupported CDP-only actions
|
||||
if matches!(state.backend_type, BackendType::WebDriver) {
|
||||
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) {
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
if matches!(state.backend_type, BackendType::WebDriver)
|
||||
&& WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
|
||||
{
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let result = match action {
|
||||
@@ -726,6 +728,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
|
||||
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
let options = launch_options_from_env();
|
||||
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
|
||||
|
||||
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
|
||||
let mgr = BrowserManager::connect_cdp(&cdp).await?;
|
||||
@@ -743,7 +746,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mgr = BrowserManager::launch(options).await?;
|
||||
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
|
||||
state.browser = Some(mgr);
|
||||
state.subscribe_to_browser_events();
|
||||
try_auto_restore_state(state).await;
|
||||
@@ -835,20 +838,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("autoConnect")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let engine = cmd
|
||||
.get("engine")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
|
||||
|
||||
// Relaunch logic: check if we can reuse the existing connection
|
||||
let needs_relaunch = if let Some(ref mgr) = state.browser {
|
||||
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
|
||||
let was_cdp = mgr.is_cdp_connection();
|
||||
if has_cdp_arg != was_cdp {
|
||||
true
|
||||
} else if has_cdp_arg && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else if auto_connect && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else {
|
||||
!mgr.is_connection_alive().await
|
||||
}
|
||||
has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
|
||||
} else {
|
||||
true
|
||||
};
|
||||
@@ -1000,7 +1000,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.domain_filter = Some(DomainFilter::new(domains));
|
||||
}
|
||||
|
||||
state.browser = Some(BrowserManager::launch(options).await?);
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
|
||||
if let Some(ref filter) = state.domain_filter {
|
||||
@@ -2469,6 +2469,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
|
||||
session_id: new_session_id.clone(),
|
||||
url: nav_url.clone(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
// Navigate to URL
|
||||
@@ -3225,12 +3226,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
.send_command_no_params("Page.getFrameTree", Some(&session_id))
|
||||
.await?;
|
||||
|
||||
fn find_frame(
|
||||
tree: &Value,
|
||||
selector: Option<&str>,
|
||||
name: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Option<String> {
|
||||
fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
|
||||
let frame = tree.get("frame")?;
|
||||
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -3249,7 +3245,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
if let Some(id) = find_frame(child, selector, name, url) {
|
||||
if let Some(id) = find_frame(child, name, url) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
@@ -3274,13 +3270,13 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
);
|
||||
let result = mgr.evaluate(&js, None).await?;
|
||||
let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
|
||||
if let Some(frame_id) = find_frame(frame_tree, None, Some(frame_name), None) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": frame_name }));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame_id) = find_frame(frame_tree, selector, name, url) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, name, url) {
|
||||
let label = name.or(url).unwrap_or("frame");
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": label }));
|
||||
@@ -4008,14 +4004,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
|
||||
Ok(Ok(event)) => {
|
||||
if event.method == "Page.downloadProgress"
|
||||
&& event.session_id.as_deref() == Some(&session_id)
|
||||
&& event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
|
||||
{
|
||||
if event.params.get("state").and_then(|v| v.as_str()) == Some("completed") {
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Err("Event stream closed".to_string()),
|
||||
@@ -4064,6 +4059,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
session_id: attach.session_id,
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
if let Some(viewport) = cmd.get("viewport") {
|
||||
@@ -5134,6 +5130,38 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_response_structure() {
|
||||
let resp = success_response("cmd-1", json!({"url": "https://example.com"}));
|
||||
@@ -5174,7 +5202,10 @@ mod tests {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
||||
_guard.set("AGENT_BROWSER_HEADED", "1");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(!opts.headless, "AGENT_BROWSER_HEADED=1 should set headless=false");
|
||||
assert!(
|
||||
!opts.headless,
|
||||
"AGENT_BROWSER_HEADED=1 should set headless=false"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5226,6 +5257,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_roundtrip_via_actions() {
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let set_cmd = json!({
|
||||
|
||||
@@ -215,16 +215,15 @@ fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str)
|
||||
.map_err(|e| format!("Invalid profile data: {}", e));
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
|
||||
+116
-19
@@ -7,6 +7,7 @@ use super::cdp::chrome::{
|
||||
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
|
||||
};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
||||
use super::cdp::types::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +56,34 @@ pub fn validate_launch_options(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
|
||||
if options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty())
|
||||
{
|
||||
return Err("Extensions are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.profile.is_some() {
|
||||
return Err("Profiles are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.storage_state.is_some() {
|
||||
return Err("Storage state is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.allow_file_access {
|
||||
return Err("File access is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if !options.headless {
|
||||
return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
|
||||
}
|
||||
if !options.args.is_empty() {
|
||||
return Err(
|
||||
"Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
let lower = error.to_lowercase();
|
||||
@@ -86,6 +115,7 @@ pub struct PageInfo {
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub target_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -105,37 +135,72 @@ impl WaitUntil {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BrowserProcess {
|
||||
Chrome(ChromeProcess),
|
||||
Lightpanda(LightpandaProcess),
|
||||
}
|
||||
|
||||
pub struct BrowserManager {
|
||||
pub client: CdpClient,
|
||||
chrome_process: Option<ChromeProcess>,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
pages: Vec<PageInfo>,
|
||||
active_page_index: usize,
|
||||
default_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl BrowserManager {
|
||||
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
|
||||
validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?;
|
||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||
let engine = engine.unwrap_or("chrome");
|
||||
|
||||
match engine {
|
||||
"chrome" => validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?,
|
||||
"lightpanda" => validate_lightpanda_options(&options)?,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
|
||||
engine
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let ignore_https_errors = options.ignore_https_errors;
|
||||
let user_agent = options.user_agent.clone();
|
||||
let color_scheme = options.color_scheme.clone();
|
||||
let download_path = options.download_path.clone();
|
||||
|
||||
let chrome = launch_chrome(&options)?;
|
||||
let ws_url = chrome.ws_url.clone();
|
||||
let (ws_url, process) = match engine {
|
||||
"lightpanda" => {
|
||||
let lp_options = LightpandaLaunchOptions {
|
||||
executable_path: options.executable_path.clone(),
|
||||
proxy: options.proxy.clone(),
|
||||
port: None,
|
||||
};
|
||||
let process = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
|
||||
.await
|
||||
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Lightpanda(process))
|
||||
}
|
||||
_ => {
|
||||
let process = tokio::task::spawn_blocking(move || launch_chrome(&options))
|
||||
.await
|
||||
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Chrome(process))
|
||||
}
|
||||
};
|
||||
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: Some(chrome),
|
||||
browser_process: Some(process),
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 25_000,
|
||||
@@ -197,7 +262,7 @@ impl BrowserManager {
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: None,
|
||||
browser_process: None,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
@@ -229,7 +294,9 @@ impl BrowserManager {
|
||||
let page_targets: Vec<TargetInfo> = result
|
||||
.target_infos
|
||||
.into_iter()
|
||||
.filter(|t| t.target_type == "page" && !t.url.is_empty())
|
||||
.filter(|t| {
|
||||
(t.target_type == "page" || t.target_type == "webview") && !t.url.is_empty()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if page_targets.is_empty() {
|
||||
@@ -262,6 +329,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
@@ -284,6 +352,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: target.url.clone(),
|
||||
title: target.title.clone(),
|
||||
target_type: target.target_type.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,10 +576,11 @@ impl BrowserManager {
|
||||
.send_command_no_params("Browser.close", None)
|
||||
.await;
|
||||
|
||||
if let Some(mut chrome) = self.chrome_process.take() {
|
||||
if let Some(process) = self.browser_process.take() {
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
chrome.wait_or_kill(timeout);
|
||||
let _ = tokio::task::spawn_blocking(move || match process {
|
||||
BrowserProcess::Chrome(mut chrome) => chrome.wait_or_kill(timeout),
|
||||
BrowserProcess::Lightpanda(mut lightpanda) => lightpanda.kill(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -541,7 +611,7 @@ impl BrowserManager {
|
||||
|
||||
/// Returns true if this manager was connected via CDP (as opposed to local launch).
|
||||
pub fn is_cdp_connection(&self) -> bool {
|
||||
self.chrome_process.is_none()
|
||||
self.browser_process.is_none()
|
||||
}
|
||||
|
||||
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
|
||||
@@ -579,6 +649,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
@@ -611,6 +682,7 @@ impl BrowserManager {
|
||||
"index": i,
|
||||
"title": p.title,
|
||||
"url": p.url,
|
||||
"type": p.target_type,
|
||||
"active": i == self.active_page_index,
|
||||
})
|
||||
})
|
||||
@@ -651,6 +723,7 @@ impl BrowserManager {
|
||||
session_id: attach.session_id,
|
||||
url: target_url.to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = index;
|
||||
|
||||
@@ -1068,6 +1141,30 @@ mod tests {
|
||||
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_extensions() {
|
||||
let opts = LaunchOptions {
|
||||
extensions: Some(vec!["/tmp/ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_headed() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_valid() {
|
||||
let opts = LaunchOptions::default();
|
||||
assert!(validate_lightpanda_options(&opts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_ai_friendly_error_strict_mode() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -123,7 +123,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let has_extensions = options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.map_or(false, |exts| !exts.is_empty());
|
||||
.is_some_and(|exts| !exts.is_empty());
|
||||
|
||||
// Extensions require headed mode in native Chrome (content scripts are not
|
||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||
@@ -144,8 +144,8 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
None
|
||||
} else {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
|
||||
args.push(format!("--user-data-dir={}", dir.display()));
|
||||
@@ -216,14 +216,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
|
||||
})?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let ws_url = match wait_for_ws_url(reader) {
|
||||
@@ -515,10 +512,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -662,10 +656,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let lines = vec!["info line".to_string(), "another info line".to_string()];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
@@ -686,10 +677,7 @@ mod tests {
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
@@ -748,14 +736,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--window-size=1920,1080"));
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_stderr_drain: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(port) => port,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|listener| listener.local_addr())
|
||||
.map(|addr| addr.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.clone());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let (address, reader) = match wait_for_address(reader) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let ws_url = format!("ws://{}", address);
|
||||
let drain = std::thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_stderr_drain: Some(drain),
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_address(
|
||||
mut reader: BufReader<std::process::ChildStderr>,
|
||||
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Timeout waiting for Lightpanda server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) => {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Lightpanda exited before providing server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = buf.trim_end().to_string();
|
||||
if let Some(address) = extract_address(&line) {
|
||||
return Ok((address, reader));
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to read Lightpanda stderr: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_address(line: &str) -> Option<String> {
|
||||
if let Some(idx) = line.find("address = ") {
|
||||
let address = line[idx + "address = ".len()..].trim().to_string();
|
||||
if !address.is_empty() {
|
||||
return Some(address);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Lightpanda)", message);
|
||||
}
|
||||
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
format!(
|
||||
"{}\nLightpanda stderr (last {} lines):\n {}",
|
||||
message,
|
||||
last_lines.len(),
|
||||
last_lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|line| line.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_standard() {
|
||||
assert_eq!(
|
||||
extract_address(" address = 127.0.0.1:9222"),
|
||||
Some("127.0.0.1:9222".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_inline() {
|
||||
assert_eq!(
|
||||
extract_address("INFO app : server running address = 127.0.0.1:4567"),
|
||||
Some("127.0.0.1:4567".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_no_match() {
|
||||
assert_eq!(extract_address("INFO app : starting up..."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_stderr() {
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let lines = vec![
|
||||
"INFO starting up".to_string(),
|
||||
"ERROR bind failed: address in use".to_string(),
|
||||
];
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
|
||||
assert!(msg.contains("bind failed"));
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
|
||||
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
|
||||
@@ -56,13 +56,11 @@ pub async fn set_cookies(
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
if c.get("url").is_none() && c.get("domain").is_none() {
|
||||
if let Some(url) = current_url {
|
||||
c.as_object_mut()
|
||||
.map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
|
||||
}
|
||||
}
|
||||
c
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -24,6 +24,16 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
let meta_path = socket_dir.join(format!("{}.meta.json", session));
|
||||
if let Ok(current_exe) = env::current_exe() {
|
||||
let daemon_path = current_exe.canonicalize().unwrap_or(current_exe);
|
||||
let cli_version = env::var("AGENT_BROWSER_CLI_VERSION").unwrap_or_default();
|
||||
let meta = json!({
|
||||
"daemonPath": daemon_path.to_string_lossy(),
|
||||
"cliVersion": cli_version,
|
||||
});
|
||||
let _ = fs::write(&meta_path, meta.to_string());
|
||||
}
|
||||
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
@@ -43,6 +53,7 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&meta_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
@@ -185,8 +196,7 @@ async fn handle_connection<S>(
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
activity_tx: UnboundedSender<()>,
|
||||
active_commands: std::sync::Arc<AtomicUsize>,
|
||||
)
|
||||
where
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 1);
|
||||
assert_eq!(tabs[0]["active"], true);
|
||||
assert_eq!(tabs[0]["type"], "page");
|
||||
|
||||
// Open new tab
|
||||
let resp = execute_command(
|
||||
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 2);
|
||||
assert_eq!(tabs[1]["active"], true);
|
||||
assert_eq!(tabs[1]["type"], "page");
|
||||
|
||||
// Switch to first tab
|
||||
let resp = execute_command(
|
||||
|
||||
@@ -374,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let result = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(250),
|
||||
execute_command(&cmd, &mut state),
|
||||
)
|
||||
.await;
|
||||
|
||||
let Ok(result) = result else {
|
||||
continue;
|
||||
};
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
|
||||
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
|
||||
"RootWebArea",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SnapshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub interactive: bool,
|
||||
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
|
||||
pub cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
interactive: false,
|
||||
compact: false,
|
||||
depth: None,
|
||||
cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
role: String,
|
||||
name: String,
|
||||
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
|
||||
let escaped = text
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', " ")
|
||||
.replace('\r', " ");
|
||||
.replace(['\n', '\r'], " ");
|
||||
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -2458,7 +2458,7 @@ Options:
|
||||
--json JSON output
|
||||
--full, -f Full page screenshot
|
||||
--annotate Annotated screenshot with numbered labels and legend
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
|
||||
@@ -2480,6 +2480,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
|
||||
@@ -2520,7 +2521,7 @@ Environment:
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
||||
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless)
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless; accepts 1 or true)
|
||||
AGENT_BROWSER_JSON JSON output
|
||||
AGENT_BROWSER_FULL Full page screenshot
|
||||
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
|
||||
@@ -2550,6 +2551,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):
|
||||
|
||||
@@ -254,6 +254,15 @@ Most CLI flags can be set in the config file using their camelCase equivalents (
|
||||
</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>colorScheme</code>
|
||||
@@ -317,6 +326,8 @@ Most CLI flags can be set in the config file using their camelCase equivalents (
|
||||
|
||||
`riskMode` defaults to `warn` when unset.
|
||||
|
||||
`engine` defaults to `chrome`. `lightpanda` implies native mode and is headless-only.
|
||||
|
||||
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
|
||||
extension available => grouped by session; extension missing/unavailable => silent no-op.
|
||||
|
||||
@@ -399,12 +410,16 @@ agent-browser --headed true open example.com # explicit
|
||||
|
||||
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--resident`.
|
||||
|
||||
For environment variables, headed mode accepts either `AGENT_BROWSER_HEADED=1` or `AGENT_BROWSER_HEADED=true`.
|
||||
|
||||
## Extensions Merging
|
||||
|
||||
Extensions from user-level and project-level configs are **concatenated**, not replaced. For example, if `~/.agent-browser/config.json` specifies `["/ext1"]` and `./agent-browser.json` specifies `["/ext2"]`, the result is `["/ext1", "/ext2"]`.
|
||||
|
||||
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
|
||||
|
||||
In this fork, local launches and extension launches remain headed by default unless headless is explicitly requested.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These environment variables configure additional daemon and runtime behavior:
|
||||
@@ -450,6 +465,17 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<td>Default directory for browser downloads.</td>
|
||||
<td>(temp directory)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ENGINE</code>
|
||||
</td>
|
||||
<td>
|
||||
Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>. Implies native mode.
|
||||
</td>
|
||||
<td>
|
||||
<code>chrome</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||
|
||||
@@ -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, or root execution).
|
||||
@@ -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. It is intended for machine-driven workloads where fast startup and low memory use matter more than full Chrome compatibility.
|
||||
|
||||
agent-browser manages Lightpanda the same way it manages Chrome: spawn the process, connect via CDP, and drive the same downstream commands (`snapshot`, `click`, `fill`, `screenshot`, and so on).
|
||||
|
||||
## 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` such as `/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 `--engine` 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 `agent-browser.json`:
|
||||
|
||||
```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 headless-only and does not support several Chrome-specific features:
|
||||
|
||||
<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</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 scraping and extraction jobs
|
||||
- AI agent workflows where speed and low memory matter
|
||||
- CI 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",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Upstream Sync Audit (2026-03-09)
|
||||
|
||||
Scope: compare current `main` plus the local in-progress sync worktree with `upstream/main`.
|
||||
|
||||
## Already Synced
|
||||
|
||||
- `de5ea1d` `fix: use reqwest for CDP port discovery instead of broken hand-rolled HTTP client (#619)`
|
||||
- `8f6ad81` `Fix dialog dismiss command parsing (#605)`
|
||||
- `7acde7e` `fix: native auth login fails due to incompatible encryption format (#648)`
|
||||
- `492830a` `Fix: Suppress Google Translate bar in native headless mode (#649)`
|
||||
- `68cebe5` `Fix Chrome extensions not loading by forcing headed mode when extensions present (#652)`
|
||||
- `b7e7a25` `fix: persist auth cookies on close in native mode (#650)`
|
||||
|
||||
## Absorbed Locally (Not Exact Cherry-Picks)
|
||||
|
||||
- `eaa968e` `fix: suppress spurious --native warning when set via env var (#611)`
|
||||
- Covered by the local native CLI restoration in:
|
||||
- [cli/src/flags.rs](/Users/leo/github.com/agent-browser/cli/src/flags.rs)
|
||||
- [cli/src/main.rs](/Users/leo/github.com/agent-browser/cli/src/main.rs)
|
||||
- [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs)
|
||||
- [cli/src/native/daemon.rs](/Users/leo/github.com/agent-browser/cli/src/native/daemon.rs)
|
||||
- `788ad0e` `chore: add cargo fmt check to Rust CI and fix existing violations (#620)`
|
||||
- The Rust CI `fmt` check is already present in [.github/workflows/ci.yml](/Users/leo/github.com/agent-browser/.github/workflows/ci.yml).
|
||||
- `aba2353` `Fix clippy warnings across CLI codebase (#654)`
|
||||
- The current worktree already carries the relevant CLI cleanup needed for `cargo clippy -- -D warnings` to pass.
|
||||
- `d9387aa` `ci: add clippy check to Rust CI workflow (#675)`
|
||||
- The Rust CI `clippy` check is already present in [.github/workflows/ci.yml](/Users/leo/github.com/agent-browser/.github/workflows/ci.yml).
|
||||
- `f262ff1` `docs: improve snapshot usage guidance and add reproducibility check (#630)`
|
||||
- Safe docs-only sync. Applied locally in [skills/dogfood/SKILL.md](/Users/leo/github.com/agent-browser/skills/dogfood/SKILL.md).
|
||||
- `a0bd0c2` `Add webview support for Electron apps in native mode (#671)`
|
||||
- Applied locally in:
|
||||
- [cli/src/native/actions.rs](/Users/leo/github.com/agent-browser/cli/src/native/actions.rs)
|
||||
- [cli/src/native/browser.rs](/Users/leo/github.com/agent-browser/cli/src/native/browser.rs)
|
||||
- Broadens native target discovery from `page` to `page | webview` and adds `type` to native `tab_list` output.
|
||||
- Does not alter the fork's Node.js stealth launch defaults.
|
||||
- `36c2e06` `add benchmarks (#637)`
|
||||
- Applied locally in:
|
||||
- [package.json](/Users/leo/github.com/agent-browser/package.json)
|
||||
- [test/benchmarks/run.ts](/Users/leo/github.com/agent-browser/test/benchmarks/run.ts)
|
||||
- [test/benchmarks/scenarios.ts](/Users/leo/github.com/agent-browser/test/benchmarks/scenarios.ts)
|
||||
- Adds developer benchmark scripts only. No runtime or stealth launch behavior changes.
|
||||
- `0da54c7` `lightpanda (#646)` core feature set
|
||||
- Applied locally in:
|
||||
- [cli/src/flags.rs](/Users/leo/github.com/agent-browser/cli/src/flags.rs)
|
||||
- [cli/src/main.rs](/Users/leo/github.com/agent-browser/cli/src/main.rs)
|
||||
- [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs)
|
||||
- [cli/src/native/actions.rs](/Users/leo/github.com/agent-browser/cli/src/native/actions.rs)
|
||||
- [cli/src/native/browser.rs](/Users/leo/github.com/agent-browser/cli/src/native/browser.rs)
|
||||
- [cli/src/native/cdp/lightpanda.rs](/Users/leo/github.com/agent-browser/cli/src/native/cdp/lightpanda.rs)
|
||||
- [src/protocol.ts](/Users/leo/github.com/agent-browser/src/protocol.ts)
|
||||
- [src/types.ts](/Users/leo/github.com/agent-browser/src/types.ts)
|
||||
- [src/actions.ts](/Users/leo/github.com/agent-browser/src/actions.ts)
|
||||
- [docs/src/app/engines/chrome/page.mdx](/Users/leo/github.com/agent-browser/docs/src/app/engines/chrome/page.mdx)
|
||||
- [docs/src/app/engines/lightpanda/page.mdx](/Users/leo/github.com/agent-browser/docs/src/app/engines/lightpanda/page.mdx)
|
||||
- [docs/src/lib/docs-navigation.ts](/Users/leo/github.com/agent-browser/docs/src/lib/docs-navigation.ts)
|
||||
- [docs/src/lib/page-titles.ts](/Users/leo/github.com/agent-browser/docs/src/lib/page-titles.ts)
|
||||
- [test/benchmarks/run.ts](/Users/leo/github.com/agent-browser/test/benchmarks/run.ts)
|
||||
- [test/benchmarks/engine-scenarios.ts](/Users/leo/github.com/agent-browser/test/benchmarks/engine-scenarios.ts)
|
||||
- [test/benchmarks/pages/article.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/article.html)
|
||||
- [test/benchmarks/pages/dashboard.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/dashboard.html)
|
||||
- [test/benchmarks/pages/ecommerce.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/ecommerce.html)
|
||||
- Shared launch protocol now accepts `engine`. The Node path still rejects `engine=lightpanda` with a clear `--native` requirement, while the native path can launch either `chrome` or `lightpanda`.
|
||||
- This preserves the current Node.js/Chrome stealth path while adding the native-only alternative engine surface and its supporting docs/benchmarks.
|
||||
|
||||
## Remaining Upstream Commits
|
||||
|
||||
Current status: there are no remaining upstream feature commits that are both codeful and safe to port directly into this fork. What remains is either release metadata or the stealth-sensitive `#607` launch-policy batch.
|
||||
|
||||
### Low Risk / Independent Of Stealth
|
||||
|
||||
- `94521e7` `chore: add minor changeset for release (#683)`
|
||||
- Release metadata only.
|
||||
- `2bab729` `chore: version packages (#684)`
|
||||
- Release/version bump only.
|
||||
- `01ac557` `chore: add patch changeset for release (#609)`
|
||||
- Release metadata only.
|
||||
- `7d2c895` `chore: add patch changeset for release (#612)`
|
||||
- Release metadata only.
|
||||
- `7edc5d5` `chore: version packages (#610)`
|
||||
- Release/version bump only.
|
||||
- `794a77e` `chore: version packages (#613)`
|
||||
- Release/version bump only.
|
||||
|
||||
### Needs Manual Review Because It Touches Stealth-Sensitive Launch Behavior
|
||||
|
||||
- `e5fd26e` `headed mode (#607)`
|
||||
- Overlaps with our fork-modified launch path:
|
||||
- `src/browser.ts`
|
||||
- `src/daemon.ts`
|
||||
- `cli/src/native/cdp/chrome.rs`
|
||||
- `cli/src/connection.rs`
|
||||
- Upstream intent:
|
||||
- honor `AGENT_BROWSER_HEADED`
|
||||
- support headed launch in more places
|
||||
- add temp profile cleanup and tests
|
||||
- Fork-specific risk:
|
||||
- upstream changes persistent extension launch from `headless: false` to `headless: options.headless ?? true` in `src/browser.ts`
|
||||
- our fork intentionally keeps extension launches headed by default via [src/browser.ts](/Users/leo/github.com/agent-browser/src/browser.ts#L2131)
|
||||
- our daemon auto-launch path already honors `AGENT_BROWSER_HEADED=1` and `AGENT_BROWSER_HEADED=true` in [src/daemon.ts](/Users/leo/github.com/agent-browser/src/daemon.ts#L523)
|
||||
- the native temp-profile cleanup and extension-headed logic from upstream are already present in [cli/src/native/cdp/chrome.rs](/Users/leo/github.com/agent-browser/cli/src/native/cdp/chrome.rs)
|
||||
- blindly reapplying the upstream Node hunk would move extension launch defaults back toward upstream headless behavior and would change current stealth assumptions
|
||||
- Recommendation:
|
||||
- do not cherry-pick this commit directly
|
||||
- keep fork ownership of headed/headless defaults in the Node.js path
|
||||
- extract only test-only utilities or assertions that do not alter launch policy
|
||||
- local regression tests now lock the fork policy in [src/browser.test.ts](/Users/leo/github.com/agent-browser/src/browser.test.ts), including default local headed launch and extension launches remaining headed by default
|
||||
- Node daemon env parsing is also locked in [src/daemon.test.ts](/Users/leo/github.com/agent-browser/src/daemon.test.ts), including `AGENT_BROWSER_HEADED=true` and comma/newline parsing for extensions and args
|
||||
- treat headless/headed defaults as a fork-owned policy decision
|
||||
|
||||
### Already Partly Reimplemented In Fork
|
||||
|
||||
- `139dd0e` `fix: surface daemon startup errors instead of opaque timeout message (#614)`
|
||||
- Current fork already captures daemon stderr with `Stdio::piped()` and checks `try_wait()` during startup polling in [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L478) and [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L685).
|
||||
- `AGENT_BROWSER_DEBUG` forwarding is already present in [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L550) and [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L651).
|
||||
- Re-review on 2026-03-09 confirms the local implementation is functionally equivalent or stronger than upstream, with the same stderr surfacing and early-exit detection but fork-specific daemon spawn logic.
|
||||
- Recommendation: treat `#614` as absorbed locally and do not cherry-pick it.
|
||||
|
||||
## Fork-Specific Blockers Found During Audit
|
||||
|
||||
- Native CLI wiring was missing during the initial audit, but has since been restored locally.
|
||||
- Remaining blocker is no longer the `--native` switch itself.
|
||||
- The real decision point is whether this fork wants to expose new native features (`--engine`, Lightpanda, Electron webview) that do not help stealth directly but do expand the maintained surface area.
|
||||
- That decision has now been made in favor of exposing them locally, so the blocker section is effectively closed for the current sync round.
|
||||
|
||||
## Current Verification
|
||||
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo fmt -- --check`
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo clippy -- -D warnings`
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo test`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm build`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm exec tsx test/benchmarks/run.ts --node-only --iterations 1 --warmup 0`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm exec vitest run src/actions.test.ts test/keyboard.test.ts test/launch-options.test.ts`
|
||||
|
||||
All checks pass against the current local sync worktree.
|
||||
|
||||
## Recommended Migration Order
|
||||
|
||||
1. CI hygiene batch
|
||||
- Already absorbed locally via the current worktree.
|
||||
- No stealth behavior change.
|
||||
2. Docs-only batch
|
||||
- Safe to keep following `#630`-style guidance updates.
|
||||
- No runtime behavior change.
|
||||
3. Headed-mode audit
|
||||
- Reconcile upstream `#607` against fork-owned stealth launch defaults instead of cherry-picking it.
|
||||
4. Release metadata
|
||||
- Keep fork-owned release/versioning flow.
|
||||
- Do not mirror upstream changesets or version bumps unless this fork explicitly decides to realign its release train.
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.16.3-fork.4",
|
||||
"version": "0.16.3-fork.5",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -36,6 +36,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"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",
|
||||
"check:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
|
||||
"check:stealth-regression": "node scripts/check-stealth-regression.js",
|
||||
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
|
||||
|
||||
@@ -316,7 +316,7 @@ agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile (path optional)
|
||||
```
|
||||
|
||||
Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode.
|
||||
Use `AGENT_BROWSER_HEADED=1` or `AGENT_BROWSER_HEADED=true` to enable headed mode via environment variable. In this fork, local launches and extension launches stay headed by default unless headless is explicitly requested.
|
||||
|
||||
### Local Files (PDFs, HTML)
|
||||
|
||||
@@ -631,6 +631,29 @@ 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 a 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
|
||||
|
||||
Lightpanda is headless-only and does not support `--extension`, `--state`, `--profile`, or `--allow-file-access`. Install it from https://lightpanda.io/docs/open-source/installation.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
|
||||
@@ -190,9 +190,11 @@ agent-browser --session {SESSION} close
|
||||
## Guidance
|
||||
|
||||
- **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot.
|
||||
- **Verify reproducibility before collecting evidence.** Before recording video or taking screenshots, verify the issue is reproducible with at least one retry. If it cannot be reproduced consistently, do not report it as a confirmed issue.
|
||||
- **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes.
|
||||
- **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence.
|
||||
- **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser.
|
||||
- **Use the right snapshot command.** Use `snapshot -i` to find clickable or fillable elements. Use plain `snapshot` when you need readable page content such as text, headings, or data lists.
|
||||
- **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate.
|
||||
- **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end.
|
||||
- **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward.
|
||||
|
||||
@@ -117,6 +117,26 @@ describe('tab grouping fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('launch engine guard', () => {
|
||||
it('should reject lightpanda on the Node.js path', async () => {
|
||||
const browser = {
|
||||
launch: vi.fn(),
|
||||
getStealthStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 'lp1', action: 'launch', engine: 'lightpanda' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
if (!response.success) {
|
||||
expect(response.error).toContain('requires --native mode');
|
||||
}
|
||||
expect(browser.launch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk interstitial recovery', () => {
|
||||
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
|
||||
const challengeClearMs = 10_000;
|
||||
|
||||
@@ -520,6 +520,10 @@ 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,
|
||||
|
||||
@@ -260,6 +260,77 @@ describe('BrowserManager', () => {
|
||||
await cdpBrowser.close();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should keep local Chrome launch headed by default under fork policy', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
const mockPage = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
emulateMedia: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ loose: true, strict: true }),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
isClosed: () => false,
|
||||
on: vi.fn(),
|
||||
url: () => 'about:blank',
|
||||
};
|
||||
const mockContext = {
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
on: vi.fn(),
|
||||
pages: () => [mockPage],
|
||||
setDefaultTimeout: vi.fn(),
|
||||
};
|
||||
const mockBrowser = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
version: vi.fn().mockReturnValue('123.0.6312.0'),
|
||||
};
|
||||
const launchSpy = vi.spyOn(chromium, 'launch').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
await testBrowser.launch({ id: 'default-headed', action: 'launch' });
|
||||
|
||||
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(launchSpy.mock.calls[0]?.[0]).toMatchObject({ headless: false });
|
||||
|
||||
await testBrowser.close();
|
||||
launchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should keep extension launches headed by default under fork policy', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
const mockPage = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
emulateMedia: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ loose: true, strict: true }),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
isClosed: () => false,
|
||||
on: vi.fn(),
|
||||
url: () => 'about:blank',
|
||||
};
|
||||
const mockContext = {
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
on: vi.fn(),
|
||||
pages: () => [mockPage],
|
||||
setDefaultTimeout: vi.fn(),
|
||||
};
|
||||
const launchPersistentContextSpy = vi
|
||||
.spyOn(chromium, 'launchPersistentContext')
|
||||
.mockResolvedValue(mockContext as any);
|
||||
|
||||
await testBrowser.launch({
|
||||
action: 'launch',
|
||||
extensions: ['/tmp/ext-a', '/tmp/ext-b'],
|
||||
id: 'ext-headed',
|
||||
});
|
||||
|
||||
expect(launchPersistentContextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(launchPersistentContextSpy.mock.calls[0]?.[1]).toMatchObject({ headless: false });
|
||||
|
||||
await testBrowser.close();
|
||||
launchPersistentContextSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab-group plugin handshake', () => {
|
||||
|
||||
+2
-1
@@ -2126,7 +2126,8 @@ export class BrowserManager {
|
||||
|
||||
let context: BrowserContext;
|
||||
if (hasExtensions) {
|
||||
// Extensions require persistent context in a temp directory
|
||||
// Extensions require persistent context in a temp directory. In this fork,
|
||||
// extension launches stay headed by default unless headless is explicitly requested.
|
||||
const extPaths = configuredExtensions.join(',');
|
||||
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
// Combine extension args with custom args and file access args
|
||||
|
||||
+74
-1
@@ -3,7 +3,12 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
import { createSerializedExecutor, getSocketDir, safeWrite } from './daemon.js';
|
||||
import {
|
||||
buildAutoLaunchOptionsFromEnv,
|
||||
createSerializedExecutor,
|
||||
getSocketDir,
|
||||
safeWrite,
|
||||
} from './daemon.js';
|
||||
|
||||
/**
|
||||
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
|
||||
@@ -97,6 +102,74 @@ describe('getSocketDir', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAutoLaunchOptionsFromEnv', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AGENT_BROWSER_HEADED;
|
||||
delete process.env.AGENT_BROWSER_EXTENSIONS;
|
||||
delete process.env.AGENT_BROWSER_ARGS;
|
||||
delete process.env.AGENT_BROWSER_PROXY;
|
||||
delete process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
delete process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
delete process.env.AGENT_BROWSER_TAB_GROUP;
|
||||
delete process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID;
|
||||
delete process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS;
|
||||
delete process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should treat AGENT_BROWSER_HEADED=true as headed mode', () => {
|
||||
process.env.AGENT_BROWSER_HEADED = 'true';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.headless).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep default auto-launch headed behavior unchanged when env is unset', () => {
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.headless).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse extensions and args from comma or newline separated env vars', () => {
|
||||
process.env.AGENT_BROWSER_EXTENSIONS = ' /tmp/ext-a,\n/tmp/ext-b ,, \n /tmp/ext-c ';
|
||||
process.env.AGENT_BROWSER_ARGS = '--start-maximized,\n--disable-gpu';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.extensions).toEqual(['/tmp/ext-a', '/tmp/ext-b', '/tmp/ext-c']);
|
||||
expect(options.args).toEqual(['--start-maximized', '--disable-gpu']);
|
||||
});
|
||||
|
||||
it('should preserve proxy and optional launch fields from env', () => {
|
||||
process.env.AGENT_BROWSER_PROXY = 'http://127.0.0.1:8080';
|
||||
process.env.AGENT_BROWSER_PROXY_BYPASS = 'localhost,*.internal';
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME = 'dark';
|
||||
process.env.AGENT_BROWSER_TAB_GROUP = ' Agent Browser ';
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID = ' plugin-123 ';
|
||||
process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS = '1';
|
||||
process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS = '1';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv('/tmp/auto-state.json');
|
||||
|
||||
expect(options.proxy).toEqual({
|
||||
server: 'http://127.0.0.1:8080',
|
||||
bypass: 'localhost,*.internal',
|
||||
});
|
||||
expect(options.colorScheme).toBe('dark');
|
||||
expect(options.tabGroup).toBe('Agent Browser');
|
||||
expect(options.tabGroupPluginId).toBe('plugin-123');
|
||||
expect(options.ignoreHTTPSErrors).toBe(true);
|
||||
expect(options.allowFileAccess).toBe(true);
|
||||
expect(options.autoStateFilePath).toBe('/tmp/auto-state.json');
|
||||
});
|
||||
});
|
||||
|
||||
function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const socket = Object.assign(emitter, {
|
||||
|
||||
+51
-60
@@ -8,6 +8,7 @@ import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand } from './actions.js';
|
||||
import { executeIOSCommand } from './ios-actions.js';
|
||||
import { StreamServer } from './stream-server.js';
|
||||
import type { LaunchCommand } from './types.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
ensureSessionsDir,
|
||||
@@ -200,6 +201,55 @@ export function getSession(): string {
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
function parseEnvList(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
export function buildAutoLaunchOptionsFromEnv(
|
||||
autoStateFilePath: string | undefined = getSessionAutoStatePath()
|
||||
): LaunchCommand {
|
||||
const proxyServer = process.env.AGENT_BROWSER_PROXY;
|
||||
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
|
||||
colorSchemeEnv === 'dark' || colorSchemeEnv === 'light' || colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
|
||||
|
||||
return {
|
||||
id: 'auto',
|
||||
action: 'launch',
|
||||
// Accept both AGENT_BROWSER_HEADED=1 and =true for daemon auto-launch.
|
||||
headless:
|
||||
process.env.AGENT_BROWSER_HEADED !== '1' && process.env.AGENT_BROWSER_HEADED !== 'true',
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
extensions: parseEnvList(process.env.AGENT_BROWSER_EXTENSIONS),
|
||||
storageState: process.env.AGENT_BROWSER_STATE,
|
||||
args: parseEnvList(process.env.AGENT_BROWSER_ARGS),
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
proxy: proxyServer
|
||||
? {
|
||||
server: proxyServer,
|
||||
...(proxyBypass && { bypass: proxyBypass }),
|
||||
}
|
||||
: undefined,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
allowFileAccess: process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1',
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
|
||||
autoStateFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get port number for TCP mode (Windows)
|
||||
* Uses a hash of the session name to get a consistent port
|
||||
@@ -484,66 +534,7 @@ export async function startDaemon(options?: {
|
||||
});
|
||||
} else if (manager instanceof BrowserManager) {
|
||||
// Auto-launch desktop browser
|
||||
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
|
||||
? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
// Parse args from env (comma or newline separated)
|
||||
const argsEnv = process.env.AGENT_BROWSER_ARGS;
|
||||
const args = argsEnv
|
||||
? argsEnv
|
||||
.split(/[,\n]/)
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0)
|
||||
: undefined;
|
||||
|
||||
// Parse proxy from env
|
||||
const proxyServer = process.env.AGENT_BROWSER_PROXY;
|
||||
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
const proxy = proxyServer
|
||||
? {
|
||||
server: proxyServer,
|
||||
...(proxyBypass && { bypass: proxyBypass }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
|
||||
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
||||
// Stealth is always enabled in agent-browser-stealth
|
||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
|
||||
colorSchemeEnv === 'dark' ||
|
||||
colorSchemeEnv === 'light' ||
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
headless:
|
||||
process.env.AGENT_BROWSER_HEADED !== '1' &&
|
||||
process.env.AGENT_BROWSER_HEADED !== 'true',
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
extensions: extensions,
|
||||
storageState: process.env.AGENT_BROWSER_STATE,
|
||||
args,
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
proxy,
|
||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||
allowFileAccess: allowFileAccess,
|
||||
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0
|
||||
? tabGroupPluginId
|
||||
: undefined,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
};
|
||||
const launchOptions = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
let attachedToExistingBrowser = false;
|
||||
try {
|
||||
|
||||
@@ -57,6 +57,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({
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
engine?: 'chrome' | 'lightpanda'; // Browser engine selection; lightpanda requires native mode
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { BenchmarkCommand, Scenario } from "./scenarios.js";
|
||||
|
||||
function generateArticlePage(): string {
|
||||
const paragraphs = Array.from({ length: 30 }, (_, index) => {
|
||||
const words = Array.from(
|
||||
{ length: 40 + (index % 5) * 10 },
|
||||
(_, wordIndex) =>
|
||||
[
|
||||
"the",
|
||||
"quick",
|
||||
"browser",
|
||||
"engine",
|
||||
"renders",
|
||||
"content",
|
||||
"across",
|
||||
"multiple",
|
||||
"layout",
|
||||
"passes",
|
||||
"while",
|
||||
"handling",
|
||||
"style",
|
||||
"recalculations",
|
||||
"and",
|
||||
"DOM",
|
||||
"mutations",
|
||||
][wordIndex % 17],
|
||||
).join(" ");
|
||||
return `<p class="article-p">${words}</p>`;
|
||||
});
|
||||
|
||||
const comments = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) =>
|
||||
`<div class="comment" data-id="${index}">` +
|
||||
`<div class="comment-header"><span class="author">User ${index}</span><time>2025-01-${String((index % 28) + 1).padStart(2, "0")}</time></div>` +
|
||||
`<div class="comment-body"><p>This is comment number ${index + 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 },
|
||||
(_, index) =>
|
||||
`<li class="sidebar-item"><a href="#section-${index}">Related Article ${index + 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 }, (_, index) => `<a href="#nav-${index}">Section ${index + 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 }, (_, index) => `<span class="tag">tag-${index + 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 }, (_, index) => `<li><a href="#month-${index}">Month ${index + 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((cell) => `<th>${cell}</th>`).join("")}</tr>`;
|
||||
|
||||
const rows = Array.from({ length: 200 }, (_, index) => {
|
||||
const department = ["Engineering", "Design", "Marketing", "Sales", "Support"][index % 5];
|
||||
const role = ["Admin", "Manager", "Member", "Viewer"][index % 4];
|
||||
const status = ["Active", "Inactive", "Pending"][index % 3];
|
||||
return (
|
||||
`<tr data-row="${index}">` +
|
||||
`<td>${index + 1}</td>` +
|
||||
`<td><a href="#user-${index}">User ${index + 1}</a></td>` +
|
||||
`<td>user${index + 1}@example.com</td>` +
|
||||
`<td>${department}</td>` +
|
||||
`<td><span class="badge badge-${role.toLowerCase()}">${role}</span></td>` +
|
||||
`<td><span class="status status-${status.toLowerCase()}">${status}</span></td>` +
|
||||
`<td>2024-${String((index % 12) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}</td>` +
|
||||
`<td>${index % 3 === 0 ? "Today" : index % 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 }, (_, index) => `<button class="page-btn" data-page="${index + 1}">${index + 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 },
|
||||
(_, index) =>
|
||||
`<div class="node depth-${depth}" data-depth="${depth}" data-idx="${index}">` +
|
||||
`<div class="node-header"><strong>Section ${prefix}.${index + 1}</strong> <em>(depth ${depth})</em></div>` +
|
||||
`<div class="node-content">${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${index + 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 },
|
||||
(_, index) =>
|
||||
`<div class="card" data-card="${index}">` +
|
||||
`<div class="card-title">Metric ${index + 1}</div>` +
|
||||
`<div class="card-value">${Math.floor(Math.random() * 10000)}</div>` +
|
||||
`<div class="card-trend ${index % 2 === 0 ? "up" : "down"}">${index % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%</div>` +
|
||||
"</div>",
|
||||
);
|
||||
|
||||
const chartBars = Array.from({ length: 24 }, (_, index) => {
|
||||
const height = 20 + ((index * 7 + 13) % 80);
|
||||
return `<div class="bar" style="height:${height}%" data-hour="${index}"><span class="bar-label">${String(index).padStart(2, "0")}:00</span></div>`;
|
||||
});
|
||||
|
||||
const logRows = Array.from({ length: 100 }, (_, index) => {
|
||||
const level = ["INFO", "WARN", "ERROR", "DEBUG"][index % 4];
|
||||
return (
|
||||
`<tr class="log-${level.toLowerCase()}" data-log="${index}">` +
|
||||
`<td>${new Date(2025, 0, 1, index % 24, index % 60).toISOString()}</td>` +
|
||||
`<td><span class="level level-${level.toLowerCase()}">${level}</span></td>` +
|
||||
`<td>Service ${["auth", "api", "worker", "cache", "db"][index % 5]}</td>` +
|
||||
`<td>Log message number ${index + 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("");
|
||||
}
|
||||
|
||||
const ARTICLE_HTML = generateArticlePage();
|
||||
const TABLE_HTML = generateDataTablePage();
|
||||
const NESTED_HTML = generateNestedPage();
|
||||
const DASHBOARD_HTML = generateDashboardPage();
|
||||
|
||||
function injectCmd(id: string, html: string): BenchmarkCommand {
|
||||
return {
|
||||
action: "evaluate",
|
||||
id,
|
||||
script: `document.open(); document.write(${JSON.stringify(html)}); document.close(); 'ok'`,
|
||||
};
|
||||
}
|
||||
|
||||
function setupPage(html: string, tag: string): BenchmarkCommand[] {
|
||||
return [
|
||||
{ action: "navigate", id: `${tag}-nav`, url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
injectCmd(`${tag}-inject`, html),
|
||||
];
|
||||
}
|
||||
|
||||
export const engineScenarios: Scenario[] = [
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a realistic article page (~800 DOM nodes, 30 paragraphs, 40 comments)",
|
||||
name: "article-snapshot",
|
||||
setup: setupPage(ARTICLE_HTML, "art"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a data table with 200 rows and 8 columns",
|
||||
name: "table-snapshot",
|
||||
setup: setupPage(TABLE_HTML, "tbl"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a deeply nested DOM tree (7 levels, ~3000 nodes)",
|
||||
name: "nested-snapshot",
|
||||
setup: setupPage(NESTED_HTML, "nest"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot an operations dashboard with cards, chart, and 100 log rows",
|
||||
name: "dashboard-snap",
|
||||
setup: setupPage(DASHBOARD_HTML, "dash"),
|
||||
},
|
||||
{
|
||||
commands: [injectCmd("ai-write", ARTICLE_HTML)],
|
||||
description: "Write a full article page into the DOM (measures parse + layout)",
|
||||
name: "article-inject",
|
||||
setup: [{ action: "navigate", id: "ai-nav", url: "about:blank", waitUntil: "domcontentloaded" }],
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{
|
||||
action: "evaluate",
|
||||
id: "query",
|
||||
script: "document.querySelectorAll('tr[data-row]').length + ' rows, ' + document.querySelectorAll('td').length + ' cells'",
|
||||
},
|
||||
],
|
||||
description: "Evaluate a querySelectorAll across a large table",
|
||||
name: "table-query",
|
||||
setup: setupPage(TABLE_HTML, "tq"),
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{ action: "snapshot", id: "dw-snap" },
|
||||
{ action: "fill", id: "dw-fill", selector: "#dash-search", value: "error logs" },
|
||||
{ action: "click", id: "dw-click", selector: "#refresh" },
|
||||
{ action: "evaluate", id: "dw-eval", script: "document.querySelectorAll('.card').length + ' cards'" },
|
||||
{ action: "screenshot", id: "dw-ss" },
|
||||
],
|
||||
description: "Full agent workflow on complex dashboard: snapshot, click, fill, eval, screenshot",
|
||||
name: "dashboard-workflow",
|
||||
setup: setupPage(DASHBOARD_HTML, "dw"),
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{
|
||||
action: "evaluate",
|
||||
id: "walk",
|
||||
script: "(function(){let c=0;const w=n=>{c++;for(const ch of n.children)w(ch);};w(document.body);return c+' nodes';})()",
|
||||
},
|
||||
],
|
||||
description: "Recursive DOM traversal via evaluate on deeply nested tree",
|
||||
name: "nested-eval",
|
||||
setup: setupPage(NESTED_HTML, "ne"),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,222 @@
|
||||
<!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 smooth 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 trace the critical rendering path, review optimization strategies, and explain 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 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 style rules that apply to the document. This includes user-agent styles, author styles, and inline styles.</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, calculates 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 because changes to one element can cascade through the rest of the tree.</p>
|
||||
|
||||
<blockquote>"The fastest code is code that does not run. The fastest layout is layout that does not need to happen."</blockquote>
|
||||
|
||||
<h2>DOM Construction and Tree Building</h2>
|
||||
|
||||
<p>The DOM is a tree-structured representation of the HTML document. Each node corresponds to an element, text node, comment, or other construct in the HTML. The tree preserves hierarchical relationships between elements, allowing efficient traversal and manipulation.</p>
|
||||
|
||||
<p>Modern parsers handle malformed HTML gracefully through error recovery algorithms specified in the HTML standard. This includes automatic closing of unclosed tags, adoption of misplaced elements, and reconstruction of formatting element lists.</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 that 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 may only require a repaint, while changing its width could trigger a full relayout.</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 garbage collected when no longer reachable. Detached DOM trees, subtrees removed from the document but still referenced by JavaScript, are a common source of memory leaks in web applications.</p>
|
||||
|
||||
<p>Browser engines use string interning for common values, node pools for rapid allocation, and lazy initialization of rarely accessed properties to minimize memory overhead.</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 fast prefilters to 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 and working backward through ancestors.</p>
|
||||
|
||||
<p>The cascade algorithm resolves conflicts between competing declarations by considering origin, specificity, and source order. Custom properties add another layer of complexity because they must be resolved during the cascade before they can be used in property values.</p>
|
||||
|
||||
<pre><code>.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 converts the styled render tree into positioned boxes with concrete pixel dimensions. Different layout modes, including block, inline, flex, grid, and table, each use their own algorithm for determining element sizes and positions.</p>
|
||||
|
||||
<p>Flexbox layout involves multiple passes: computing the flex basis of each item, distributing free space according to flex-grow and flex-shrink factors, and then positioning items along the cross axis. This 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 placement algorithm must resolve conflicts between explicitly placed and auto-placed items while respecting sizing constraints.</p>
|
||||
|
||||
<h2>Paint and Compositing</h2>
|
||||
|
||||
<p>After layout, the browser paints the visual representation of each element. This includes drawing backgrounds, borders, text, images, shadows, and other 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, such as animations, scrolling regions, and video, are promoted to their own compositing layers. These layers can be updated independently and combined on the GPU.</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 often handled directly by the compositor.</p>
|
||||
|
||||
<h2>JavaScript Engine Integration</h2>
|
||||
|
||||
<p>The JavaScript engine is tightly integrated with the browser rendering pipeline. Script execution can trigger style recalculation, layout, and paint through DOM manipulation and CSSOM access. The browser must balance script execution with maintaining smooth rendering.</p>
|
||||
|
||||
<p>Modern engines use just-in-time 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.</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.</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.</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 useful.' :
|
||||
i % 3 === 1 ?
|
||||
'Thanks for the detailed breakdown. The invalidation notes were helpful.' :
|
||||
'This clarified several misconceptions I had about GPU acceleration.') +
|
||||
'</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>
|
||||
</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,234 @@
|
||||
<!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; }
|
||||
.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; }
|
||||
.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; }
|
||||
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() {
|
||||
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(metric) {
|
||||
var sparkBars = '';
|
||||
for (var index = 0; index < 12; index++) {
|
||||
var height = 20 + Math.floor(Math.random() * 80);
|
||||
sparkBars += '<div class="bar" style="height:' + height + '%"></div>';
|
||||
}
|
||||
grid.innerHTML += '<div class="card"><div class="card-label">' + metric.label +
|
||||
'</div><div class="card-value">' + metric.value +
|
||||
'</div><div class="card-trend ' + (metric.up ? 'up' : 'down') + '">' +
|
||||
(metric.up ? '+' : '') + metric.trend +
|
||||
'</div><div class="card-sparkline">' + sparkBars + '</div></div>';
|
||||
});
|
||||
|
||||
var chart = document.getElementById('chart');
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
var height = 15 + ((hour * 17 + 7) % 85);
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
bar.style.height = height + '%';
|
||||
bar.innerHTML = '<span class="bar-label">' + String(hour).padStart(2, '0') + ':00</span>';
|
||||
chart.appendChild(bar);
|
||||
}
|
||||
|
||||
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'];
|
||||
paths.forEach(function(pathName, index) {
|
||||
endpoints.innerHTML += '<tr><td><code>' + pathName + '</code></td><td>' +
|
||||
(50000 - index * 3000) + '</td><td>' + (45 + index * 12) + 'ms</td><td>' +
|
||||
(0.1 + index * 0.08).toFixed(2) + '%</td></tr>';
|
||||
});
|
||||
|
||||
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']
|
||||
];
|
||||
alertData.forEach(function(alert, index) {
|
||||
var badge = alert[1] === 'error' ? 'badge-error' : alert[1] === 'warn' ? 'badge-warn' : 'badge-info';
|
||||
alerts.innerHTML += '<tr><td>' + alert[0] + '</td><td><span class="badge ' + badge + '">' +
|
||||
alert[1].toUpperCase() + '</span></td><td>' + alert[2] + '</td><td>' + (index * 15 + 5) + 'm ago</td></tr>';
|
||||
});
|
||||
|
||||
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'
|
||||
];
|
||||
for (var i = 0; i < 160; 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 % 8] + ' #' + (i + 1) +
|
||||
'</td><td>' + (Math.floor(Math.random() * 500) + 10) + 'ms</td></tr>';
|
||||
}
|
||||
|
||||
var pagination = document.getElementById('pagination');
|
||||
for (var page = 1; page <= 10; page++) {
|
||||
pagination.innerHTML += '<button class="' + (page === 1 ? 'active' : '') + '">' + page + '</button>';
|
||||
}
|
||||
|
||||
var servicesTable = document.getElementById('services-table');
|
||||
var serviceNames = ['api-gateway', 'auth-service', 'payment-processor', 'search-engine',
|
||||
'notification-hub', 'analytics-pipeline', 'cache-layer', 'worker-pool'];
|
||||
serviceNames.forEach(function(name, index) {
|
||||
var status = index < 6 ? 'active' : index === 6 ? 'pending' : 'inactive';
|
||||
var badge = status === 'active' ? 'badge-active' : status === 'pending' ? 'badge-pending' : 'badge-inactive';
|
||||
servicesTable.innerHTML += '<tr><td><strong>' + name + '</strong></td>' +
|
||||
'<td><span class="badge ' + badge + '">' + status + '</span></td>' +
|
||||
'<td>' + (99.9 - index * 0.05).toFixed(2) + '%</td>' +
|
||||
'<td>' + (30 + index * 5) + '%</td>' +
|
||||
'<td>' + (512 + index * 128) + 'MB</td>' +
|
||||
'<td>' + (2000 - index * 150) + '</td>' +
|
||||
'<td>' + (0.1 + index * 0.04).toFixed(2) + '%</td>' +
|
||||
'<td>' + (index + 1) + 'h ago</td></tr>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!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; }
|
||||
.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.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></div>
|
||||
<div><h4>Support</h4><a href="#">Help Center</a><a href="#">Returns</a><a href="#">Warranty</a><a href="#">Contact Us</a></div>
|
||||
<div><h4>Company</h4><a href="#">About Us</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Blog</a></div>
|
||||
<div><h4>Connect</h4><a href="#">Newsletter</a><a href="#">Social Media</a><a href="#">Affiliate Program</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'];
|
||||
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 category = 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] + '">' +
|
||||
category.charAt(0).toUpperCase() + '</div><div class="product-info">' +
|
||||
'<div class="product-brand">' + brand + '</div>' +
|
||||
'<div class="product-name">' + brand + ' ' + category + ' 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 j = 16; j < 32; j++) bestsellers.innerHTML += makeProduct(j);
|
||||
|
||||
var newArrivals = document.getElementById('newarrivals-grid');
|
||||
for (var k = 32; k < 48; k++) newArrivals.innerHTML += makeProduct(k);
|
||||
|
||||
var reviewsEl = document.getElementById('reviews');
|
||||
var names = ['Alice M.', 'Bob K.', 'Carol S.', 'David L.', 'Eva R.', 'Frank W.'];
|
||||
var reviewTexts = [
|
||||
'Excellent product, arrived faster than expected. Build quality is outstanding.',
|
||||
'Good value for money. Reliable device and easy to set up.',
|
||||
'Battery life could be better, but performance is strong.',
|
||||
'Amazing quality. This is my third purchase from this brand.'
|
||||
];
|
||||
for (var idx = 0; idx < 16; idx++) {
|
||||
reviewsEl.innerHTML += '<div class="review"><div class="review-header"><div><span class="review-author">' +
|
||||
names[idx % names.length] + '</span></div><span class="review-date">March ' + (15 - idx % 15) + ', 2025</span></div>' +
|
||||
'<div class="review-body">' + reviewTexts[idx % 4] + '</div></div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
export interface BenchmarkCommand {
|
||||
id: string;
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Commands to run once before measured iterations (e.g. navigate to a page). */
|
||||
setup?: BenchmarkCommand[];
|
||||
/** The commands whose total execution time is measured per iteration. */
|
||||
commands: BenchmarkCommand[];
|
||||
/** Commands to run once after measured iterations (e.g. cleanup). */
|
||||
teardown?: BenchmarkCommand[];
|
||||
}
|
||||
|
||||
const FORM_HTML = [
|
||||
"<html><head><title>Bench</title></head><body>",
|
||||
"<h1>Benchmark Page</h1>",
|
||||
"<input id='name' type='text' placeholder='Name'>",
|
||||
"<input id='email' type='email' placeholder='Email'>",
|
||||
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
|
||||
"<input id='agree' type='checkbox'>",
|
||||
"<textarea id='bio' placeholder='Bio'></textarea>",
|
||||
"<button id='submit'>Submit</button>",
|
||||
"<p id='status'>Ready</p>",
|
||||
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
|
||||
"<ul>",
|
||||
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
|
||||
"</ul>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
|
||||
const INJECT_FORM: BenchmarkCommand = {
|
||||
id: "inject",
|
||||
action: "evaluate",
|
||||
script: `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`,
|
||||
};
|
||||
|
||||
const SETUP_PAGE: BenchmarkCommand[] = [
|
||||
{ id: "setup-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
INJECT_FORM,
|
||||
];
|
||||
|
||||
export const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "navigate",
|
||||
description: "Page navigation (about:blank round-trip)",
|
||||
commands: [
|
||||
{ id: "nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "snapshot",
|
||||
description: "DOM snapshot (accessibility tree)",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Screenshot capture",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "ss", action: "screenshot" }],
|
||||
},
|
||||
{
|
||||
name: "evaluate",
|
||||
description: "JavaScript evaluation",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [
|
||||
{
|
||||
id: "eval",
|
||||
action: "evaluate",
|
||||
script: "document.title + ' ' + document.querySelectorAll('li').length",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "click",
|
||||
description: "Element click interaction",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "clk", action: "click", selector: "#link" }],
|
||||
},
|
||||
{
|
||||
name: "fill",
|
||||
description: "Form field fill",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "fill", action: "fill", selector: "#name", value: "Benchmark User" }],
|
||||
},
|
||||
{
|
||||
name: "tabs",
|
||||
description: "Tab new + list + switch",
|
||||
commands: [
|
||||
{ id: "tnew", action: "tab_new", url: "about:blank" },
|
||||
{ id: "tlist", action: "tab_list" },
|
||||
{ id: "tswitch", action: "tab_switch", index: 0 },
|
||||
],
|
||||
teardown: [{ id: "tclose", action: "tab_close", index: 1 }],
|
||||
},
|
||||
{
|
||||
name: "full-workflow",
|
||||
description: "Realistic agent workflow: navigate, snapshot, click, fill, evaluate, screenshot",
|
||||
commands: [
|
||||
{ id: "w-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
INJECT_FORM,
|
||||
{ id: "w-snap", action: "snapshot" },
|
||||
{ id: "w-click", action: "click", selector: "#link" },
|
||||
{ id: "w-fill", action: "fill", selector: "#name", value: "Agent User" },
|
||||
{ id: "w-eval", action: "evaluate", script: "document.getElementById('name').value" },
|
||||
{ id: "w-ss", action: "screenshot" },
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user