Compare commits

..
1 Commits
Author SHA1 Message Date
Chris Tate 8b4f969219 v0.5.0 2026-01-13 21:48:18 -06:00
20 changed files with 123 additions and 1541 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agent-browser",
"description": "Headless browser automation for AI agents",
"owner": {
"name": "Vercel",
"email": "support@vercel.com"
},
"plugins": [
{
"name": "agent-browser",
"description": "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
"source": "./",
"strict": false,
"skills": ["./skills/agent-browser"],
"category": "development"
}
]
}
-1
View File
@@ -5,7 +5,6 @@ Instructions for AI coding agents working with this codebase.
## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
<!-- opensrc:start -->
+6 -11
View File
@@ -74,11 +74,10 @@ agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
agent-browser scrollintoview <sel> # Scroll element into view (alias: scrollinto)
agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files
agent-browser screenshot [path] # Take screenshot (--full for full page, base64 png to stdout if no path)
agent-browser screenshot [path] # Take screenshot (--full for full page)
agent-browser pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript
agent-browser connect <port> # Connect to browser via CDP
agent-browser close # Close browser (aliases: quit, exit)
```
@@ -466,16 +465,12 @@ export async function handler() {
Connect to an existing browser via Chrome DevTools Protocol:
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
# Connect to Electron app
agent-browser --cdp 9222 snapshot
# Connect to Chrome with remote debugging
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
agent-browser --cdp 9222 open about:blank
```
This enables control of:
-158
View File
@@ -1,158 +0,0 @@
//! Color output utilities respecting NO_COLOR environment variable.
//!
//! When the NO_COLOR environment variable is present (regardless of value),
//! all color formatting is disabled per https://no-color.org/
use std::env;
use std::sync::OnceLock;
/// Returns true if color output is enabled (NO_COLOR is NOT set)
pub fn is_enabled() -> bool {
static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
*COLORS_ENABLED.get_or_init(|| env::var("NO_COLOR").is_err())
}
/// Format text in red (errors)
pub fn red(text: &str) -> String {
if is_enabled() {
format!("\x1b[31m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in green (success)
pub fn green(text: &str) -> String {
if is_enabled() {
format!("\x1b[32m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in yellow (warnings)
pub fn yellow(text: &str) -> String {
if is_enabled() {
format!("\x1b[33m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in cyan (info/progress)
pub fn cyan(text: &str) -> String {
if is_enabled() {
format!("\x1b[36m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in bold
pub fn bold(text: &str) -> String {
if is_enabled() {
format!("\x1b[1m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Format text in dim
pub fn dim(text: &str) -> String {
if is_enabled() {
format!("\x1b[2m{}\x1b[0m", text)
} else {
text.to_string()
}
}
/// Red X error indicator
pub fn error_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[31m✗\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Green checkmark success indicator
pub fn success_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[32m✓\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Yellow warning indicator
pub fn warning_indicator() -> &'static str {
static INDICATOR: OnceLock<String> = OnceLock::new();
INDICATOR.get_or_init(|| {
if is_enabled() {
"\x1b[33m⚠\x1b[0m".to_string()
} else {
"".to_string()
}
})
}
/// Get console log color prefix by level
pub fn console_level_prefix(level: &str) -> String {
if !is_enabled() {
return format!("[{}]", level);
}
let color = match level {
"error" => "\x1b[31m",
"warning" => "\x1b[33m",
"info" => "\x1b[36m",
_ => "",
};
if color.is_empty() {
format!("[{}]", level)
} else {
format!("{}[{}]\x1b[0m", color, level)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_red_contains_ansi_codes() {
// Test the format structure (actual color depends on NO_COLOR env)
let formatted = format!("\x1b[31m{}\x1b[0m", "error");
assert!(formatted.contains("\x1b[31m"));
assert!(formatted.contains("\x1b[0m"));
}
#[test]
fn test_green_contains_ansi_codes() {
let formatted = format!("\x1b[32m{}\x1b[0m", "success");
assert!(formatted.contains("\x1b[32m"));
}
#[test]
fn test_console_level_prefix_contains_level() {
// Regardless of color state, the level text should be present
assert!(console_level_prefix("error").contains("error"));
assert!(console_level_prefix("warning").contains("warning"));
assert!(console_level_prefix("info").contains("info"));
assert!(console_level_prefix("log").contains("log"));
}
#[test]
fn test_indicators_contain_symbols() {
// Regardless of color state, symbols should be present
assert!(error_indicator().contains('✗'));
assert!(success_indicator().contains('✓'));
assert!(warning_indicator().contains('⚠'));
}
}
+26 -343
View File
@@ -75,12 +75,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: cmd.to_string(),
usage: "open <url>",
})?;
let url_lower = url.to_lowercase();
let url = if url_lower.starts_with("http://")
|| url_lower.starts_with("https://")
|| url_lower.starts_with("about:")
|| url_lower.starts_with("data:")
|| url_lower.starts_with("file:") {
let url = if url.starts_with("http") {
url.to_string()
} else {
format!("https://{}", url)
@@ -158,18 +153,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"select" => {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value...>",
usage: "select <selector> <value>",
})?;
let _val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
let val = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(),
usage: "select <selector> <value...>",
usage: "select <selector> <value>",
})?;
let values = &rest[1..];
if values.len() == 1 {
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values[0] }))
} else {
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values }))
}
Ok(json!({ "id": id, "action": "select", "selector": sel, "value": val }))
}
"drag" => {
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -283,11 +273,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Screenshot/PDF ===
"screenshot" => {
let mut cmd = json!({ "id": id, "action": "screenshot", "fullPage": flags.full });
if let Some(path) = rest.get(0) {
cmd["path"] = json!(path);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full }))
}
"pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -337,19 +323,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Connect (CDP) ===
"connect" => {
let port_str = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "connect".to_string(),
usage: "connect <port>",
})?;
let port: u16 = port_str.parse().map_err(|_| ParseError::MissingArguments {
context: format!("connect: invalid port '{}'", port_str),
usage: "connect <port>",
})?;
Ok(json!({ "id": id, "action": "launch", "cdpPort": port }))
}
// === Get ===
"get" => parse_get(&rest, &id),
@@ -394,20 +367,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Tabs ===
"tab" => {
match rest.get(0).map(|s| *s) {
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
}
Some("new") => Ok(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })),
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::<i32>().ok()) }))
}
Some(n) if n.parse::<i32>().is_ok() => {
Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().unwrap() }))
@@ -435,7 +398,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Frame ===
"frame" => {
if rest.get(0).map(|s| *s) == Some("main") {
Ok(json!({ "id": id, "action": "mainframe" }))
Ok(json!({ "id": id, "action": "frame_main" }))
} else {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "frame".to_string(),
@@ -450,11 +413,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
const VALID: &[&str] = &["accept", "dismiss"];
match rest.get(0).map(|s| *s) {
Some("accept") => {
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" });
if let Some(prompt_text) = rest.get(1) {
cmd["promptText"] = json!(prompt_text);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) }))
}
Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -472,14 +431,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"trace" => {
const VALID: &[&str] = &["start", "stop"];
match rest.get(0).map(|s| *s) {
Some("start") => Ok(json!({ "id": id, "action": "trace_start" })),
Some("stop") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "trace stop".to_string(),
usage: "trace stop <path>",
})?;
Ok(json!({ "id": id, "action": "trace_stop", "path": path }))
},
Some("start") => Ok(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })),
Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
@@ -490,60 +443,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}),
}
}
// === Recording (Playwright native video recording) ===
"record" => {
const VALID: &[&str] = &["start", "stop", "restart"];
match rest.get(0).map(|s| *s) {
Some("start") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "record start".to_string(),
usage: "record start <output.webm> [url]",
})?;
// Optional URL parameter
let url = rest.get(2);
let mut cmd = json!({ "id": id, "action": "recording_start", "path": path });
if let Some(u) = url {
// Add https:// prefix if needed
let url_str = if u.starts_with("http") {
u.to_string()
} else {
format!("https://{}", u)
};
cmd["url"] = json!(url_str);
}
Ok(cmd)
}
Some("stop") => Ok(json!({ "id": id, "action": "recording_stop" })),
Some("restart") => {
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "record restart".to_string(),
usage: "record restart <output.webm> [url]",
})?;
// Optional URL parameter
let url = rest.get(2);
let mut cmd = json!({ "id": id, "action": "recording_restart", "path": path });
if let Some(u) = url {
// Add https:// prefix if needed
let url_str = if u.starts_with("http") {
u.to_string()
} else {
format!("https://{}", u)
};
cmd["url"] = json!(url_str);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "record".to_string(),
usage: "record <start|stop|restart> [path] [url]",
}),
}
}
"console" => {
let clear = rest.iter().any(|&s| s == "--clear");
Ok(json!({ "id": id, "action": "console", "clear": clear }))
@@ -596,7 +495,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"];
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box"];
match rest.get(0).map(|s| *s) {
Some("text") => {
@@ -647,20 +546,13 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel }))
}
Some("styles") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "get styles".to_string(),
usage: "get styles <selector>",
})?;
Ok(json!({ "id": id, "action": "styles", "selector": sel }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
usage: "get <text|html|value|attr|url|title|count|box> [args...]",
}),
}
}
@@ -738,39 +630,15 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
};
match *locator {
"role" => {
let mut cmd = json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "name": name, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"role" => Ok(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })),
"text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })),
"label" => {
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"placeholder" => {
let mut cmd = json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"label" => Ok(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })),
"alt" => Ok(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })),
"title" => Ok(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })),
"testid" => {
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"first" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"last" => {
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
Ok(cmd)
}
"testid" => Ok(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })),
"first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })),
"last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })),
_ => unreachable!(),
}
}
@@ -793,9 +661,7 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
None
};
let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub });
if let Some(v) = fv { cmd["value"] = json!(v); }
Ok(cmd)
Ok(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv }))
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(),
@@ -836,7 +702,7 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("wheel") => {
let dy = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(100);
let dx = rest.get(2).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
Ok(json!({ "id": id, "action": "wheel", "deltaX": dx, "deltaY": dy }))
Ok(json!({ "id": id, "action": "mousewheel", "deltaX": dx, "deltaY": dy }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -934,12 +800,8 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
"no-preference"
};
let reduced = if rest.iter().any(|&s| s == "reduced-motion") {
"reduce"
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
let reduced = rest.iter().any(|&s| s == "reduced-motion");
Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -966,22 +828,12 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
let body = body_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
Ok(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body }))
}
Some("unroute") => {
let mut cmd = json!({ "id": id, "action": "unroute" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
},
Some("unroute") => Ok(json!({ "id": id, "action": "unroute", "url": rest.get(1) })),
Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
if let Some(f) = filter {
cmd["filter"] = json!(f);
}
Ok(cmd)
Ok(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -1051,7 +903,6 @@ mod tests {
executable_path: None,
extensions: Vec::new(),
cdp: None,
proxy: None,
}
}
@@ -1296,46 +1147,12 @@ mod tests {
assert_eq!(cmd["text"], "some text");
}
#[test]
fn test_select() {
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], "option1");
}
#[test]
fn test_select_multiple_values() {
let cmd = parse_command(
&args("select #menu opt1 opt2 opt3"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "select");
assert_eq!(cmd["selector"], "#menu");
assert_eq!(cmd["values"], json!(["opt1", "opt2", "opt3"]));
}
#[test]
fn test_frame_main() {
let cmd = parse_command(&args("frame main"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "mainframe");
}
// === Tabs ===
#[test]
fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert!(cmd.get("url").is_none(), "url should not be present when not provided");
}
#[test]
fn test_tab_new_with_url() {
let cmd = parse_command(&args("tab new https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
@@ -1363,14 +1180,6 @@ mod tests {
fn test_screenshot() {
let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert!(cmd.get("path").is_none());
}
#[test]
fn test_screenshot_path() {
let cmd = parse_command(&args("screenshot out.png"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["path"], "out.png");
}
#[test]
@@ -1464,82 +1273,6 @@ mod tests {
// === Unknown command ===
// === Record Tests ===
#[test]
fn test_record_start() {
let cmd = parse_command(&args("record start output.webm"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "output.webm");
assert!(cmd.get("url").is_none());
}
#[test]
fn test_record_start_with_url() {
let cmd = parse_command(&args("record start demo.webm https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_start_with_url_no_protocol() {
let cmd = parse_command(&args("record start demo.webm example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_start");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_start_missing_path() {
let result = parse_command(&args("record start"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_record_stop() {
let cmd = parse_command(&args("record stop"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_stop");
}
#[test]
fn test_record_restart() {
let cmd = parse_command(&args("record restart output.webm"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_restart");
assert_eq!(cmd["path"], "output.webm");
assert!(cmd.get("url").is_none());
}
#[test]
fn test_record_restart_with_url() {
let cmd = parse_command(&args("record restart demo.webm https://example.com"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "recording_restart");
assert_eq!(cmd["path"], "demo.webm");
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_record_restart_missing_path() {
let result = parse_command(&args("record restart"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_record_invalid_subcommand() {
let result = parse_command(&args("record foo"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownSubcommand { .. }));
}
#[test]
fn test_record_missing_subcommand() {
let result = parse_command(&args("record"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
}
#[test]
fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags());
@@ -1583,54 +1316,4 @@ mod tests {
assert!(matches!(err, ParseError::MissingArguments { .. }));
assert!(err.format().contains("get text"));
}
// === Protocol alignment tests ===
#[test]
fn test_mouse_wheel() {
let cmd = parse_command(&args("mouse wheel 100 50"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "wheel");
assert_eq!(cmd["deltaY"], 100);
assert_eq!(cmd["deltaX"], 50);
}
#[test]
fn test_set_media() {
let cmd = parse_command(&args("set media dark"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "dark");
assert_eq!(cmd["reducedMotion"], "no-preference");
}
#[test]
fn test_set_media_reduced_motion() {
let cmd = parse_command(&args("set media light reduced-motion"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "emulatemedia");
assert_eq!(cmd["colorScheme"], "light");
assert_eq!(cmd["reducedMotion"], "reduce");
}
#[test]
fn test_find_first_no_value() {
let cmd = parse_command(&args("find first a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert!(cmd.get("value").is_none());
}
#[test]
fn test_find_first_with_value() {
let cmd = parse_command(&args("find first input fill hello"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 0);
assert_eq!(cmd["value"], "hello");
}
#[test]
fn test_find_nth_no_value() {
let cmd = parse_command(&args("find nth 2 a click"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "nth");
assert_eq!(cmd["index"], 2);
assert!(cmd.get("value").is_none());
}
}
+11 -18
View File
@@ -104,9 +104,7 @@ fn get_port_for_session(session: &str) -> u16 {
for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
}
// Correct logic: first take absolute modulo, then cast to u16
// Using unsigned_abs() to safely handle i32::MIN
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
49152 + ((hash.abs() as u16) % 16383)
}
#[cfg(unix)]
@@ -142,8 +140,7 @@ fn is_daemon_running(session: &str) -> bool {
fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
let socket_path = get_socket_path(session);
UnixStream::connect(&socket_path).is_ok()
get_socket_path(session).exists()
}
#[cfg(windows)]
{
@@ -177,23 +174,16 @@ pub fn ensure_daemon(
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
let daemon_paths = [
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
.ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
@@ -237,10 +227,13 @@ 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)
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
// Pass the entire command as a single string to /c to handle paths with spaces.
let cmd_string = format!("node \"{}\"", daemon_path.display());
let mut cmd = Command::new("cmd");
cmd.arg("/c")
.arg(&cmd_string)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
+1 -9
View File
@@ -10,7 +10,6 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub proxy: Option<String>,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -29,7 +28,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None,
extensions: extensions_env,
proxy: None,
};
let mut i = 0;
@@ -69,12 +67,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
i += 1;
}
}
_ => {}
}
i += 1;
@@ -89,7 +81,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension"];
for arg in args.iter() {
if skip_next {
+13 -32
View File
@@ -1,4 +1,3 @@
use crate::color;
use std::process::{exit, Command, Stdio};
pub fn run_install(with_deps: bool) {
@@ -6,15 +5,9 @@ pub fn run_install(with_deps: bool) {
if is_linux {
if with_deps {
println!("{}", color::cyan("Installing system dependencies..."));
println!("\x1b[36mInstalling system dependencies...\x1b[0m");
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
@@ -37,7 +30,7 @@ pub fn run_install(with_deps: bool) {
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libasound2",
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
@@ -100,7 +93,7 @@ pub fn run_install(with_deps: bool) {
],
)
} else {
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)");
exit(1);
};
@@ -119,23 +112,22 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
println!("\x1b[32m✓\x1b[0m System dependencies installed")
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
"\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e),
}
} else {
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:");
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
@@ -152,23 +144,23 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("{} Chromium installed successfully", color::success_indicator());
println!("\x1b[32m✓\x1b[0m Chromium installed successfully");
if is_linux && !with_deps {
println!();
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:");
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
if is_linux {
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:");
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1);
}
@@ -197,14 +189,3 @@ fn which_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
}
fn package_exists_apt(pkg: &str) -> bool {
Command::new("apt-cache")
.arg("show")
.arg(pkg)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+21 -130
View File
@@ -1,4 +1,3 @@
mod color;
mod commands;
mod connection;
mod flags;
@@ -22,37 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, send_command};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response, print_version};
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
return json!({ "server": proxy_str });
};
let protocol = &proxy_str[..protocol_end + 3];
let rest = &proxy_str[protocol_end + 3..];
let Some(at_pos) = rest.rfind('@') else {
return json!({ "server": proxy_str });
};
let creds = &rest[..at_pos];
let server_part = &rest[at_pos + 1..];
let server = format!("{}{}", protocol, server_part);
let Some(colon_pos) = creds.find(':') else {
return json!({
"server": server,
"username": creds,
"password": ""
});
};
json!({
"server": server,
"username": &creds[..colon_pos],
"password": &creds[colon_pos + 1..]
})
}
use output::{print_command_help, print_help, print_response};
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -108,7 +77,7 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session { color::cyan("") } else { " ".to_string() };
let marker = if s == session { "" } else { " " };
println!("{} {}", marker, s);
}
}
@@ -125,18 +94,16 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
}
fn main() {
// Ignore SIGPIPE to prevent panic when piping to head/tail
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
if clean.is_empty() {
print_help();
return;
}
if has_help {
if let Some(cmd) = clean.get(0) {
@@ -148,16 +115,6 @@ fn main() {
return;
}
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
// Handle install separately
if clean.get(0).map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
@@ -186,7 +143,7 @@ fn main() {
error_type
);
} else {
eprintln!("{}", color::red(&e.format()));
eprintln!("\x1b[31m{}\x1b[0m", e.format());
}
exit(1);
}
@@ -198,7 +155,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
eprintln!("\x1b[31m✗\x1b[0m {}", e);
}
exit(1);
}
@@ -208,10 +165,10 @@ fn main() {
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json {
if flags.executable_path.is_some() {
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
}
if !flags.extensions.is_empty() {
eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator());
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
}
}
}
@@ -224,7 +181,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
}
exit(1);
}
@@ -233,7 +190,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
}
exit(1);
}
@@ -243,7 +200,7 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
}
exit(1);
}
@@ -265,30 +222,23 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
}
exit(1);
}
}
// Launch headed browser or proxy if flags are set (without CDP)
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
let mut launch_cmd = json!({
// Launch headed browser if --headed flag is set (without CDP)
if flags.headed && flags.cdp.is_none() {
let launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": !flags.headed
"headless": false
});
if let Some(ref proxy_str) = flags.proxy {
let proxy_obj = parse_proxy(proxy_str);
launch_cmd.as_object_mut()
.expect("json! macro guarantees object type")
.insert("proxy".to_string(), proxy_obj);
}
if let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json {
eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e);
eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
}
}
}
@@ -305,68 +255,9 @@ fn main() {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
eprintln!("\x1b[31m✗\x1b[0m {}", e);
}
exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert!(result.get("username").is_none());
assert!(result.get("password").is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "pass");
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "");
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result["server"], "proxy.com:8080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert!(result.get("username").is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result["server"], "socks5://proxy.com:1080");
assert_eq!(result["username"], "admin");
assert_eq!(result["password"], "secret");
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result["server"], "http://proxy.com:8080");
assert_eq!(result["username"], "user");
assert_eq!(result["password"], "p@ss:w0rd");
}
}
+21 -160
View File
@@ -1,4 +1,3 @@
use crate::color;
use crate::connection::Response;
pub fn print_response(resp: &Response, json_mode: bool) {
@@ -9,8 +8,7 @@ pub fn print_response(resp: &Response, json_mode: bool) {
if !resp.success {
eprintln!(
"{} {}",
color::error_indicator(),
"\x1b[31m✗\x1b[0m {}",
resp.error.as_deref().unwrap_or("Unknown error")
);
return;
@@ -20,8 +18,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
// Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} {}", color::success_indicator(), color::bold(title));
println!(" {}", color::dim(url));
println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title);
println!("\x1b[2m {}\x1b[0m", url);
return;
}
println!("{}", url);
@@ -87,17 +85,23 @@ pub fn print_response(resp: &Response, json_mode: bool) {
.unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active { color::cyan("") } else { " ".to_string() };
let marker = if active { "" } else { " " };
println!("{} [{}] {} - {}", marker, i, title, url);
}
return;
}
// Console logs
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
if let Some(logs) = data.get("logs").and_then(|v| v.as_array()) {
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {}", color::console_level_prefix(level), text);
let color = match level {
"error" => "\x1b[31m",
"warning" => "\x1b[33m",
"info" => "\x1b[36m",
_ => "\x1b[0m",
};
println!("{}[{}]\x1b[0m {}", color, level, text);
}
return;
}
@@ -105,7 +109,7 @@ pub fn print_response(resp: &Response, json_mode: bool) {
if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) {
for err in errors {
let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {}", color::error_indicator(), msg);
println!("\x1b[31m✗\x1b[0m {}", msg);
}
return;
}
@@ -118,27 +122,6 @@ pub fn print_response(resp: &Response, json_mode: bool) {
}
return;
}
// Network requests
if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) {
if requests.is_empty() {
println!("No requests captured");
} else {
for req in requests {
let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("");
let resource_type = req.get("resourceType").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {} ({})", method, url, resource_type);
}
}
return;
}
// Cleared requests
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
if cleared {
println!("\x1b[32m✓\x1b[0m Request log cleared");
return;
}
}
// Bounding box
if let Some(box_data) = data.get("box") {
println!(
@@ -147,91 +130,18 @@ pub fn print_response(resp: &Response, json_mode: bool) {
);
return;
}
// Element styles
if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) {
for (i, el) in elements.iter().enumerate() {
let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?");
let text = el.get("text").and_then(|v| v.as_str()).unwrap_or("");
println!("[{}] {} \"{}\"", i, tag, text);
if let Some(box_data) = el.get("box") {
let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
let x = box_data.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
println!(" box: {}x{} at ({}, {})", w, h, x, y);
}
if let Some(styles) = el.get("styles") {
let font_size = styles.get("fontSize").and_then(|v| v.as_str()).unwrap_or("");
let font_weight = styles.get("fontWeight").and_then(|v| v.as_str()).unwrap_or("");
let font_family = styles.get("fontFamily").and_then(|v| v.as_str()).unwrap_or("");
let color = styles.get("color").and_then(|v| v.as_str()).unwrap_or("");
let bg = styles.get("backgroundColor").and_then(|v| v.as_str()).unwrap_or("");
let radius = styles.get("borderRadius").and_then(|v| v.as_str()).unwrap_or("");
println!(" font: {} {} {}", font_size, font_weight, font_family);
println!(" color: {}", color);
println!(" background: {}", bg);
if radius != "0px" {
println!(" border-radius: {}", radius);
}
}
println!();
}
return;
}
// Closed
if data.get("closed").is_some() {
println!("{} Browser closed", color::success_indicator());
println!("\x1b[32m✓\x1b[0m Browser closed");
return;
}
// Recording start (has "started" field)
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
if started {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Recording started: {}", color::success_indicator(), path);
} else {
println!("{} Recording started", color::success_indicator());
}
return;
}
}
// Recording restart (has "stopped" field - from recording_restart action)
if data.get("stopped").is_some() {
let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("unknown");
if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) {
println!("{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path);
} else {
println!("{} Recording started: {}", color::success_indicator(), path);
}
return;
}
// Recording stop (has "frames" field - from recording_stop action)
if data.get("frames").is_some() {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
if let Some(error) = data.get("error").and_then(|v| v.as_str()) {
println!("{} Recording saved to {} - {}", color::warning_indicator(), path, error);
} else {
println!("{} Recording saved to {}", color::success_indicator(), path);
}
} else {
println!("{} Recording stopped", color::success_indicator());
}
return;
}
// Screenshot path (no "started" or "frames" field)
// Screenshot path
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path));
return;
}
// Screenshot base64
if let Some(base64) = data.get("base64").and_then(|v| v.as_str()) {
println!("{}", base64);
println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path);
return;
}
// Default success
println!("{} Done", color::success_indicator());
println!("\x1b[32m✓\x1b[0m Done");
}
}
@@ -440,9 +350,9 @@ Examples:
"select" => r##"
agent-browser select - Select a dropdown option
Usage: agent-browser select <selector> <value...>
Usage: agent-browser select <selector> <value>
Selects one or more options in a <select> dropdown by value.
Selects an option in a <select> dropdown by its value attribute.
Global Options:
--json Output as JSON
@@ -451,7 +361,6 @@ Global Options:
Examples:
agent-browser select "#country" "US"
agent-browser select @e5 "option2"
agent-browser select "#menu" "opt1" "opt2" "opt3"
"##,
"drag" => r##"
agent-browser drag - Drag and drop
@@ -733,7 +642,6 @@ Subcommands:
url Get current URL
count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
Global Options:
--json Output as JSON
@@ -748,8 +656,6 @@ Examples:
agent-browser get url
agent-browser get count "li.item"
agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##,
// === Is ===
@@ -1073,42 +979,6 @@ Examples:
agent-browser trace stop ./debug-trace.zip
"##,
// === Record (video) ===
"record" => r##"
agent-browser record - Record browser session to video
Usage: agent-browser record start <path.webm> [url]
agent-browser record stop
agent-browser record restart <path.webm> [url]
Record the browser to a WebM video file using Playwright's native recording.
Creates a fresh browser context but preserves cookies and localStorage.
If no URL is provided, automatically navigates to your current page.
Operations:
start <path> [url] Start recording (defaults to current URL if omitted)
stop Stop recording and save video
restart <path> [url] Stop current recording (if any) and start a new one
Global Options:
--json Output as JSON
--session <name> Use specific session
Examples:
# Record from current page (preserves login state)
agent-browser open https://app.example.com/dashboard
agent-browser snapshot -i # Explore and plan
agent-browser record start ./demo.webm
agent-browser click @e3 # Execute planned actions
agent-browser record stop
# Or specify a different URL
agent-browser record start ./demo.webm https://example.com
# Restart recording with a new file (stops previous, starts new)
agent-browser record restart ./take2.webm
"##,
// === Console/Errors ===
"console" => r##"
agent-browser console - View console logs
@@ -1251,7 +1121,7 @@ Core Commands:
focus <sel> Focus element
check <sel> Check checkbox
uncheck <sel> Uncheck checkbox
select <sel> <val...> Select dropdown option
select <sel> <val> Select dropdown option
drag <src> <dst> Drag and drop
upload <sel> <files...> Upload files
scroll <dir> [px] Scroll (up/down/left/right)
@@ -1261,7 +1131,6 @@ Core Commands:
pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI)
eval <js> Run JavaScript
connect <port> Connect to browser via CDP (e.g., connect 9222)
close Close browser
Navigation:
@@ -1270,7 +1139,7 @@ Navigation:
reload Reload page
Get Info: agent-browser get <what> [selector]
text, html, value, attr <name>, title, url, count, box, styles
text, html, value, attr <name>, title, url, count, box
Check State: agent-browser is <what> <selector>
visible, enabled, checked
@@ -1300,8 +1169,6 @@ Tabs:
Debug:
trace start|stop [path] Record trace
record start <path> [url] Start video recording (WebM)
record stop Stop and save video
console [--clear] View console logs
errors [--clear] View page errors
highlight <sel> Highlight element
@@ -1325,13 +1192,11 @@ Options:
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--debug Debug output
--version, -V Show version
Environment:
AGENT_BROWSER_SESSION Session name (default: "default")
@@ -1350,7 +1215,3 @@ Examples:
"#
);
}
pub fn print_version() {
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
}
+5 -9
View File
@@ -6,16 +6,12 @@ export default function CDPMode() {
<div className="prose">
<h1>CDP Mode</h1>
<p>Connect to an existing browser via Chrome DevTools Protocol:</p>
<CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
<CodeBlock code={`# Connect to Electron app
agent-browser --cdp 9222 snapshot
# Connect once, then run commands without --cdp
agent-browser connect 9222
agent-browser snapshot
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot`} />
# Connect to Chrome with remote debugging
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
agent-browser --cdp 9222 open about:blank`} />
<h2>Use cases</h2>
<p>This enables control of:</p>
+1 -2
View File
@@ -12,8 +12,7 @@ agent-browser snapshot # Get accessibility tree with refs
agent-browser click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text by ref
agent-browser screenshot # Base64 png to stdout
agent-browser screenshot page.png # Save to file
agent-browser screenshot page.png
agent-browser close`} />
<h2>Traditional selectors</h2>
+6 -115
View File
@@ -1,7 +1,6 @@
---
name: agent-browser
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
@@ -29,93 +28,56 @@ agent-browser close # Close browser
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
### Screenshots
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
@@ -123,66 +85,6 @@ agent-browser mouse wheel 100 # Scroll wheel
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Example: Form submission
@@ -235,18 +137,7 @@ agent-browser get text @e1 --json
## Debugging
```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser errors # View page errors
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser open example.com --headed # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP
agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
```
+3 -117
View File
@@ -26,7 +26,6 @@ import type {
SelectCommand,
HoverCommand,
ContentCommand,
TabNewCommand,
TabSwitchCommand,
TabCloseCommand,
WindowNewCommand,
@@ -50,7 +49,6 @@ import type {
IsCheckedCommand,
CountCommand,
BoundingBoxCommand,
StylesCommand,
TraceStartCommand,
TraceStopCommand,
HarStopCommand,
@@ -101,9 +99,6 @@ import type {
InputMouseCommand,
InputKeyboardCommand,
InputTouchCommand,
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
NavigateData,
ScreenshotData,
EvaluateData,
@@ -114,11 +109,7 @@ import type {
TabCloseData,
ScreencastStartData,
ScreencastStopData,
RecordingStartData,
RecordingStopData,
RecordingRestartData,
InputEventData,
StylesData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
@@ -320,8 +311,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser);
case 'boundingbox':
return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start':
return await handleVideoStart(command, browser);
case 'video_stop':
@@ -438,12 +427,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleInputKeyboard(command, browser);
case 'input_touch':
return await handleInputTouch(command, browser);
case 'recording_start':
return await handleRecordingStart(command, browser);
case 'recording_stop':
return await handleRecordingStop(command, browser);
case 'recording_restart':
return await handleRecordingRestart(command, browser);
default: {
// TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string };
@@ -714,17 +697,10 @@ async function handleClose(
}
async function handleTabNew(
command: TabNewCommand,
command: Command & { action: 'tab_new' },
browser: BrowserManager
): Promise<Response<TabNewData>> {
const result = await browser.newTab();
// Navigate to URL if provided (same pattern as handleNavigate)
if (command.url) {
const page = browser.getPage();
await page.goto(command.url, { waitUntil: 'domcontentloaded' });
}
return successResponse(command.id, result);
}
@@ -1250,62 +1226,6 @@ async function handleBoundingBox(
return successResponse(command.id, { box });
}
async function handleStyles(
command: StylesCommand,
browser: BrowserManager
): Promise<Response<StylesData>> {
const page = browser.getPage();
// Shared extraction logic as a string to be eval'd in browser context
const extractStylesScript = `(function(el) {
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
tag: el.tagName.toLowerCase(),
text: el.innerText?.trim().slice(0, 80) || null,
box: {
x: Math.round(r.x),
y: Math.round(r.y),
width: Math.round(r.width),
height: Math.round(r.height),
},
styles: {
fontSize: s.fontSize,
fontWeight: s.fontWeight,
fontFamily: s.fontFamily.split(',')[0].trim().replace(/"/g, ''),
color: s.color,
backgroundColor: s.backgroundColor,
borderRadius: s.borderRadius,
border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null,
boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null,
padding: s.padding,
},
};
})`;
// Check if it's a ref - single element
if (browser.isRef(command.selector)) {
const locator = browser.getLocator(command.selector);
const element = (await locator.evaluate((el, script) => {
const fn = eval(script);
return fn(el);
}, extractStylesScript)) as StylesData['elements'][0];
return successResponse(command.id, { elements: [element] });
}
// CSS selector - can match multiple elements
const elements = (await page.$$eval(
command.selector,
(els, script) => {
const fn = eval(script);
return els.map((el) => fn(el));
},
extractStylesScript
)) as StylesData['elements'];
return successResponse(command.id, { elements });
}
// Advanced handlers
async function handleVideoStart(
@@ -1504,8 +1424,8 @@ async function handleInputValue(
command: InputValueCommand,
browser: BrowserManager
): Promise<Response> {
const locator = browser.getLocator(command.selector);
const value = await locator.inputValue();
const page = browser.getPage();
const value = await page.locator(command.selector).inputValue();
return successResponse(command.id, { value });
}
@@ -1966,37 +1886,3 @@ async function handleInputTouch(
});
return successResponse(command.id, { injected: true });
}
// Recording handlers (Playwright native video recording)
async function handleRecordingStart(
command: RecordingStartCommand,
browser: BrowserManager
): Promise<Response<RecordingStartData>> {
await browser.startRecording(command.path, command.url);
return successResponse(command.id, {
started: true,
path: command.path,
});
}
async function handleRecordingStop(
command: RecordingStopCommand,
browser: BrowserManager
): Promise<Response<RecordingStopData>> {
const result = await browser.stopRecording();
return successResponse(command.id, result);
}
async function handleRecordingRestart(
command: RecordingRestartCommand,
browser: BrowserManager
): Promise<Response<RecordingRestartData>> {
const result = await browser.restartRecording(command.path, command.url);
return successResponse(command.id, {
started: true,
path: command.path,
previousPath: result.previousPath,
stopped: result.stopped,
});
}
+2 -32
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';
describe('BrowserManager', () => {
let browser: BrowserManager;
@@ -391,35 +390,6 @@ describe('BrowserManager', () => {
const cdp2 = await browser.getCDPSession();
expect(cdp1).toBe(cdp2);
});
it('should filter out pages with empty URLs during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
],
on: vi.fn(),
},
],
close: vi.fn(),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
// Should have 2 pages, not 3
expect(cdpBrowser.getPages().length).toBe(2);
// Verify that the empty URL page is not in the list
const urls = cdpBrowser.getPages().map((p) => p.url());
expect(urls).not.toContain('');
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
});
describe('screencast', () => {
@@ -435,7 +405,7 @@ describe('BrowserManager', () => {
expect(browser.isScreencasting()).toBe(true);
// Wait a bit for at least one frame
await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 200));
await browser.stopScreencast();
expect(browser.isScreencasting()).toBe(false);
+4 -245
View File
@@ -12,11 +12,9 @@ import {
type Route,
type Locator,
type CDPSession,
type Video,
} from 'playwright-core';
import path from 'node:path';
import os from 'node:os';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
@@ -91,12 +89,6 @@ export class BrowserManager {
private frameCallback: ((frame: ScreencastFrame) => void) | null = null;
private screencastFrameHandler: ((params: any) => void) | null = null;
// Video recording (Playwright native)
private recordingContext: BrowserContext | null = null;
private recordingPage: Page | null = null;
private recordingOutputPath: string = '';
private recordingTempDir: string = '';
/**
* Check if browser is launched
*/
@@ -690,7 +682,6 @@ export class BrowserManager {
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
}
);
this.isPersistentContext = true;
@@ -700,14 +691,10 @@ export class BrowserManager {
executablePath: options.executablePath,
});
this.cdpPort = null;
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
});
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
}
context.setDefaultTimeout(60000);
context.setDefaultTimeout(10000);
this.contexts.push(context);
const page = context.pages()[0] ?? (await context.newPage());
@@ -738,9 +725,7 @@ export class BrowserManager {
throw new Error('No browser context found. Make sure the app has an open window.');
}
// Filter out pages with empty URLs, which can cause Playwright to hang
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
const allPages = contexts.flatMap((context) => context.pages());
if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.');
}
@@ -843,7 +828,7 @@ export class BrowserManager {
const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 },
});
context.setDefaultTimeout(60000);
context.setDefaultTimeout(10000);
this.contexts.push(context);
const page = await context.newPage();
@@ -1118,236 +1103,10 @@ export class BrowserManager {
});
}
/**
* Check if video recording is currently active
*/
isRecording(): boolean {
return this.recordingContext !== null;
}
/**
* Start recording to a video file using Playwright's native video recording.
* Creates a fresh browser context with video recording enabled.
* Automatically captures current URL and transfers cookies/storage if no URL provided.
*
* @param outputPath - Path to the output video file (will be .webm)
* @param url - Optional URL to navigate to (defaults to current page URL)
*/
async startRecording(outputPath: string, url?: string): Promise<void> {
if (this.recordingContext) {
throw new Error(
"Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording."
);
}
if (!this.browser) {
throw new Error('Browser not launched. Call launch first.');
}
// Check if output file already exists
if (existsSync(outputPath)) {
throw new Error(`Output file already exists: ${outputPath}`);
}
// Validate output path is .webm (Playwright native format)
if (!outputPath.endsWith('.webm')) {
throw new Error(
'Playwright native recording only supports WebM format. Please use a .webm extension.'
);
}
// Auto-capture current URL if none provided
const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
if (!url && currentPage) {
const currentUrl = currentPage.url();
if (currentUrl && currentUrl !== 'about:blank') {
url = currentUrl;
}
}
// Capture state from current context (cookies + storage)
let storageState:
| {
cookies: Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}>;
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
| undefined;
if (currentContext) {
try {
storageState = await currentContext.storageState();
} catch {
// Ignore errors - context might be closed or invalid
}
}
// Create a temp directory for video recording
const session = process.env.AGENT_BROWSER_SESSION || 'default';
this.recordingTempDir = path.join(
os.tmpdir(),
`agent-browser-recording-${session}-${Date.now()}`
);
mkdirSync(this.recordingTempDir, { recursive: true });
this.recordingOutputPath = outputPath;
// Create a new context with video recording enabled and restored state
const viewport = { width: 1280, height: 720 };
this.recordingContext = await this.browser.newContext({
viewport,
recordVideo: {
dir: this.recordingTempDir,
size: viewport,
},
storageState,
});
this.recordingContext.setDefaultTimeout(10000);
// Create a page in the recording context
this.recordingPage = await this.recordingContext.newPage();
// Add the recording context and page to our managed lists
this.contexts.push(this.recordingContext);
this.pages.push(this.recordingPage);
this.activePageIndex = this.pages.length - 1;
// Set up page tracking
this.setupPageTracking(this.recordingPage);
// Invalidate CDP session since we switched pages
await this.invalidateCDPSession();
// Navigate to URL if provided or captured
if (url) {
await this.recordingPage.goto(url, { waitUntil: 'load' });
}
}
/**
* Stop recording and save the video file
* @returns Recording result with path
*/
async stopRecording(): Promise<{ path: string; frames: number; error?: string }> {
if (!this.recordingContext || !this.recordingPage) {
return { path: '', frames: 0, error: 'No recording in progress' };
}
const outputPath = this.recordingOutputPath;
try {
// Get the video object before closing the page
const video = this.recordingPage.video();
// Remove recording page/context from our managed lists before closing
const pageIndex = this.pages.indexOf(this.recordingPage);
if (pageIndex !== -1) {
this.pages.splice(pageIndex, 1);
}
const contextIndex = this.contexts.indexOf(this.recordingContext);
if (contextIndex !== -1) {
this.contexts.splice(contextIndex, 1);
}
// Close the page to finalize the video
await this.recordingPage.close();
// Save the video to the desired output path
if (video) {
await video.saveAs(outputPath);
}
// Clean up temp directory
if (this.recordingTempDir) {
rmSync(this.recordingTempDir, { recursive: true, force: true });
}
// Close the recording context
await this.recordingContext.close();
// Reset recording state
this.recordingContext = null;
this.recordingPage = null;
this.recordingOutputPath = '';
this.recordingTempDir = '';
// Adjust active page index
if (this.pages.length > 0) {
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
} else {
this.activePageIndex = 0;
}
// Invalidate CDP session since we may have switched pages
await this.invalidateCDPSession();
return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
} catch (error) {
// Clean up temp directory on error
if (this.recordingTempDir) {
rmSync(this.recordingTempDir, { recursive: true, force: true });
}
// Reset state on error
this.recordingContext = null;
this.recordingPage = null;
this.recordingOutputPath = '';
this.recordingTempDir = '';
const message = error instanceof Error ? error.message : String(error);
return { path: outputPath, frames: 0, error: message };
}
}
/**
* Restart recording - stops current recording (if any) and starts a new one.
* Convenience method that combines stopRecording and startRecording.
*
* @param outputPath - Path to the output video file (must be .webm)
* @param url - Optional URL to navigate to (defaults to current page URL)
* @returns Result from stopping the previous recording (if any)
*/
async restartRecording(
outputPath: string,
url?: string
): Promise<{ previousPath?: string; stopped: boolean }> {
let previousPath: string | undefined;
let stopped = false;
// Stop current recording if active
if (this.recordingContext) {
const result = await this.stopRecording();
previousPath = result.path;
stopped = true;
}
// Start new recording
await this.startRecording(outputPath, url);
return { previousPath, stopped };
}
/**
* Close the browser and clean up
*/
async close(): Promise<void> {
// Stop recording if active (saves video)
if (this.recordingContext) {
await this.stopRecording();
}
// Stop screencast if active
if (this.screencastActive) {
await this.stopScreencast();
+1 -1
View File
@@ -200,7 +200,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
await browser.launch({
id: 'auto',
action: 'launch',
headless: process.env.AGENT_BROWSER_HEADED !== '1',
headless: true,
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
});
-24
View File
@@ -15,22 +15,6 @@ describe('parseCommand', () => {
}
});
it('should parse navigate with headers', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'navigate',
url: 'https://example.com',
headers: { Authorization: 'Bearer token' },
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('navigate');
expect(result.command.headers).toEqual({ Authorization: 'Bearer token' });
}
});
it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false);
@@ -389,14 +373,6 @@ describe('parseCommand', () => {
expect(result.success).toBe(true);
});
it('should parse tab_new with url', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_new', url: 'https://example.com' }));
expect(result.success).toBe(true);
if (result.success) {
expect((result.command as { url?: string }).url).toBe('https://example.com');
}
});
it('should parse tab_list', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_list' }));
expect(result.success).toBe(true);
+1 -40
View File
@@ -19,24 +19,12 @@ const launchSchema = baseCommandSchema.extend({
.optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().optional(),
executablePath: z.string().optional(),
extensions: z.array(z.string()).optional(),
headers: z.record(z.string()).optional(),
proxy: z
.object({
server: z.string().min(1),
bypass: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
})
.optional(),
});
const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'),
url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
});
const clickSchema = baseCommandSchema.extend({
@@ -307,11 +295,6 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1),
});
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'),
path: z.string().min(1),
@@ -321,23 +304,6 @@ const videoStopSchema = baseCommandSchema.extend({
action: z.literal('video_stop'),
});
// Recording schemas (Playwright native video recording)
const recordingStartSchema = baseCommandSchema.extend({
action: z.literal('recording_start'),
path: z.string().min(1),
url: z.string().min(1).optional(),
});
const recordingStopSchema = baseCommandSchema.extend({
action: z.literal('recording_stop'),
});
const recordingRestartSchema = baseCommandSchema.extend({
action: z.literal('recording_restart'),
path: z.string().min(1),
url: z.string().min(1).optional(),
});
const traceStartSchema = baseCommandSchema.extend({
action: z.literal('trace_start'),
screenshots: z.boolean().optional(),
@@ -676,7 +642,7 @@ const pressSchema = baseCommandSchema.extend({
const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'),
path: z.string().nullable().optional(),
path: z.string().optional(),
fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(),
@@ -736,7 +702,6 @@ const closeSchema = baseCommandSchema.extend({
// Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'),
url: z.string().min(1).optional(),
});
const tabListSchema = baseCommandSchema.extend({
@@ -826,12 +791,8 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema,
countSchema,
boundingBoxSchema,
stylesSchema,
videoStartSchema,
videoStopSchema,
recordingStartSchema,
recordingStopSchema,
recordingRestartSchema,
traceStartSchema,
traceStopSchema,
harStartSchema,
+1 -75
View File
@@ -16,12 +16,6 @@ export interface LaunchCommand extends BaseCommand {
executablePath?: string;
cdpPort?: number;
extensions?: string[];
proxy?: {
server: string;
bypass?: string;
username?: string;
password?: string;
};
}
export interface NavigateCommand extends BaseCommand {
@@ -316,12 +310,6 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string;
}
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators
export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext';
@@ -514,7 +502,7 @@ export interface InputTouchCommand extends BaseCommand {
modifiers?: number;
}
// Video recording (Playwright native - requires launch-time setup)
// Video recording
export interface VideoStartCommand extends BaseCommand {
action: 'video_start';
path: string;
@@ -524,23 +512,6 @@ export interface VideoStopCommand extends BaseCommand {
action: 'video_stop';
}
// Screen recording (Playwright native - creates fresh recording context)
export interface RecordingStartCommand extends BaseCommand {
action: 'recording_start';
path: string;
url?: string;
}
export interface RecordingStopCommand extends BaseCommand {
action: 'recording_stop';
}
export interface RecordingRestartCommand extends BaseCommand {
action: 'recording_restart';
path: string;
url?: string;
}
// Tracing
export interface TraceStartCommand extends BaseCommand {
action: 'trace_start';
@@ -778,7 +749,6 @@ export interface CloseCommand extends BaseCommand {
// Tab/Window commands
export interface TabNewCommand extends BaseCommand {
action: 'tab_new';
url?: string;
}
export interface TabListCommand extends BaseCommand {
@@ -863,12 +833,8 @@ export type Command =
| IsCheckedCommand
| CountCommand
| BoundingBoxCommand
| StylesCommand
| VideoStartCommand
| VideoStopCommand
| RecordingStartCommand
| RecordingStopCommand
| RecordingRestartCommand
| TraceStartCommand
| TraceStopCommand
| HarStartCommand
@@ -1002,50 +968,10 @@ export interface ScreencastStopData {
stopped: boolean;
}
export interface RecordingStartData {
started: boolean;
path: string;
}
export interface RecordingStopData {
path: string;
frames: number;
error?: string;
}
export interface RecordingRestartData {
started: boolean;
path: string;
previousPath?: string;
stopped: boolean;
}
export interface InputEventData {
injected: boolean;
}
// Element styles data
export interface ElementStyleInfo {
tag: string;
text: string | null;
box: { x: number; y: number; width: number; height: number };
styles: {
fontSize: string;
fontWeight: string;
fontFamily: string;
color: string;
backgroundColor: string;
borderRadius: string;
border: string | null;
boxShadow: string | null;
padding: string;
};
}
export interface StylesData {
elements: ElementStyleInfo[];
}
// Browser state
export interface BrowserState {
browser: Browser | null;