Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0c5a2acc | ||
|
|
818d9fa95e | ||
|
|
a8dcbb1222 | ||
|
|
a9fcef4579 | ||
|
|
59baf97e51 | ||
|
|
d02ef66c89 | ||
|
|
1689cf9eca | ||
|
|
03a53c9f36 | ||
|
|
b1c0c6a366 | ||
|
|
c88734da89 | ||
|
|
28740acecf | ||
|
|
4112234371 | ||
|
|
5e08e5d077 | ||
|
|
42879c337a | ||
|
|
412ac63b68 | ||
|
|
e6e832d2bc | ||
|
|
b19ca760aa | ||
|
|
e7c4936bc7 | ||
|
|
7aad47d3bd | ||
|
|
e196ed3e35 | ||
|
|
1f31452fea | ||
|
|
3675e6bd7a | ||
|
|
fff9a146bd | ||
|
|
34dcb7195a | ||
|
|
7bdfcf8541 | ||
|
|
6abee37641 |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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 -->
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,11 @@ 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)
|
agent-browser screenshot [path] # Take screenshot (--full for full page, base64 png to stdout if no path)
|
||||||
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -465,12 +466,16 @@ export async function handler() {
|
|||||||
Connect to an existing browser via Chrome DevTools Protocol:
|
Connect to an existing browser via Chrome DevTools Protocol:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Connect to Electron app
|
# Start Chrome with: google-chrome --remote-debugging-port=9222
|
||||||
agent-browser --cdp 9222 snapshot
|
|
||||||
|
|
||||||
# Connect to Chrome with remote debugging
|
# Connect once, then run commands without --cdp
|
||||||
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
|
agent-browser connect 9222
|
||||||
agent-browser --cdp 9222 open about:blank
|
agent-browser snapshot
|
||||||
|
agent-browser tab
|
||||||
|
agent-browser close
|
||||||
|
|
||||||
|
# Or pass --cdp on each command
|
||||||
|
agent-browser --cdp 9222 snapshot
|
||||||
```
|
```
|
||||||
|
|
||||||
This enables control of:
|
This enables control of:
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.4"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.4"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! 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('⚠'));
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
-26
@@ -75,7 +75,12 @@ 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 = if url.starts_with("http") {
|
let url_lower = url.to_lowercase();
|
||||||
|
let url = if url_lower.starts_with("http://")
|
||||||
|
|| url_lower.starts_with("https://")
|
||||||
|
|| url_lower.starts_with("about:")
|
||||||
|
|| url_lower.starts_with("data:")
|
||||||
|
|| url_lower.starts_with("file:") {
|
||||||
url.to_string()
|
url.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("https://{}", url)
|
format!("https://{}", url)
|
||||||
@@ -153,13 +158,18 @@ 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...>",
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({ "id": id, "action": "select", "selector": sel, "value": val }))
|
let values = &rest[1..];
|
||||||
|
if values.len() == 1 {
|
||||||
|
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values[0] }))
|
||||||
|
} else {
|
||||||
|
Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"drag" => {
|
"drag" => {
|
||||||
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
|
let src = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
|
||||||
@@ -273,7 +283,11 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
|
|
||||||
// === Screenshot/PDF ===
|
// === Screenshot/PDF ===
|
||||||
"screenshot" => {
|
"screenshot" => {
|
||||||
Ok(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full }))
|
let mut cmd = json!({ "id": id, "action": "screenshot", "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 {
|
||||||
@@ -323,6 +337,19 @@ 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),
|
||||||
|
|
||||||
@@ -367,10 +394,20 @@ 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") => Ok(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })),
|
Some("new") => {
|
||||||
|
let mut cmd = json!({ "id": id, "action": "tab_new" });
|
||||||
|
if let Some(url) = rest.get(1) {
|
||||||
|
cmd["url"] = json!(url);
|
||||||
|
}
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
|
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
|
||||||
Some("close") => {
|
Some("close") => {
|
||||||
Ok(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::<i32>().ok()) }))
|
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
||||||
|
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
|
||||||
|
cmd["index"] = json!(index);
|
||||||
|
}
|
||||||
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
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() }))
|
||||||
@@ -398,7 +435,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": "frame_main" }))
|
Ok(json!({ "id": id, "action": "mainframe" }))
|
||||||
} 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(),
|
||||||
@@ -413,7 +450,11 @@ 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") => {
|
||||||
Ok(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) }))
|
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" });
|
||||||
|
if let Some(prompt_text) = rest.get(1) {
|
||||||
|
cmd["promptText"] = json!(prompt_text);
|
||||||
|
}
|
||||||
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -431,8 +472,14 @@ 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", "path": rest.get(1) })),
|
Some("start") => Ok(json!({ "id": id, "action": "trace_start" })),
|
||||||
Some("stop") => Ok(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })),
|
Some("stop") => {
|
||||||
|
let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||||
|
context: "trace stop".to_string(),
|
||||||
|
usage: "trace stop <path>",
|
||||||
|
})?;
|
||||||
|
Ok(json!({ "id": id, "action": "trace_stop", "path": path }))
|
||||||
|
},
|
||||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
valid_options: VALID,
|
valid_options: VALID,
|
||||||
@@ -443,6 +490,60 @@ 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 }))
|
||||||
@@ -495,7 +596,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"];
|
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"];
|
||||||
|
|
||||||
match rest.get(0).map(|s| *s) {
|
match rest.get(0).map(|s| *s) {
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
@@ -546,13 +647,20 @@ 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> [args...]",
|
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,15 +738,39 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match *locator {
|
match *locator {
|
||||||
"role" => Ok(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })),
|
"role" => {
|
||||||
|
let mut cmd = json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "name": name, "exact": exact });
|
||||||
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
"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" => Ok(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })),
|
"label" => {
|
||||||
"placeholder" => Ok(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })),
|
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact });
|
||||||
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
"placeholder" => {
|
||||||
|
let mut cmd = json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "exact": exact });
|
||||||
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
"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" => Ok(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })),
|
"testid" => {
|
||||||
"first" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })),
|
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
|
||||||
"last" => Ok(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })),
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
"first" => {
|
||||||
|
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction });
|
||||||
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
"last" => {
|
||||||
|
let mut cmd = json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction });
|
||||||
|
if let Some(v) = fill_value { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,7 +793,9 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv }))
|
let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub });
|
||||||
|
if let Some(v) = fv { cmd["value"] = json!(v); }
|
||||||
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
_ => Err(ParseError::UnknownSubcommand {
|
_ => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: locator.to_string(),
|
subcommand: locator.to_string(),
|
||||||
@@ -702,7 +836,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": "mousewheel", "deltaX": dx, "deltaY": dy }))
|
Ok(json!({ "id": id, "action": "wheel", "deltaX": dx, "deltaY": dy }))
|
||||||
}
|
}
|
||||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
@@ -800,8 +934,12 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
} else {
|
} else {
|
||||||
"no-preference"
|
"no-preference"
|
||||||
};
|
};
|
||||||
let reduced = rest.iter().any(|&s| s == "reduced-motion");
|
let reduced = if rest.iter().any(|&s| s == "reduced-motion") {
|
||||||
Ok(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced }))
|
"reduce"
|
||||||
|
} 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(),
|
||||||
@@ -828,12 +966,22 @@ 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") => Ok(json!({ "id": id, "action": "unroute", "url": rest.get(1) })),
|
Some("unroute") => {
|
||||||
|
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));
|
||||||
Ok(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter }))
|
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
|
||||||
|
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(),
|
||||||
@@ -903,6 +1051,7 @@ mod tests {
|
|||||||
executable_path: None,
|
executable_path: None,
|
||||||
extensions: Vec::new(),
|
extensions: Vec::new(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
|
proxy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1147,12 +1296,46 @@ 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]
|
||||||
@@ -1180,6 +1363,14 @@ 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]
|
||||||
@@ -1273,6 +1464,82 @@ 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());
|
||||||
@@ -1316,4 +1583,54 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-11
@@ -104,7 +104,9 @@ 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);
|
||||||
}
|
}
|
||||||
49152 + ((hash.abs() as u16) % 16383)
|
// Correct logic: first take absolute modulo, then cast to u16
|
||||||
|
// Using unsigned_abs() to safely handle i32::MIN
|
||||||
|
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -140,7 +142,8 @@ fn is_daemon_running(session: &str) -> bool {
|
|||||||
fn daemon_ready(session: &str) -> bool {
|
fn daemon_ready(session: &str) -> bool {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
get_socket_path(session).exists()
|
let socket_path = get_socket_path(session);
|
||||||
|
UnixStream::connect(&socket_path).is_ok()
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -174,16 +177,23 @@ 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 daemon_paths = [
|
let mut daemon_paths = vec![
|
||||||
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. Run from project directory or ensure daemon.js is alongside binary.")?;
|
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
|
||||||
|
|
||||||
// Spawn daemon as a fully detached background process
|
// Spawn daemon as a fully detached background process
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -227,13 +237,10 @@ pub fn ensure_daemon(
|
|||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||||
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
|
// and automatically quotes arguments containing spaces.
|
||||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
let mut cmd = Command::new("node");
|
||||||
let cmd_string = format!("node \"{}\"", daemon_path.display());
|
cmd.arg(daemon_path)
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -10,6 +10,7 @@ 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 {
|
||||||
@@ -28,6 +29,7 @@ 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;
|
||||||
@@ -67,6 +69,12 @@ 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;
|
||||||
@@ -81,7 +89,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"];
|
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"];
|
||||||
|
|
||||||
for arg in args.iter() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
|
|||||||
+32
-13
@@ -1,3 +1,4 @@
|
|||||||
|
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) {
|
||||||
@@ -5,9 +6,15 @@ pub fn run_install(with_deps: bool) {
|
|||||||
|
|
||||||
if is_linux {
|
if is_linux {
|
||||||
if with_deps {
|
if with_deps {
|
||||||
println!("\x1b[36mInstalling system dependencies...\x1b[0m");
|
println!("{}", color::cyan("Installing system dependencies..."));
|
||||||
|
|
||||||
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![
|
||||||
@@ -30,7 +37,7 @@ pub fn run_install(with_deps: bool) {
|
|||||||
"libcairo2",
|
"libcairo2",
|
||||||
"libgdk-pixbuf-2.0-0",
|
"libgdk-pixbuf-2.0-0",
|
||||||
"libxrender1",
|
"libxrender1",
|
||||||
"libasound2",
|
libasound,
|
||||||
"libfreetype6",
|
"libfreetype6",
|
||||||
"libfontconfig1",
|
"libfontconfig1",
|
||||||
"libdbus-1-3",
|
"libdbus-1-3",
|
||||||
@@ -93,7 +100,7 @@ pub fn run_install(with_deps: bool) {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)");
|
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
|
||||||
exit(1);
|
exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,22 +119,23 @@ pub fn run_install(with_deps: bool) {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
Ok(s) if s.success() => {
|
Ok(s) if s.success() => {
|
||||||
println!("\x1b[32m✓\x1b[0m System dependencies installed")
|
println!("{} System dependencies installed", color::success_indicator())
|
||||||
}
|
}
|
||||||
Ok(_) => eprintln!(
|
Ok(_) => eprintln!(
|
||||||
"\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
|
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||||
|
color::warning_indicator()
|
||||||
),
|
),
|
||||||
Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e),
|
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:");
|
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
|
||||||
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!("\x1b[36mInstalling Chromium browser...\x1b[0m");
|
println!("{}", color::cyan("Installing Chromium browser..."));
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -144,23 +152,23 @@ pub fn run_install(with_deps: bool) {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
Ok(s) if s.success() => {
|
Ok(s) if s.success() => {
|
||||||
println!("\x1b[32m✓\x1b[0m Chromium installed successfully");
|
println!("{} Chromium installed successfully", color::success_indicator());
|
||||||
if is_linux && !with_deps {
|
if is_linux && !with_deps {
|
||||||
println!();
|
println!();
|
||||||
println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:");
|
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
|
||||||
println!(" agent-browser install --with-deps");
|
println!(" agent-browser install --with-deps");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
|
eprintln!("{} Failed to install browser", color::error_indicator());
|
||||||
if is_linux {
|
if is_linux {
|
||||||
println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:");
|
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
|
||||||
println!(" agent-browser install --with-deps");
|
println!(" agent-browser install --with-deps");
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e);
|
eprintln!("{} Failed to run npx: {}", color::error_indicator(), 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);
|
||||||
}
|
}
|
||||||
@@ -189,3 +197,14 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
+130
-21
@@ -1,3 +1,4 @@
|
|||||||
|
mod color;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod flags;
|
mod flags;
|
||||||
@@ -21,7 +22,37 @@ 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};
|
use output::{print_command_help, print_help, print_response, print_version};
|
||||||
|
|
||||||
|
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
|
||||||
|
let Some(protocol_end) = proxy_str.find("://") else {
|
||||||
|
return json!({ "server": proxy_str });
|
||||||
|
};
|
||||||
|
let protocol = &proxy_str[..protocol_end + 3];
|
||||||
|
let rest = &proxy_str[protocol_end + 3..];
|
||||||
|
|
||||||
|
let Some(at_pos) = rest.rfind('@') else {
|
||||||
|
return json!({ "server": proxy_str });
|
||||||
|
};
|
||||||
|
|
||||||
|
let creds = &rest[..at_pos];
|
||||||
|
let server_part = &rest[at_pos + 1..];
|
||||||
|
let server = format!("{}{}", protocol, server_part);
|
||||||
|
|
||||||
|
let Some(colon_pos) = creds.find(':') else {
|
||||||
|
return json!({
|
||||||
|
"server": server,
|
||||||
|
"username": creds,
|
||||||
|
"password": ""
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"server": server,
|
||||||
|
"username": &creds[..colon_pos],
|
||||||
|
"password": &creds[colon_pos + 1..]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
@@ -77,7 +108,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 { "→" } else { " " };
|
let marker = if s == session { color::cyan("→") } else { " ".to_string() };
|
||||||
println!("{} {}", marker, s);
|
println!("{} {}", marker, s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,16 +125,18 @@ 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) {
|
||||||
@@ -115,6 +148,16 @@ 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");
|
||||||
@@ -143,7 +186,7 @@ fn main() {
|
|||||||
error_type
|
error_type
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m{}\x1b[0m", e.format());
|
eprintln!("{}", color::red(&e.format()));
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -155,7 +198,7 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", e);
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -165,10 +208,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!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
|
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
|
||||||
}
|
}
|
||||||
if !flags.extensions.is_empty() {
|
if !flags.extensions.is_empty() {
|
||||||
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
|
eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +224,7 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -190,7 +233,7 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -200,7 +243,7 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -222,23 +265,30 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch headed browser if --headed flag is set (without CDP)
|
// Launch headed browser or proxy if flags are set (without CDP)
|
||||||
if flags.headed && flags.cdp.is_none() {
|
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
|
||||||
let launch_cmd = json!({
|
let mut launch_cmd = json!({
|
||||||
"id": gen_id(),
|
"id": gen_id(),
|
||||||
"action": "launch",
|
"action": "launch",
|
||||||
"headless": false
|
"headless": !flags.headed
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
|
eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,9 +305,68 @@ fn main() {
|
|||||||
if flags.json {
|
if flags.json {
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", e);
|
eprintln!("{} {}", color::error_indicator(), 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+162
-23
@@ -1,3 +1,4 @@
|
|||||||
|
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) {
|
||||||
@@ -8,7 +9,8 @@ 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;
|
||||||
@@ -18,8 +20,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!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title);
|
println!("{} {}", color::success_indicator(), color::bold(title));
|
||||||
println!("\x1b[2m {}\x1b[0m", url);
|
println!(" {}", color::dim(url));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
println!("{}", url);
|
println!("{}", url);
|
||||||
@@ -85,23 +87,17 @@ 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 { "→" } else { " " };
|
let marker = if active { color::cyan("→") } else { " ".to_string() };
|
||||||
println!("{} [{}] {} - {}", marker, i, title, url);
|
println!("{} [{}] {} - {}", marker, i, title, url);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Console logs
|
// Console logs
|
||||||
if let Some(logs) = data.get("logs").and_then(|v| v.as_array()) {
|
if let Some(logs) = data.get("messages").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("");
|
||||||
let color = match level {
|
println!("{} {}", color::console_level_prefix(level), text);
|
||||||
"error" => "\x1b[31m",
|
|
||||||
"warning" => "\x1b[33m",
|
|
||||||
"info" => "\x1b[36m",
|
|
||||||
_ => "\x1b[0m",
|
|
||||||
};
|
|
||||||
println!("{}[{}]\x1b[0m {}", color, level, text);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -109,7 +105,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!("\x1b[31m✗\x1b[0m {}", msg);
|
println!("{} {}", color::error_indicator(), msg);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,6 +118,27 @@ 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!(
|
||||||
@@ -130,18 +147,91 @@ pub fn print_response(resp: &Response, json_mode: bool) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Closed
|
// Element styles
|
||||||
if data.get("closed").is_some() {
|
if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) {
|
||||||
println!("\x1b[32m✓\x1b[0m Browser closed");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Screenshot path
|
// Closed
|
||||||
|
if data.get("closed").is_some() {
|
||||||
|
println!("{} Browser closed", color::success_indicator());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Recording start (has "started" field)
|
||||||
|
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
|
||||||
|
if started {
|
||||||
|
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||||
|
println!("{} Recording started: {}", color::success_indicator(), path);
|
||||||
|
} else {
|
||||||
|
println!("{} Recording started", color::success_indicator());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recording restart (has "stopped" field - from recording_restart action)
|
||||||
|
if data.get("stopped").is_some() {
|
||||||
|
let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
|
if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) {
|
||||||
|
println!("{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path);
|
||||||
|
} else {
|
||||||
|
println!("{} Recording started: {}", color::success_indicator(), path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Recording stop (has "frames" field - from recording_stop action)
|
||||||
|
if data.get("frames").is_some() {
|
||||||
|
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(error) = data.get("error").and_then(|v| v.as_str()) {
|
||||||
|
println!("{} Recording saved to {} - {}", color::warning_indicator(), path, error);
|
||||||
|
} else {
|
||||||
|
println!("{} Recording saved to {}", color::success_indicator(), path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("{} Recording stopped", color::success_indicator());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screenshot path (no "started" or "frames" field)
|
||||||
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!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path);
|
println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screenshot base64
|
||||||
|
if let Some(base64) = data.get("base64").and_then(|v| v.as_str()) {
|
||||||
|
println!("{}", base64);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Default success
|
// Default success
|
||||||
println!("\x1b[32m✓\x1b[0m Done");
|
println!("{} Done", color::success_indicator());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,9 +440,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 an option in a <select> dropdown by its value attribute.
|
Selects one or more options in a <select> dropdown by value.
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -361,6 +451,7 @@ 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
|
||||||
@@ -642,6 +733,7 @@ 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
|
||||||
@@ -656,6 +748,8 @@ 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 ===
|
||||||
@@ -979,6 +1073,42 @@ 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
|
||||||
@@ -1121,7 +1251,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)
|
||||||
@@ -1131,6 +1261,7 @@ 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:
|
||||||
@@ -1139,7 +1270,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
|
text, html, value, attr <name>, title, url, count, box, styles
|
||||||
|
|
||||||
Check State: agent-browser is <what> <selector>
|
Check State: agent-browser is <what> <selector>
|
||||||
visible, enabled, checked
|
visible, enabled, checked
|
||||||
@@ -1169,6 +1300,8 @@ 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
|
||||||
@@ -1192,11 +1325,13 @@ 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")
|
||||||
@@ -1215,3 +1350,7 @@ Examples:
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn print_version() {
|
||||||
|
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ 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={`# Connect to Electron app
|
<CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
|
||||||
agent-browser --cdp 9222 snapshot
|
|
||||||
|
|
||||||
# Connect to Chrome with remote debugging
|
# Connect once, then run commands without --cdp
|
||||||
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
|
agent-browser connect 9222
|
||||||
agent-browser --cdp 9222 open about:blank`} />
|
agent-browser snapshot
|
||||||
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Changelog() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Changelog</h1>
|
||||||
|
|
||||||
|
<h2 id="v0.6.0">v0.6.0</h2>
|
||||||
|
<p className="text-[#888] text-sm">January 2026</p>
|
||||||
|
|
||||||
|
<h3>New Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Video recording</strong> - Record browser sessions to WebM using Playwright's native recording
|
||||||
|
<CodeBlock code={`agent-browser record start ./demo.webm
|
||||||
|
agent-browser click @e1
|
||||||
|
agent-browser record stop`} />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><code>connect</code> command</strong> - Connect to a browser via CDP and persist the connection for subsequent commands
|
||||||
|
<CodeBlock code={`agent-browser connect 9222
|
||||||
|
agent-browser snapshot # No --cdp needed after connect`} />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><code>--proxy</code> flag</strong> - Configure browser proxy with optional authentication
|
||||||
|
<CodeBlock code="agent-browser --proxy http://user:pass@proxy.com:8080 open example.com" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><code>get styles</code> command</strong> - Extract computed styles from elements
|
||||||
|
<CodeBlock code={`agent-browser get styles "button"`} />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Claude marketplace plugin</strong> - Added <code>.claude-plugin/marketplace.json</code> for Claude Code integration
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Enhanced network output</strong> - <code>network requests</code> now shows method, URL, and resource type
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><code>--version</code> flag</strong> - Display CLI version
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Bug Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Fix Windows daemon startup and port calculation</li>
|
||||||
|
<li>Support <code>libasound2t64</code> on newer Ubuntu versions (24.04+)</li>
|
||||||
|
<li>Prevent CDP timeout on empty URL tabs</li>
|
||||||
|
<li>Output screenshot as base64 when no path provided</li>
|
||||||
|
<li>Resolve refs in <code>get value</code> command</li>
|
||||||
|
<li>Support URL parameter in <code>tab new</code> command</li>
|
||||||
|
<li>Allow <code>about:</code>, <code>data:</code>, and <code>file:</code> URL schemes</li>
|
||||||
|
<li>Detect stale unix socket by attempting connection</li>
|
||||||
|
<li>Respect <code>AGENT_BROWSER_HEADED</code> environment variable</li>
|
||||||
|
<li>Handle SIGPIPE to prevent panic when piping to <code>head</code>/<code>tail</code></li>
|
||||||
|
<li>Fix null path validation in screenshot command</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Protocol Alignment</h3>
|
||||||
|
<p>These changes align the CLI with the daemon protocol for consistency:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>select</code> command now uses <code>values</code> field (supports multiple selections)</li>
|
||||||
|
<li><code>frame main</code> uses <code>mainframe</code> action</li>
|
||||||
|
<li><code>mouse wheel</code> uses <code>wheel</code> action</li>
|
||||||
|
<li><code>set media</code> uses <code>emulatemedia</code> action</li>
|
||||||
|
<li>Console output uses <code>messages</code> field</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Documentation</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Expanded SKILL.md with comprehensive command reference</li>
|
||||||
|
<li>Updated README with new commands and options</li>
|
||||||
|
<li>Updated CDP mode documentation with <code>connect</code> workflow</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ 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 page.png
|
agent-browser screenshot # Base64 png to stdout
|
||||||
|
agent-browser screenshot page.png # Save to file
|
||||||
agent-browser close`} />
|
agent-browser close`} />
|
||||||
|
|
||||||
<h2>Traditional selectors</h2>
|
<h2>Traditional selectors</h2>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const navigation = [
|
|||||||
{ name: "Streaming", href: "/streaming" },
|
{ name: "Streaming", href: "/streaming" },
|
||||||
{ name: "Agent Mode", href: "/agent-mode" },
|
{ name: "Agent Mode", href: "/agent-mode" },
|
||||||
{ name: "CDP Mode", href: "/cdp-mode" },
|
{ name: "CDP Mode", href: "/cdp-mode" },
|
||||||
|
{ name: "Changelog", href: "/changelog" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser",
|
"name": "agent-browser",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"description": "Headless browser automation CLI for AI agents",
|
"description": "Headless browser automation CLI for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
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
|
||||||
@@ -28,56 +29,93 @@ agent-browser close # Close browser
|
|||||||
```bash
|
```bash
|
||||||
agent-browser open <url> # Navigate to URL
|
agent-browser open <url> # Navigate to URL
|
||||||
agent-browser back # Go back
|
agent-browser back # Go back
|
||||||
agent-browser forward # Go forward
|
agent-browser forward # Go forward
|
||||||
agent-browser reload # Reload page
|
agent-browser reload # Reload page
|
||||||
agent-browser close # Close browser
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Screenshots
|
### Check state
|
||||||
|
```bash
|
||||||
|
agent-browser is visible @e1 # Check if visible
|
||||||
|
agent-browser is enabled @e1 # Check if enabled
|
||||||
|
agent-browser is checked @e1 # Check if checked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshots & PDF
|
||||||
```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)
|
||||||
@@ -85,6 +123,66 @@ agent-browser wait --load networkidle # Wait for network idle
|
|||||||
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
|
||||||
@@ -137,7 +235,18 @@ 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
|
||||||
```
|
```
|
||||||
|
|||||||
+117
-3
@@ -26,6 +26,7 @@ import type {
|
|||||||
SelectCommand,
|
SelectCommand,
|
||||||
HoverCommand,
|
HoverCommand,
|
||||||
ContentCommand,
|
ContentCommand,
|
||||||
|
TabNewCommand,
|
||||||
TabSwitchCommand,
|
TabSwitchCommand,
|
||||||
TabCloseCommand,
|
TabCloseCommand,
|
||||||
WindowNewCommand,
|
WindowNewCommand,
|
||||||
@@ -49,6 +50,7 @@ import type {
|
|||||||
IsCheckedCommand,
|
IsCheckedCommand,
|
||||||
CountCommand,
|
CountCommand,
|
||||||
BoundingBoxCommand,
|
BoundingBoxCommand,
|
||||||
|
StylesCommand,
|
||||||
TraceStartCommand,
|
TraceStartCommand,
|
||||||
TraceStopCommand,
|
TraceStopCommand,
|
||||||
HarStopCommand,
|
HarStopCommand,
|
||||||
@@ -99,6 +101,9 @@ import type {
|
|||||||
InputMouseCommand,
|
InputMouseCommand,
|
||||||
InputKeyboardCommand,
|
InputKeyboardCommand,
|
||||||
InputTouchCommand,
|
InputTouchCommand,
|
||||||
|
RecordingStartCommand,
|
||||||
|
RecordingStopCommand,
|
||||||
|
RecordingRestartCommand,
|
||||||
NavigateData,
|
NavigateData,
|
||||||
ScreenshotData,
|
ScreenshotData,
|
||||||
EvaluateData,
|
EvaluateData,
|
||||||
@@ -109,7 +114,11 @@ 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';
|
||||||
|
|
||||||
@@ -311,6 +320,8 @@ 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':
|
||||||
@@ -427,6 +438,12 @@ 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 };
|
||||||
@@ -697,10 +714,17 @@ async function handleClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTabNew(
|
async function handleTabNew(
|
||||||
command: Command & { action: 'tab_new' },
|
command: TabNewCommand,
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1250,62 @@ 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(
|
||||||
@@ -1424,8 +1504,8 @@ async function handleInputValue(
|
|||||||
command: InputValueCommand,
|
command: InputValueCommand,
|
||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const page = browser.getPage();
|
const locator = browser.getLocator(command.selector);
|
||||||
const value = await page.locator(command.selector).inputValue();
|
const value = await locator.inputValue();
|
||||||
return successResponse(command.id, { value });
|
return successResponse(command.id, { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1886,3 +1966,37 @@ 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+32
-2
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } 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;
|
||||||
@@ -390,6 +391,35 @@ 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', () => {
|
||||||
@@ -405,7 +435,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, 200));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
await browser.stopScreencast();
|
await browser.stopScreencast();
|
||||||
expect(browser.isScreencasting()).toBe(false);
|
expect(browser.isScreencasting()).toBe(false);
|
||||||
|
|||||||
+245
-4
@@ -12,9 +12,11 @@ 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';
|
||||||
|
|
||||||
@@ -89,6 +91,12 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -682,6 +690,7 @@ 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;
|
||||||
@@ -691,10 +700,14 @@ export class BrowserManager {
|
|||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
});
|
});
|
||||||
this.cdpPort = null;
|
this.cdpPort = null;
|
||||||
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
|
context = await this.browser.newContext({
|
||||||
|
viewport,
|
||||||
|
extraHTTPHeaders: options.headers,
|
||||||
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(60000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? (await context.newPage());
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
@@ -725,7 +738,9 @@ 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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPages = contexts.flatMap((context) => context.pages());
|
// Filter out pages with empty URLs, which can cause Playwright to hang
|
||||||
|
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.');
|
||||||
}
|
}
|
||||||
@@ -828,7 +843,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(10000);
|
context.setDefaultTimeout(60000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -1103,10 +1118,236 @@ 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
@@ -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: true,
|
headless: process.env.AGENT_BROWSER_HEADED !== '1',
|
||||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ 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);
|
||||||
@@ -373,6 +389,14 @@ 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);
|
||||||
|
|||||||
+40
-1
@@ -19,12 +19,24 @@ 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({
|
||||||
@@ -295,6 +307,11 @@ 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),
|
||||||
@@ -304,6 +321,23 @@ 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(),
|
||||||
@@ -642,7 +676,7 @@ const pressSchema = baseCommandSchema.extend({
|
|||||||
|
|
||||||
const screenshotSchema = baseCommandSchema.extend({
|
const screenshotSchema = baseCommandSchema.extend({
|
||||||
action: z.literal('screenshot'),
|
action: z.literal('screenshot'),
|
||||||
path: z.string().optional(),
|
path: z.string().nullable().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(),
|
||||||
@@ -702,6 +736,7 @@ 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({
|
||||||
@@ -791,8 +826,12 @@ 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,
|
||||||
|
|||||||
+75
-1
@@ -16,6 +16,12 @@ 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 {
|
||||||
@@ -310,6 +316,12 @@ 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';
|
||||||
@@ -502,7 +514,7 @@ export interface InputTouchCommand extends BaseCommand {
|
|||||||
modifiers?: number;
|
modifiers?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video recording
|
// Video recording (Playwright native - requires launch-time setup)
|
||||||
export interface VideoStartCommand extends BaseCommand {
|
export interface VideoStartCommand extends BaseCommand {
|
||||||
action: 'video_start';
|
action: 'video_start';
|
||||||
path: string;
|
path: string;
|
||||||
@@ -512,6 +524,23 @@ 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';
|
||||||
@@ -749,6 +778,7 @@ 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 {
|
||||||
@@ -833,8 +863,12 @@ export type Command =
|
|||||||
| IsCheckedCommand
|
| IsCheckedCommand
|
||||||
| CountCommand
|
| CountCommand
|
||||||
| BoundingBoxCommand
|
| BoundingBoxCommand
|
||||||
|
| StylesCommand
|
||||||
| VideoStartCommand
|
| VideoStartCommand
|
||||||
| VideoStopCommand
|
| VideoStopCommand
|
||||||
|
| RecordingStartCommand
|
||||||
|
| RecordingStopCommand
|
||||||
|
| RecordingRestartCommand
|
||||||
| TraceStartCommand
|
| TraceStartCommand
|
||||||
| TraceStopCommand
|
| TraceStopCommand
|
||||||
| HarStartCommand
|
| HarStartCommand
|
||||||
@@ -968,10 +1002,50 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user