Compare 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 ## Code Style
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable. - 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 --> <!-- 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 scrollintoview <sel> # Scroll element into view (alias: scrollinto)
agent-browser drag <src> <tgt> # Drag and drop agent-browser drag <src> <tgt> # Drag and drop
agent-browser upload <sel> <files> # Upload files 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 pdf <path> # Save as PDF
agent-browser snapshot # Accessibility tree with refs (best for AI) agent-browser snapshot # Accessibility tree with refs (best for AI)
agent-browser eval <js> # Run JavaScript agent-browser eval <js> # Run JavaScript
agent-browser connect <port> # Connect to browser via CDP
agent-browser close # Close browser (aliases: quit, exit) 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: Connect to an existing browser via Chrome DevTools Protocol:
```bash ```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222 # Connect to Electron app
# 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 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: 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(), context: cmd.to_string(),
usage: "open <url>", usage: "open <url>",
})?; })?;
let url_lower = url.to_lowercase(); let url = if url.starts_with("http") {
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:") {
url.to_string() url.to_string()
} else { } else {
format!("https://{}", url) format!("https://{}", url)
@@ -158,18 +153,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"select" => { "select" => {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments { let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "select".to_string(), 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(), context: "select".to_string(),
usage: "select <selector> <value...>", usage: "select <selector> <value>",
})?; })?;
let values = &rest[1..]; Ok(json!({ "id": id, "action": "select", "selector": sel, "value": val }))
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 }))
}
} }
"drag" => { "drag" => {
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments { 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/PDF ===
"screenshot" => { "screenshot" => {
let mut cmd = json!({ "id": id, "action": "screenshot", "fullPage": flags.full }); Ok(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full }))
if let Some(path) = rest.get(0) {
cmd["path"] = json!(path);
}
Ok(cmd)
} }
"pdf" => { "pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments { 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 ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "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 ===
"get" => parse_get(&rest, &id), "get" => parse_get(&rest, &id),
@@ -394,20 +367,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Tabs === // === Tabs ===
"tab" => { "tab" => {
match rest.get(0).map(|s| *s) { match rest.get(0).map(|s| *s) {
Some("new") => { Some("new") => Ok(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })),
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })), Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => { Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" }); Ok(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::<i32>().ok()) }))
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
}
Ok(cmd)
} }
Some(n) if n.parse::<i32>().is_ok() => { Some(n) if n.parse::<i32>().is_ok() => {
Ok(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().unwrap() })) 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 ===
"frame" => { "frame" => {
if rest.get(0).map(|s| *s) == Some("main") { if rest.get(0).map(|s| *s) == Some("main") {
Ok(json!({ "id": id, "action": "mainframe" })) Ok(json!({ "id": id, "action": "frame_main" }))
} else { } else {
let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments { let sel = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "frame".to_string(), context: "frame".to_string(),
@@ -450,11 +413,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
const VALID: &[&str] = &["accept", "dismiss"]; const VALID: &[&str] = &["accept", "dismiss"];
match rest.get(0).map(|s| *s) { match rest.get(0).map(|s| *s) {
Some("accept") => { Some("accept") => {
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" }); Ok(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) }))
if let Some(prompt_text) = rest.get(1) {
cmd["promptText"] = json!(prompt_text);
}
Ok(cmd)
} }
Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })), Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
@@ -472,14 +431,8 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"trace" => { "trace" => {
const VALID: &[&str] = &["start", "stop"]; const VALID: &[&str] = &["start", "stop"];
match rest.get(0).map(|s| *s) { match rest.get(0).map(|s| *s) {
Some("start") => Ok(json!({ "id": id, "action": "trace_start" })), Some("start") => Ok(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })),
Some("stop") => { Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })),
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(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, 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" => { "console" => {
let clear = rest.iter().any(|&s| s == "--clear"); let clear = rest.iter().any(|&s| s == "--clear");
Ok(json!({ "id": id, "action": "console", "clear": 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> { 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) { match rest.get(0).map(|s| *s) {
Some("text") => { Some("text") => {
@@ -647,20 +546,13 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?; })?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel })) 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, valid_options: VALID,
}), }),
None => Err(ParseError::MissingArguments { None => Err(ParseError::MissingArguments {
context: "get".to_string(), 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 { match *locator {
"role" => { "role" => Ok(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })),
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)
}
"text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })), "text" => Ok(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })),
"label" => { "label" => Ok(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })),
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact }); "placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "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)
}
"alt" => Ok(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "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 })), "title" => Ok(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })),
"testid" => { "testid" => Ok(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })),
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction }); "first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })),
if let Some(v) = fill_value { cmd["value"] = json!(v); } "last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })),
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)
}
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -793,9 +661,7 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else { } else {
None None
}; };
let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub }); Ok(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv }))
if let Some(v) = fv { cmd["value"] = json!(v); }
Ok(cmd)
} }
_ => Err(ParseError::UnknownSubcommand { _ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(), subcommand: locator.to_string(),
@@ -836,7 +702,7 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Some("wheel") => { Some("wheel") => {
let dy = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(100); 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); 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -934,12 +800,8 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else { } else {
"no-preference" "no-preference"
}; };
let reduced = if rest.iter().any(|&s| s == "reduced-motion") { let reduced = rest.iter().any(|&s| s == "reduced-motion");
"reduce" Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced }))
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
} }
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), 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)); 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 })) Ok(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body }))
} }
Some("unroute") => { Some("unroute") => Ok(json!({ "id": id, "action": "unroute", "url": rest.get(1) })),
let mut cmd = json!({ "id": id, "action": "unroute" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
},
Some("requests") => { Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear"); let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter"); let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear }); Ok(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter }))
if let Some(f) = filter {
cmd["filter"] = json!(f);
}
Ok(cmd)
} }
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -1051,7 +903,6 @@ mod tests {
executable_path: None, executable_path: None,
extensions: Vec::new(), extensions: Vec::new(),
cdp: None, cdp: None,
proxy: None,
} }
} }
@@ -1296,46 +1147,12 @@ mod tests {
assert_eq!(cmd["text"], "some text"); 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 === // === Tabs ===
#[test] #[test]
fn test_tab_new() { fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap(); let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new"); 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] #[test]
@@ -1363,14 +1180,6 @@ mod tests {
fn test_screenshot() { fn test_screenshot() {
let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap(); let cmd = parse_command(&args("screenshot"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "screenshot"); 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] #[test]
@@ -1464,82 +1273,6 @@ mod tests {
// === Unknown command === // === 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] #[test]
fn test_unknown_command() { fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags()); let result = parse_command(&args("unknowncommand"), &default_flags());
@@ -1583,54 +1316,4 @@ mod tests {
assert!(matches!(err, ParseError::MissingArguments { .. })); assert!(matches!(err, ParseError::MissingArguments { .. }));
assert!(err.format().contains("get text")); 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() { for c in session.chars() {
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
} }
// Correct logic: first take absolute modulo, then cast to u16 49152 + ((hash.abs() as u16) % 16383)
// Using unsigned_abs() to safely handle i32::MIN
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
} }
#[cfg(unix)] #[cfg(unix)]
@@ -142,8 +140,7 @@ fn is_daemon_running(session: &str) -> bool {
fn daemon_ready(session: &str) -> bool { fn daemon_ready(session: &str) -> bool {
#[cfg(unix)] #[cfg(unix)]
{ {
let socket_path = get_socket_path(session); get_socket_path(session).exists()
UnixStream::connect(&socket_path).is_ok()
} }
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -177,23 +174,16 @@ pub fn ensure_daemon(
let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap(); let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![ let daemon_paths = [
exe_dir.join("daemon.js"), exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"), exe_dir.join("../dist/daemon.js"),
PathBuf::from("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 let daemon_path = daemon_paths
.iter() .iter()
.find(|p| p.exists()) .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 // Spawn daemon as a fully detached background process
#[cfg(unix)] #[cfg(unix)]
@@ -237,10 +227,13 @@ pub fn ensure_daemon(
{ {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) // On Windows, use cmd.exe to run node to ensure proper PATH resolution.
// and automatically quotes arguments containing spaces. // This handles cases where node.exe isn't directly in PATH but node.cmd is.
let mut cmd = Command::new("node"); // Pass the entire command as a single string to /c to handle paths with spaces.
cmd.arg(daemon_path) 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_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session); .env("AGENT_BROWSER_SESSION", session);
+1 -9
View File
@@ -10,7 +10,6 @@ pub struct Flags {
pub executable_path: Option<String>, pub executable_path: Option<String>,
pub cdp: Option<String>, pub cdp: Option<String>,
pub extensions: Vec<String>, pub extensions: Vec<String>,
pub proxy: Option<String>,
} }
pub fn parse_flags(args: &[String]) -> Flags { 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(), executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None, cdp: None,
extensions: extensions_env, extensions: extensions_env,
proxy: None,
}; };
let mut i = 0; let mut i = 0;
@@ -69,12 +67,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; i += 1;
} }
} }
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
i += 1;
}
}
_ => {} _ => {}
} }
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 // Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too) // 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() { for arg in args.iter() {
if skip_next { if skip_next {
+13 -32
View File
@@ -1,4 +1,3 @@
use crate::color;
use std::process::{exit, Command, Stdio}; use std::process::{exit, Command, Stdio};
pub fn run_install(with_deps: bool) { pub fn run_install(with_deps: bool) {
@@ -6,15 +5,9 @@ pub fn run_install(with_deps: bool) {
if is_linux { if is_linux {
if with_deps { 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 (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
( (
"apt-get", "apt-get",
vec![ vec![
@@ -37,7 +30,7 @@ pub fn run_install(with_deps: bool) {
"libcairo2", "libcairo2",
"libgdk-pixbuf-2.0-0", "libgdk-pixbuf-2.0-0",
"libxrender1", "libxrender1",
libasound, "libasound2",
"libfreetype6", "libfreetype6",
"libfontconfig1", "libfontconfig1",
"libdbus-1-3", "libdbus-1-3",
@@ -100,7 +93,7 @@ pub fn run_install(with_deps: bool) {
], ],
) )
} else { } 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); exit(1);
}; };
@@ -119,23 +112,22 @@ pub fn run_install(with_deps: bool) {
match status { match status {
Ok(s) if s.success() => { Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator()) println!("\x1b[32m✓\x1b[0m System dependencies installed")
} }
Ok(_) => eprintln!( Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.", "\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
color::warning_indicator()
), ),
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 { } 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!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium"); println!(" or: npx playwright install-deps chromium");
println!(); 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 // 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. // 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 { match status {
Ok(s) if s.success() => { 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 { if is_linux && !with_deps {
println!(); 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"); println!(" agent-browser install --with-deps");
} }
} }
Ok(_) => { Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator()); eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
if is_linux { 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"); println!(" agent-browser install --with-deps");
} }
exit(1); exit(1);
} }
Err(e) => { 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"); eprintln!("Make sure Node.js is installed and npx is in your PATH");
exit(1); exit(1);
} }
@@ -197,14 +189,3 @@ fn which_exists(cmd: &str) -> bool {
.unwrap_or(false) .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 commands;
mod connection; mod connection;
mod flags; mod flags;
@@ -22,37 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, send_command}; use connection::{ensure_daemon, send_command};
use flags::{clean_args, parse_flags}; use flags::{clean_args, parse_flags};
use install::run_install; use install::run_install;
use output::{print_command_help, print_help, print_response, print_version}; use output::{print_command_help, print_help, print_response};
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..]
})
}
fn run_session(args: &[String], session: &str, json_mode: bool) { fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str()); 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 { } else {
println!("Active sessions:"); println!("Active sessions:");
for s in &sessions { for s in &sessions {
let marker = if s == session { color::cyan("") } else { " ".to_string() }; let marker = if s == session { "" } else { " " };
println!("{} {}", marker, s); println!("{} {}", marker, s);
} }
} }
@@ -125,18 +94,16 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} }
fn main() { 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 args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args); let flags = parse_flags(&args);
let clean = clean_args(&args); let clean = clean_args(&args);
let has_help = args.iter().any(|a| a == "--help" || a == "-h"); 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 has_help {
if let Some(cmd) = clean.get(0) { if let Some(cmd) = clean.get(0) {
@@ -148,16 +115,6 @@ fn main() {
return; return;
} }
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
// Handle install separately // Handle install separately
if clean.get(0).map(|s| s.as_str()) == Some("install") { if clean.get(0).map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d"); let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
@@ -186,7 +143,7 @@ fn main() {
error_type error_type
); );
} else { } else {
eprintln!("{}", color::red(&e.format())); eprintln!("\x1b[31m{}\x1b[0m", e.format());
} }
exit(1); exit(1);
} }
@@ -198,7 +155,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("{} {}", color::error_indicator(), e); eprintln!("\x1b[31m✗\x1b[0m {}", e);
} }
exit(1); exit(1);
} }
@@ -208,10 +165,10 @@ fn main() {
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) { if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json { if !flags.json {
if flags.executable_path.is_some() { 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() { 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 { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("{} {}", color::error_indicator(), msg); eprintln!("\x1b[31m✗\x1b[0m {}", msg);
} }
exit(1); exit(1);
} }
@@ -233,7 +190,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("{} {}", color::error_indicator(), msg); eprintln!("\x1b[31m✗\x1b[0m {}", msg);
} }
exit(1); exit(1);
} }
@@ -243,7 +200,7 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("{} {}", color::error_indicator(), msg); eprintln!("\x1b[31m✗\x1b[0m {}", msg);
} }
exit(1); exit(1);
} }
@@ -265,30 +222,23 @@ fn main() {
if flags.json { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg); println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else { } else {
eprintln!("{} {}", color::error_indicator(), msg); eprintln!("\x1b[31m✗\x1b[0m {}", msg);
} }
exit(1); exit(1);
} }
} }
// Launch headed browser or proxy if flags are set (without CDP) // Launch headed browser if --headed flag is set (without CDP)
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() { if flags.headed && flags.cdp.is_none() {
let mut launch_cmd = json!({ let launch_cmd = json!({
"id": gen_id(), "id": gen_id(),
"action": "launch", "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 let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json { 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 { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("{} {}", color::error_indicator(), e); eprintln!("\x1b[31m✗\x1b[0m {}", e);
} }
exit(1); 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; use crate::connection::Response;
pub fn print_response(resp: &Response, json_mode: bool) { 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 { if !resp.success {
eprintln!( eprintln!(
"{} {}", "\x1b[31m✗\x1b[0m {}",
color::error_indicator(),
resp.error.as_deref().unwrap_or("Unknown error") resp.error.as_deref().unwrap_or("Unknown error")
); );
return; return;
@@ -20,8 +18,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
// Navigation response // Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) { 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()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} {}", color::success_indicator(), color::bold(title)); println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title);
println!(" {}", color::dim(url)); println!("\x1b[2m {}\x1b[0m", url);
return; return;
} }
println!("{}", url); println!("{}", url);
@@ -87,17 +85,23 @@ pub fn print_response(resp: &Response, json_mode: bool) {
.unwrap_or("Untitled"); .unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); 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 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); println!("{} [{}] {} - {}", marker, i, title, url);
} }
return; return;
} }
// Console logs // 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 { for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); 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(""); 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; 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()) { if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) {
for err in errors { for err in errors {
let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); 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; return;
} }
@@ -118,27 +122,6 @@ pub fn print_response(resp: &Response, json_mode: bool) {
} }
return; 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 // Bounding box
if let Some(box_data) = data.get("box") { if let Some(box_data) = data.get("box") {
println!( println!(
@@ -147,91 +130,18 @@ pub fn print_response(resp: &Response, json_mode: bool) {
); );
return; 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 // Closed
if data.get("closed").is_some() { if data.get("closed").is_some() {
println!("{} Browser closed", color::success_indicator()); println!("\x1b[32m✓\x1b[0m Browser closed");
return; return;
} }
// Recording start (has "started" field) // Screenshot path
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)
if let Some(path) = data.get("path").and_then(|v| v.as_str()) { if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path)); println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path);
return;
}
// Screenshot base64
if let Some(base64) = data.get("base64").and_then(|v| v.as_str()) {
println!("{}", base64);
return; return;
} }
// Default success // Default success
println!("{} Done", color::success_indicator()); println!("\x1b[32m✓\x1b[0m Done");
} }
} }
@@ -440,9 +350,9 @@ Examples:
"select" => r##" "select" => r##"
agent-browser select - Select a dropdown option 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: Global Options:
--json Output as JSON --json Output as JSON
@@ -451,7 +361,6 @@ Global Options:
Examples: Examples:
agent-browser select "#country" "US" agent-browser select "#country" "US"
agent-browser select @e5 "option2" agent-browser select @e5 "option2"
agent-browser select "#menu" "opt1" "opt2" "opt3"
"##, "##,
"drag" => r##" "drag" => r##"
agent-browser drag - Drag and drop agent-browser drag - Drag and drop
@@ -733,7 +642,6 @@ Subcommands:
url Get current URL url Get current URL
count <selector> Count matching elements count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height) box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
Global Options: Global Options:
--json Output as JSON --json Output as JSON
@@ -748,8 +656,6 @@ Examples:
agent-browser get url agent-browser get url
agent-browser get count "li.item" agent-browser get count "li.item"
agent-browser get box "#header" agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##, "##,
// === Is === // === Is ===
@@ -1073,42 +979,6 @@ Examples:
agent-browser trace stop ./debug-trace.zip 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/Errors ===
"console" => r##" "console" => r##"
agent-browser console - View console logs agent-browser console - View console logs
@@ -1251,7 +1121,7 @@ Core Commands:
focus <sel> Focus element focus <sel> Focus element
check <sel> Check checkbox check <sel> Check checkbox
uncheck <sel> Uncheck checkbox uncheck <sel> Uncheck checkbox
select <sel> <val...> Select dropdown option select <sel> <val> Select dropdown option
drag <src> <dst> Drag and drop drag <src> <dst> Drag and drop
upload <sel> <files...> Upload files upload <sel> <files...> Upload files
scroll <dir> [px] Scroll (up/down/left/right) scroll <dir> [px] Scroll (up/down/left/right)
@@ -1261,7 +1131,6 @@ Core Commands:
pdf <path> Save as PDF pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI) snapshot Accessibility tree with refs (for AI)
eval <js> Run JavaScript eval <js> Run JavaScript
connect <port> Connect to browser via CDP (e.g., connect 9222)
close Close browser close Close browser
Navigation: Navigation:
@@ -1270,7 +1139,7 @@ Navigation:
reload Reload page reload Reload page
Get Info: agent-browser get <what> [selector] 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> Check State: agent-browser is <what> <selector>
visible, enabled, checked visible, enabled, checked
@@ -1300,8 +1169,6 @@ Tabs:
Debug: Debug:
trace start|stop [path] Record trace 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 console [--clear] View console logs
errors [--clear] View page errors errors [--clear] View page errors
highlight <sel> Highlight element highlight <sel> Highlight element
@@ -1325,13 +1192,11 @@ Options:
--headers <json> HTTP headers scoped to URL's origin (for auth) --headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable). --extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--json JSON output --json JSON output
--full, -f Full page screenshot --full, -f Full page screenshot
--headed Show browser window (not headless) --headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol) --cdp <port> Connect via CDP (Chrome DevTools Protocol)
--debug Debug output --debug Debug output
--version, -V Show version
Environment: Environment:
AGENT_BROWSER_SESSION Session name (default: "default") 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"> <div className="prose">
<h1>CDP Mode</h1> <h1>CDP Mode</h1>
<p>Connect to an existing browser via Chrome DevTools Protocol:</p> <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 # Connect to Chrome with remote debugging
agent-browser connect 9222 # (Start Chrome with: google-chrome --remote-debugging-port=9222)
agent-browser snapshot agent-browser --cdp 9222 open about:blank`} />
agent-browser tab
agent-browser close
# Or pass --cdp on each command
agent-browser --cdp 9222 snapshot`} />
<h2>Use cases</h2> <h2>Use cases</h2>
<p>This enables control of:</p> <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 click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text by ref agent-browser get text @e1 # Get text by ref
agent-browser screenshot # Base64 png to stdout agent-browser screenshot page.png
agent-browser screenshot page.png # Save to file
agent-browser close`} /> agent-browser close`} />
<h2>Traditional selectors</h2> <h2>Traditional selectors</h2>
+5 -114
View File
@@ -1,7 +1,6 @@
--- ---
name: agent-browser 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. 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 # Browser Automation with agent-browser
@@ -36,86 +35,49 @@ agent-browser close # Close browser
### Snapshot (page analysis) ### Snapshot (page analysis)
```bash ```bash
agent-browser snapshot # Full accessibility tree agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended) agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3 agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
``` ```
### Interactions (use @refs from snapshot) ### Interactions (use @refs from snapshot)
```bash ```bash
agent-browser click @e1 # Click agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key agent-browser press Enter # Press key
agent-browser press Control+a # Key combination 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 hover @e1 # Hover
agent-browser check @e1 # Check checkbox agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view 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 ### Get information
```bash ```bash
agent-browser get text @e1 # Get element text 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 value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title agent-browser get title # Get page title
agent-browser get url # Get current URL 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 ### Screenshots
```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
```bash ```bash
agent-browser screenshot # Screenshot to stdout agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page 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 ### Wait
```bash ```bash
agent-browser wait @e1 # Wait for element agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text 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 --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) ### 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 role button click --name "Submit"
agent-browser find text "Sign In" click agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com" 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 ## Example: Form submission
@@ -235,18 +137,7 @@ agent-browser get text @e1 --json
## Debugging ## Debugging
```bash ```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 open example.com --headed # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP
agent-browser console # View console messages agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors 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, SelectCommand,
HoverCommand, HoverCommand,
ContentCommand, ContentCommand,
TabNewCommand,
TabSwitchCommand, TabSwitchCommand,
TabCloseCommand, TabCloseCommand,
WindowNewCommand, WindowNewCommand,
@@ -50,7 +49,6 @@ import type {
IsCheckedCommand, IsCheckedCommand,
CountCommand, CountCommand,
BoundingBoxCommand, BoundingBoxCommand,
StylesCommand,
TraceStartCommand, TraceStartCommand,
TraceStopCommand, TraceStopCommand,
HarStopCommand, HarStopCommand,
@@ -101,9 +99,6 @@ import type {
InputMouseCommand, InputMouseCommand,
InputKeyboardCommand, InputKeyboardCommand,
InputTouchCommand, InputTouchCommand,
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
NavigateData, NavigateData,
ScreenshotData, ScreenshotData,
EvaluateData, EvaluateData,
@@ -114,11 +109,7 @@ import type {
TabCloseData, TabCloseData,
ScreencastStartData, ScreencastStartData,
ScreencastStopData, ScreencastStopData,
RecordingStartData,
RecordingStopData,
RecordingRestartData,
InputEventData, InputEventData,
StylesData,
} from './types.js'; } from './types.js';
import { successResponse, errorResponse } from './protocol.js'; import { successResponse, errorResponse } from './protocol.js';
@@ -320,8 +311,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser); return await handleCount(command, browser);
case 'boundingbox': case 'boundingbox':
return await handleBoundingBox(command, browser); return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start': case 'video_start':
return await handleVideoStart(command, browser); return await handleVideoStart(command, browser);
case 'video_stop': case 'video_stop':
@@ -438,12 +427,6 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleInputKeyboard(command, browser); return await handleInputKeyboard(command, browser);
case 'input_touch': case 'input_touch':
return await handleInputTouch(command, browser); 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: { default: {
// TypeScript narrows to never here, but we handle it for safety // TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string }; const unknownCommand = command as { id: string; action: string };
@@ -714,17 +697,10 @@ async function handleClose(
} }
async function handleTabNew( async function handleTabNew(
command: TabNewCommand, command: Command & { action: 'tab_new' },
browser: BrowserManager browser: BrowserManager
): Promise<Response<TabNewData>> { ): Promise<Response<TabNewData>> {
const result = await browser.newTab(); 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); return successResponse(command.id, result);
} }
@@ -1250,62 +1226,6 @@ async function handleBoundingBox(
return successResponse(command.id, { box }); 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 // Advanced handlers
async function handleVideoStart( async function handleVideoStart(
@@ -1504,8 +1424,8 @@ async function handleInputValue(
command: InputValueCommand, command: InputValueCommand,
browser: BrowserManager browser: BrowserManager
): Promise<Response> { ): Promise<Response> {
const locator = browser.getLocator(command.selector); const page = browser.getPage();
const value = await locator.inputValue(); const value = await page.locator(command.selector).inputValue();
return successResponse(command.id, { value }); return successResponse(command.id, { value });
} }
@@ -1966,37 +1886,3 @@ async function handleInputTouch(
}); });
return successResponse(command.id, { injected: true }); 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 { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';
describe('BrowserManager', () => { describe('BrowserManager', () => {
let browser: BrowserManager; let browser: BrowserManager;
@@ -391,35 +390,6 @@ describe('BrowserManager', () => {
const cdp2 = await browser.getCDPSession(); const cdp2 = await browser.getCDPSession();
expect(cdp1).toBe(cdp2); 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', () => { describe('screencast', () => {
@@ -435,7 +405,7 @@ describe('BrowserManager', () => {
expect(browser.isScreencasting()).toBe(true); expect(browser.isScreencasting()).toBe(true);
// Wait a bit for at least one frame // 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(); await browser.stopScreencast();
expect(browser.isScreencasting()).toBe(false); expect(browser.isScreencasting()).toBe(false);
+4 -245
View File
@@ -12,11 +12,9 @@ import {
type Route, type Route,
type Locator, type Locator,
type CDPSession, type CDPSession,
type Video,
} from 'playwright-core'; } from 'playwright-core';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import type { LaunchCommand } from './types.js'; import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.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 frameCallback: ((frame: ScreencastFrame) => void) | null = null;
private screencastFrameHandler: ((params: any) => 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 * Check if browser is launched
*/ */
@@ -690,7 +682,6 @@ export class BrowserManager {
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`], args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
} }
); );
this.isPersistentContext = true; this.isPersistentContext = true;
@@ -700,14 +691,10 @@ export class BrowserManager {
executablePath: options.executablePath, executablePath: options.executablePath,
}); });
this.cdpPort = null; this.cdpPort = null;
context = await this.browser.newContext({ context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
viewport,
extraHTTPHeaders: options.headers,
...(options.proxy && { proxy: options.proxy }),
});
} }
context.setDefaultTimeout(60000); context.setDefaultTimeout(10000);
this.contexts.push(context); this.contexts.push(context);
const page = context.pages()[0] ?? (await context.newPage()); 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.'); 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());
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
if (allPages.length === 0) { if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.'); 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({ const context = await this.browser.newContext({
viewport: viewport ?? { width: 1280, height: 720 }, viewport: viewport ?? { width: 1280, height: 720 },
}); });
context.setDefaultTimeout(60000); context.setDefaultTimeout(10000);
this.contexts.push(context); this.contexts.push(context);
const page = await context.newPage(); 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 * Close the browser and clean up
*/ */
async close(): Promise<void> { async close(): Promise<void> {
// Stop recording if active (saves video)
if (this.recordingContext) {
await this.stopRecording();
}
// Stop screencast if active // Stop screencast if active
if (this.screencastActive) { if (this.screencastActive) {
await this.stopScreencast(); await this.stopScreencast();
+1 -1
View File
@@ -200,7 +200,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
await browser.launch({ await browser.launch({
id: 'auto', id: 'auto',
action: 'launch', action: 'launch',
headless: process.env.AGENT_BROWSER_HEADED !== '1', headless: true,
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions, 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', () => { it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' })); const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -389,14 +373,6 @@ describe('parseCommand', () => {
expect(result.success).toBe(true); 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', () => { it('should parse tab_list', () => {
const result = parseCommand(cmd({ id: '1', action: 'tab_list' })); const result = parseCommand(cmd({ id: '1', action: 'tab_list' }));
expect(result.success).toBe(true); expect(result.success).toBe(true);
+1 -40
View File
@@ -19,24 +19,12 @@ const launchSchema = baseCommandSchema.extend({
.optional(), .optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().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({ const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'), action: z.literal('navigate'),
url: z.string().min(1), url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(), waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
}); });
const clickSchema = baseCommandSchema.extend({ const clickSchema = baseCommandSchema.extend({
@@ -307,11 +295,6 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1), selector: z.string().min(1),
}); });
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({ const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'), action: z.literal('video_start'),
path: z.string().min(1), path: z.string().min(1),
@@ -321,23 +304,6 @@ const videoStopSchema = baseCommandSchema.extend({
action: z.literal('video_stop'), 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({ const traceStartSchema = baseCommandSchema.extend({
action: z.literal('trace_start'), action: z.literal('trace_start'),
screenshots: z.boolean().optional(), screenshots: z.boolean().optional(),
@@ -676,7 +642,7 @@ const pressSchema = baseCommandSchema.extend({
const screenshotSchema = baseCommandSchema.extend({ const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'), action: z.literal('screenshot'),
path: z.string().nullable().optional(), path: z.string().optional(),
fullPage: z.boolean().optional(), fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(), selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(), format: z.enum(['png', 'jpeg']).optional(),
@@ -736,7 +702,6 @@ const closeSchema = baseCommandSchema.extend({
// Tab/Window schemas // Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({ const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'), action: z.literal('tab_new'),
url: z.string().min(1).optional(),
}); });
const tabListSchema = baseCommandSchema.extend({ const tabListSchema = baseCommandSchema.extend({
@@ -826,12 +791,8 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema, isCheckedSchema,
countSchema, countSchema,
boundingBoxSchema, boundingBoxSchema,
stylesSchema,
videoStartSchema, videoStartSchema,
videoStopSchema, videoStopSchema,
recordingStartSchema,
recordingStopSchema,
recordingRestartSchema,
traceStartSchema, traceStartSchema,
traceStopSchema, traceStopSchema,
harStartSchema, harStartSchema,
+1 -75
View File
@@ -16,12 +16,6 @@ export interface LaunchCommand extends BaseCommand {
executablePath?: string; executablePath?: string;
cdpPort?: number; cdpPort?: number;
extensions?: string[]; extensions?: string[];
proxy?: {
server: string;
bypass?: string;
username?: string;
password?: string;
};
} }
export interface NavigateCommand extends BaseCommand { export interface NavigateCommand extends BaseCommand {
@@ -316,12 +310,6 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string; selector: string;
} }
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators // More semantic locators
export interface GetByAltTextCommand extends BaseCommand { export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext'; action: 'getbyalttext';
@@ -514,7 +502,7 @@ export interface InputTouchCommand extends BaseCommand {
modifiers?: number; modifiers?: number;
} }
// Video recording (Playwright native - requires launch-time setup) // Video recording
export interface VideoStartCommand extends BaseCommand { export interface VideoStartCommand extends BaseCommand {
action: 'video_start'; action: 'video_start';
path: string; path: string;
@@ -524,23 +512,6 @@ export interface VideoStopCommand extends BaseCommand {
action: 'video_stop'; 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 // Tracing
export interface TraceStartCommand extends BaseCommand { export interface TraceStartCommand extends BaseCommand {
action: 'trace_start'; action: 'trace_start';
@@ -778,7 +749,6 @@ export interface CloseCommand extends BaseCommand {
// Tab/Window commands // Tab/Window commands
export interface TabNewCommand extends BaseCommand { export interface TabNewCommand extends BaseCommand {
action: 'tab_new'; action: 'tab_new';
url?: string;
} }
export interface TabListCommand extends BaseCommand { export interface TabListCommand extends BaseCommand {
@@ -863,12 +833,8 @@ export type Command =
| IsCheckedCommand | IsCheckedCommand
| CountCommand | CountCommand
| BoundingBoxCommand | BoundingBoxCommand
| StylesCommand
| VideoStartCommand | VideoStartCommand
| VideoStopCommand | VideoStopCommand
| RecordingStartCommand
| RecordingStopCommand
| RecordingRestartCommand
| TraceStartCommand | TraceStartCommand
| TraceStopCommand | TraceStopCommand
| HarStartCommand | HarStartCommand
@@ -1002,50 +968,10 @@ export interface ScreencastStopData {
stopped: boolean; 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 { export interface InputEventData {
injected: boolean; 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 // Browser state
export interface BrowserState { export interface BrowserState {
browser: Browser | null; browser: Browser | null;