feat: add browser launch --args, --user-agent, --proxy-bypass configuration support. (#35)

* feat: add browser launch args, user-agent, and proxy configuration support

* fix: User Agent env need added

* fix: command pass error

---------

Co-authored-by: Chris Tate <chris@ctate.dev>
This commit is contained in:
Oanakiaja
2026-01-22 00:19:37 -06:00
committed by GitHub
co-authored by Chris Tate
parent e892bceadf
commit 083a946aac
12 changed files with 1075 additions and 198 deletions
+4
View File
@@ -298,6 +298,10 @@ agent-browser snapshot -i -c -d 5 # Combine options
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
| `--args <args>` | Browser launch args, comma or newline separated (or `AGENT_BROWSER_ARGS` env) |
| `--user-agent <ua>` | Custom User-Agent string (or `AGENT_BROWSER_USER_AGENT` env) |
| `--proxy <url>` | Proxy server URL with optional auth (or `AGENT_BROWSER_PROXY` env) |
| `--proxy-bypass <hosts>` | Hosts to bypass proxy (or `AGENT_BROWSER_PROXY_BYPASS` env) |
| `--json` | JSON output (for agents) |
| `--full, -f` | Full page screenshot |
| `--name, -n` | Locator name filter |
+155 -77
View File
@@ -216,7 +216,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Scroll ===
"scroll" => {
let dir = rest.get(0).unwrap_or(&"down");
let amount = rest.get(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(300);
let amount = rest
.get(1)
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(300);
Ok(json!({ "id": id, "action": "scroll", "direction": dir, "amount": amount }))
}
"scrollintoview" | "scrollinto" => {
@@ -231,45 +234,57 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"wait" => {
// Check for --url flag: wait --url "**/dashboard"
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
let url = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
context: "wait --url".to_string(),
usage: "wait --url <pattern>",
})?;
let url = rest
.get(idx + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: "wait --url".to_string(),
usage: "wait --url <pattern>",
})?;
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
}
// Check for --load flag: wait --load networkidle
if let Some(idx) = rest.iter().position(|&s| s == "--load" || s == "-l") {
let state = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
context: "wait --load".to_string(),
usage: "wait --load <state>",
})?;
let state = rest
.get(idx + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: "wait --load".to_string(),
usage: "wait --load <state>",
})?;
return Ok(json!({ "id": id, "action": "waitforloadstate", "state": state }));
}
// Check for --fn flag: wait --fn "window.ready === true"
if let Some(idx) = rest.iter().position(|&s| s == "--fn" || s == "-f") {
let expr = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
context: "wait --fn".to_string(),
usage: "wait --fn <expression>",
})?;
let expr = rest
.get(idx + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: "wait --fn".to_string(),
usage: "wait --fn <expression>",
})?;
return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr }));
}
// Check for --text flag: wait --text "Welcome"
if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") {
let text = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
context: "wait --text".to_string(),
usage: "wait --text <text>",
})?;
let text = rest
.get(idx + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: "wait --text".to_string(),
usage: "wait --text <text>",
})?;
// Use getByText locator to wait for text to appear
return Ok(json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }));
return Ok(
json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }),
);
}
// Default: selector or timeout
if let Some(arg) = rest.get(0) {
if arg.parse::<u64>().is_ok() {
Ok(json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }))
Ok(
json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }),
)
} else {
Ok(json!({ "id": id, "action": "wait", "selector": arg }))
}
@@ -384,7 +399,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
context: "cookies set".to_string(),
usage: "cookies set <name> <value>",
})?;
Ok(json!({ "id": id, "action": "cookies_set", "cookies": [{ "name": name, "value": value }] }))
Ok(
json!({ "id": id, "action": "cookies_set", "cookies": [{ "name": name, "value": value }] }),
)
}
"clear" => Ok(json!({ "id": id, "action": "cookies_clear" })),
_ => Ok(json!({ "id": id, "action": "cookies_get" })),
@@ -414,7 +431,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
_ => Ok(json!({ "id": id, "action": "tab_list" })),
}
}
},
// === Window ===
"window" => {
@@ -456,7 +473,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
Ok(cmd)
}
Some("dismiss") => Ok(json!({ "id": id, "action": "dialog", "response": "dismiss" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
@@ -667,7 +683,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
fn parse_is(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["visible", "enabled", "checked"];
match rest.get(0).map(|s| *s) {
Some("visible") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -702,19 +718,31 @@ fn parse_is(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["role", "text", "label", "placeholder", "alt", "title", "testid", "first", "last", "nth"];
const VALID: &[&str] = &[
"role",
"text",
"label",
"placeholder",
"alt",
"title",
"testid",
"first",
"last",
"nth",
];
let locator = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
context: "find".to_string(),
usage: "find <locator> <value> [action] [text]",
})?;
let name_idx = rest.iter().position(|&s| s == "--name");
let name = name_idx.and_then(|i| rest.get(i + 1).map(|s| *s));
let exact = rest.iter().any(|&s| s == "--exact");
match *locator {
"role" | "text" | "label" | "placeholder" | "alt" | "title" | "testid" | "first" | "last" => {
"role" | "text" | "label" | "placeholder" | "alt" | "title" | "testid" | "first"
| "last" => {
let value = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: format!("find {}", locator),
usage: match *locator {
@@ -779,10 +807,12 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "find nth".to_string(),
usage: "find nth <index> <selector> [action] [text]",
})?;
let idx = idx_str.parse::<i32>().map_err(|_| ParseError::MissingArguments {
context: "find nth".to_string(),
usage: "find nth <index> <selector> [action] [text]",
})?;
let idx = idx_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "find nth".to_string(),
usage: "find nth <index> <selector> [action] [text]",
})?;
let sel = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
context: "find nth".to_string(),
usage: "find nth <index> <selector> [action] [text]",
@@ -806,7 +836,7 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["move", "down", "up", "wheel"];
match rest.get(0).map(|s| *s) {
Some("move") => {
let x_str = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -817,14 +847,18 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "mouse move".to_string(),
usage: "mouse move <x> <y>",
})?;
let x = x_str.parse::<i32>().map_err(|_| ParseError::MissingArguments {
context: "mouse move".to_string(),
usage: "mouse move <x> <y>",
})?;
let y = y_str.parse::<i32>().map_err(|_| ParseError::MissingArguments {
context: "mouse move".to_string(),
usage: "mouse move <x> <y>",
})?;
let x = x_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "mouse move".to_string(),
usage: "mouse move <x> <y>",
})?;
let y = y_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "mouse move".to_string(),
usage: "mouse move <x> <y>",
})?;
Ok(json!({ "id": id, "action": "mousemove", "x": x, "y": y }))
}
Some("down") => {
@@ -834,7 +868,10 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
Ok(json!({ "id": id, "action": "mouseup", "button": rest.get(1).unwrap_or(&"left") }))
}
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);
Ok(json!({ "id": id, "action": "wheel", "deltaX": dx, "deltaY": dy }))
}
@@ -850,8 +887,18 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["viewport", "device", "geo", "geolocation", "offline", "headers", "credentials", "auth", "media"];
const VALID: &[&str] = &[
"viewport",
"device",
"geo",
"geolocation",
"offline",
"headers",
"credentials",
"auth",
"media",
];
match rest.get(0).map(|s| *s) {
Some("viewport") => {
let w_str = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -862,14 +909,18 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
})?;
let w = w_str.parse::<i32>().map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
})?;
let h = h_str.parse::<i32>().map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
})?;
let w = w_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
})?;
let h = h_str
.parse::<i32>()
.map_err(|_| ParseError::MissingArguments {
context: "set viewport".to_string(),
usage: "set viewport <width> <height>",
})?;
Ok(json!({ "id": id, "action": "viewport", "width": w, "height": h }))
}
Some("device") => {
@@ -888,18 +939,25 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: "set geo".to_string(),
usage: "set geo <latitude> <longitude>",
})?;
let lat = lat_str.parse::<f64>().map_err(|_| ParseError::MissingArguments {
context: "set geo".to_string(),
usage: "set geo <latitude> <longitude>",
})?;
let lng = lng_str.parse::<f64>().map_err(|_| ParseError::MissingArguments {
context: "set geo".to_string(),
usage: "set geo <latitude> <longitude>",
})?;
let lat = lat_str
.parse::<f64>()
.map_err(|_| ParseError::MissingArguments {
context: "set geo".to_string(),
usage: "set geo <latitude> <longitude>",
})?;
let lng = lng_str
.parse::<f64>()
.map_err(|_| ParseError::MissingArguments {
context: "set geo".to_string(),
usage: "set geo <latitude> <longitude>",
})?;
Ok(json!({ "id": id, "action": "geolocation", "latitude": lat, "longitude": lng }))
}
Some("offline") => {
let off = rest.get(1).map(|s| *s != "off" && *s != "false").unwrap_or(true);
let off = rest
.get(1)
.map(|s| *s != "off" && *s != "false")
.unwrap_or(true);
Ok(json!({ "id": id, "action": "offline", "offline": off }))
}
Some("headers") => {
@@ -908,8 +966,8 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
usage: "set headers <json>",
})?;
// Parse the JSON string into an object
let headers: serde_json::Value = serde_json::from_str(headers_json)
.map_err(|_| ParseError::MissingArguments {
let headers: serde_json::Value =
serde_json::from_str(headers_json).map_err(|_| ParseError::MissingArguments {
context: "set headers".to_string(),
usage: "set headers <json> (must be valid JSON object)",
})?;
@@ -954,7 +1012,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["route", "unroute", "requests"];
match rest.get(0).map(|s| *s) {
Some("route") => {
let url = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -996,7 +1054,7 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
fn parse_storage(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["local", "session"];
match rest.get(0).map(|s| *s) {
Some("local") | Some("session") => {
let storage_type = rest.get(0).unwrap();
@@ -1013,13 +1071,18 @@ fn parse_storage(rest: &[&str], id: &str) -> Result<Value, ParseError> {
context: format!("storage {} set", storage_type),
usage: "storage <local|session> set <key> <value>",
})?;
Ok(json!({ "id": id, "action": "storage_set", "type": storage_type, "key": k, "value": v }))
Ok(
json!({ "id": id, "action": "storage_set", "type": storage_type, "key": k, "value": v }),
)
}
"clear" => Ok(json!({ "id": id, "action": "storage_clear", "type": storage_type })),
_ => {
let mut cmd = json!({ "id": id, "action": "storage_get", "type": storage_type });
let mut cmd =
json!({ "id": id, "action": "storage_get", "type": storage_type });
if let Some(k) = key {
cmd.as_object_mut().unwrap().insert("key".to_string(), json!(k));
cmd.as_object_mut()
.unwrap()
.insert("key".to_string(), json!(k));
}
Ok(cmd)
}
@@ -1052,6 +1115,9 @@ mod tests {
extensions: Vec::new(),
cdp: None,
proxy: None,
proxy_bypass: None,
args: None,
user_agent: None,
}
}
@@ -1120,7 +1186,8 @@ mod tests {
#[test]
fn test_storage_local_set() {
let cmd = parse_command(&args("storage local set mykey myvalue"), &default_flags()).unwrap();
let cmd =
parse_command(&args("storage local set mykey myvalue"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "storage_set");
assert_eq!(cmd["type"], "local");
assert_eq!(cmd["key"], "mykey");
@@ -1129,7 +1196,8 @@ mod tests {
#[test]
fn test_storage_session_set() {
let cmd = parse_command(&args("storage session set skey svalue"), &default_flags()).unwrap();
let cmd =
parse_command(&args("storage session set skey svalue"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "storage_set");
assert_eq!(cmd["type"], "session");
assert_eq!(cmd["key"], "skey");
@@ -1191,7 +1259,8 @@ mod tests {
#[test]
fn test_navigate_with_multiple_headers() {
let mut flags = default_flags();
flags.headers = Some(r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string());
flags.headers =
Some(r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string());
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
assert_eq!(cmd["headers"]["X-Custom"], "value");
@@ -1445,7 +1514,10 @@ mod tests {
fn test_wait_load_missing_state() {
let result = parse_command(&args("wait --load"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
#[test]
@@ -1544,14 +1616,20 @@ mod tests {
fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCommand { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::UnknownCommand { .. }
));
}
#[test]
fn test_empty_args() {
let result = parse_command(&[], &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
// === Error message tests ===
+41 -3
View File
@@ -167,6 +167,10 @@ pub fn ensure_daemon(
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
args: Option<&str>,
user_agent: Option<&str>,
proxy: Option<&str>,
proxy_bypass: Option<&str>,
) -> Result<DaemonResult, String> {
if is_daemon_running(session) && daemon_ready(session) {
return Ok(DaemonResult {
@@ -199,7 +203,7 @@ pub fn ensure_daemon(
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
@@ -217,6 +221,22 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -256,10 +276,26 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -270,7 +306,9 @@ pub fn ensure_daemon(
for _ in 0..50 {
if daemon_ready(session) {
return Ok(DaemonResult { already_running: false });
return Ok(DaemonResult {
already_running: false,
});
}
thread::sleep(Duration::from_millis(100));
}
+55 -8
View File
@@ -11,6 +11,9 @@ pub struct Flags {
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub args: Option<String>,
pub user_agent: Option<String>,
pub provider: Option<String>,
}
@@ -30,7 +33,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None,
extensions: extensions_env,
proxy: None,
proxy: env::var("AGENT_BROWSER_PROXY").ok(),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
args: env::var("AGENT_BROWSER_ARGS").ok(),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
provider: env::var("AGENT_BROWSER_PROVIDER").ok(),
};
@@ -77,6 +83,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--proxy-bypass" => {
if let Some(s) = args.get(i + 1) {
flags.proxy_bypass = Some(s.clone());
i += 1;
}
}
"--args" => {
if let Some(s) = args.get(i + 1) {
flags.args = Some(s.clone());
i += 1;
}
}
"--user-agent" => {
if let Some(s) = args.get(i + 1) {
flags.user_agent = Some(s.clone());
i += 1;
}
}
"-p" | "--provider" => {
if let Some(p) = args.get(i + 1) {
flags.provider = Some(p.clone());
@@ -97,7 +121,19 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy", "-p", "--provider"];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
"--session",
"--headers",
"--executable-path",
"--cdp",
"--extension",
"--proxy",
"--proxy-bypass",
"--args",
"--user-agent",
"-p",
"--provider",
];
for arg in args.iter() {
if skip_next {
@@ -141,7 +177,10 @@ mod tests {
r#"{"Authorization": "Bearer token"}"#.to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.headers, Some(r#"{"Authorization": "Bearer token"}"#.to_string()));
assert_eq!(
flags.headers,
Some(r#"{"Authorization": "Bearer token"}"#.to_string())
);
}
#[test]
@@ -188,14 +227,16 @@ mod tests {
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
assert!(flags.json);
assert!(flags.headed);
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_parse_executable_path_flag() {
let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com"));
let flags = parse_flags(&args(
"--executable-path /path/to/chromium open example.com",
));
assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string()));
}
@@ -207,19 +248,25 @@ mod tests {
#[test]
fn test_clean_args_removes_executable_path() {
let cleaned = clean_args(&args("--executable-path /path/to/chromium open example.com"));
let cleaned = clean_args(&args(
"--executable-path /path/to/chromium open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_executable_path_with_other_flags() {
let cleaned = clean_args(&args("--json --executable-path /path/to/chromium --headed open example.com"));
let cleaned = clean_args(&args(
"--json --executable-path /path/to/chromium --headed open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args("--session test --executable-path /custom/chrome open example.com"));
let flags = parse_flags(&args(
"--session test --executable-path /custom/chrome open example.com",
));
assert_eq!(flags.session, "test");
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
}
+51 -16
View File
@@ -198,6 +198,10 @@ fn main() {
flags.headed,
flags.executable_path.as_deref(),
&flags.extensions,
flags.args.as_deref(),
flags.user_agent.as_deref(),
flags.proxy.as_deref(),
flags.proxy_bypass.as_deref(),
) {
Ok(result) => result,
Err(e) => {
@@ -210,17 +214,27 @@ fn main() {
}
};
// Warn if executable_path was specified but daemon was already running
if daemon_result.already_running
&& (flags.executable_path.is_some() || !flags.extensions.is_empty())
{
if !flags.json {
if flags.executable_path.is_some() {
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
}
if !flags.extensions.is_empty() {
eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator());
}
// Warn if launch-time options were specified but daemon was already running
if daemon_result.already_running {
let has_extensions = !flags.extensions.is_empty();
let ignored_flags: Vec<&str> = [
flags.executable_path.as_ref().map(|_| "--executable-path"),
if has_extensions { Some("--extension") } else { None },
flags.args.as_ref().map(|_| "--args"),
flags.user_agent.as_ref().map(|_| "--user-agent"),
flags.proxy.as_ref().map(|_| "--proxy"),
flags.proxy_bypass.as_ref().map(|_| "--proxy-bypass"),
]
.into_iter()
.flatten()
.collect();
if !ignored_flags.is_empty() && !flags.json {
eprintln!(
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
color::warning_indicator(),
ignored_flags.join(", ")
);
}
}
@@ -348,18 +362,39 @@ fn main() {
}
// Launch headed browser or proxy if flags are set (without CDP or provider)
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() && flags.provider.is_none() {
if (flags.headed || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some()) && flags.cdp.is_none() && flags.provider.is_none() {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": !flags.headed
});
let cmd_obj = launch_cmd.as_object_mut()
.expect("json! macro guarantees object type");
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);
let mut proxy_obj = parse_proxy(proxy_str);
// Add bypass if specified
if let Some(ref bypass) = flags.proxy_bypass {
if let Some(obj) = proxy_obj.as_object_mut() {
obj.insert("bypass".to_string(), json!(bypass));
}
}
cmd_obj.insert("proxy".to_string(), proxy_obj);
}
if let Some(ref ua) = flags.user_agent {
cmd_obj.insert("userAgent".to_string(), json!(ua));
}
if let Some(ref a) = flags.args {
// Parse args (comma or newline separated)
let args_vec: Vec<String> = a
.split(&[',', '\n'][..])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
cmd_obj.insert("args".to_string(), json!(args_vec));
}
if let Err(e) = send_command(launch_cmd, &flags.session) {
+188 -92
View File
@@ -239,7 +239,8 @@ pub fn print_response(resp: &Response, json_mode: bool) {
pub fn print_command_help(command: &str) -> bool {
let help = match command {
// === Navigation ===
"open" | "goto" | "navigate" => r##"
"open" | "goto" | "navigate" => {
r##"
agent-browser open - Navigate to a URL
Usage: agent-browser open <url>
@@ -261,8 +262,10 @@ Examples:
agent-browser open localhost:3000
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
# ^ Headers only sent to api.example.com, not other domains
"##,
"back" => r##"
"##
}
"back" => {
r##"
agent-browser back - Navigate back in history
Usage: agent-browser back
@@ -276,8 +279,10 @@ Global Options:
Examples:
agent-browser back
"##,
"forward" => r##"
"##
}
"forward" => {
r##"
agent-browser forward - Navigate forward in history
Usage: agent-browser forward
@@ -291,8 +296,10 @@ Global Options:
Examples:
agent-browser forward
"##,
"reload" => r##"
"##
}
"reload" => {
r##"
agent-browser reload - Reload the current page
Usage: agent-browser reload
@@ -306,10 +313,12 @@ Global Options:
Examples:
agent-browser reload
"##,
"##
}
// === Core Actions ===
"click" => r##"
"click" => {
r##"
agent-browser click - Click an element
Usage: agent-browser click <selector>
@@ -326,8 +335,10 @@ Examples:
agent-browser click @e1
agent-browser click "button.primary"
agent-browser click "//button[@type='submit']"
"##,
"dblclick" => r##"
"##
}
"dblclick" => {
r##"
agent-browser dblclick - Double-click an element
Usage: agent-browser dblclick <selector>
@@ -342,8 +353,10 @@ Global Options:
Examples:
agent-browser dblclick "#editable-text"
agent-browser dblclick @e5
"##,
"fill" => r##"
"##
}
"fill" => {
r##"
agent-browser fill - Clear and fill an input field
Usage: agent-browser fill <selector> <text>
@@ -359,8 +372,10 @@ Examples:
agent-browser fill "#email" "user@example.com"
agent-browser fill @e3 "Hello World"
agent-browser fill "input[name='search']" "query"
"##,
"type" => r##"
"##
}
"type" => {
r##"
agent-browser type - Type text into an element
Usage: agent-browser type <selector> <text>
@@ -375,8 +390,10 @@ Global Options:
Examples:
agent-browser type "#search" "hello"
agent-browser type @e2 "additional text"
"##,
"hover" => r##"
"##
}
"hover" => {
r##"
agent-browser hover - Hover over an element
Usage: agent-browser hover <selector>
@@ -391,8 +408,10 @@ Global Options:
Examples:
agent-browser hover "#dropdown-trigger"
agent-browser hover @e4
"##,
"focus" => r##"
"##
}
"focus" => {
r##"
agent-browser focus - Focus an element
Usage: agent-browser focus <selector>
@@ -406,8 +425,10 @@ Global Options:
Examples:
agent-browser focus "#input-field"
agent-browser focus @e2
"##,
"check" => r##"
"##
}
"check" => {
r##"
agent-browser check - Check a checkbox
Usage: agent-browser check <selector>
@@ -421,8 +442,10 @@ Global Options:
Examples:
agent-browser check "#terms-checkbox"
agent-browser check @e7
"##,
"uncheck" => r##"
"##
}
"uncheck" => {
r##"
agent-browser uncheck - Uncheck a checkbox
Usage: agent-browser uncheck <selector>
@@ -436,8 +459,10 @@ Global Options:
Examples:
agent-browser uncheck "#newsletter-opt-in"
agent-browser uncheck @e8
"##,
"select" => r##"
"##
}
"select" => {
r##"
agent-browser select - Select a dropdown option
Usage: agent-browser select <selector> <value...>
@@ -452,8 +477,10 @@ Examples:
agent-browser select "#country" "US"
agent-browser select @e5 "option2"
agent-browser select "#menu" "opt1" "opt2" "opt3"
"##,
"drag" => r##"
"##
}
"drag" => {
r##"
agent-browser drag - Drag and drop
Usage: agent-browser drag <source> <target>
@@ -467,8 +494,10 @@ Global Options:
Examples:
agent-browser drag "#draggable" "#drop-zone"
agent-browser drag @e1 @e2
"##,
"upload" => r##"
"##
}
"upload" => {
r##"
agent-browser upload - Upload files
Usage: agent-browser upload <selector> <files...>
@@ -482,10 +511,12 @@ Global Options:
Examples:
agent-browser upload "#file-input" ./document.pdf
agent-browser upload @e3 ./image1.png ./image2.png
"##,
"##
}
// === Keyboard ===
"press" | "key" => r##"
"press" | "key" => {
r##"
agent-browser press - Press a key or key combination
Usage: agent-browser press <key>
@@ -513,8 +544,10 @@ Examples:
agent-browser press Control+a
agent-browser press Control+Shift+s
agent-browser press Escape
"##,
"keydown" => r##"
"##
}
"keydown" => {
r##"
agent-browser keydown - Press a key down (without release)
Usage: agent-browser keydown <key>
@@ -529,8 +562,10 @@ Global Options:
Examples:
agent-browser keydown Shift
agent-browser keydown Control
"##,
"keyup" => r##"
"##
}
"keyup" => {
r##"
agent-browser keyup - Release a key
Usage: agent-browser keyup <key>
@@ -544,10 +579,12 @@ Global Options:
Examples:
agent-browser keyup Shift
agent-browser keyup Control
"##,
"##
}
// === Scroll ===
"scroll" => r##"
"scroll" => {
r##"
agent-browser scroll - Scroll the page
Usage: agent-browser scroll [direction] [amount]
@@ -567,8 +604,10 @@ Examples:
agent-browser scroll down 500
agent-browser scroll up 200
agent-browser scroll left 100
"##,
"scrollintoview" | "scrollinto" => r##"
"##
}
"scrollintoview" | "scrollinto" => {
r##"
agent-browser scrollintoview - Scroll element into view
Usage: agent-browser scrollintoview <selector>
@@ -584,10 +623,12 @@ Global Options:
Examples:
agent-browser scrollintoview "#footer"
agent-browser scrollintoview @e15
"##,
"##
}
// === Wait ===
"wait" => r##"
"wait" => {
r##"
agent-browser wait - Wait for condition
Usage: agent-browser wait <selector|ms|option>
@@ -613,10 +654,12 @@ Examples:
agent-browser wait --load networkidle
agent-browser wait --fn "window.appReady === true"
agent-browser wait --text "Welcome back"
"##,
"##
}
// === Screenshot/PDF ===
"screenshot" => r##"
"screenshot" => {
r##"
agent-browser screenshot - Take a screenshot
Usage: agent-browser screenshot [path]
@@ -635,8 +678,10 @@ Examples:
agent-browser screenshot
agent-browser screenshot ./screenshot.png
agent-browser screenshot --full ./full-page.png
"##,
"pdf" => r##"
"##
}
"pdf" => {
r##"
agent-browser pdf - Save page as PDF
Usage: agent-browser pdf <path>
@@ -650,10 +695,12 @@ Global Options:
Examples:
agent-browser pdf ./page.pdf
agent-browser pdf ~/Documents/report.pdf
"##,
"##
}
// === Snapshot ===
"snapshot" => r##"
"snapshot" => {
r##"
agent-browser snapshot - Get accessibility tree snapshot
Usage: agent-browser snapshot [options]
@@ -677,10 +724,12 @@ Examples:
agent-browser snapshot -i
agent-browser snapshot --compact --depth 5
agent-browser snapshot -s "#main-content"
"##,
"##
}
// === Eval ===
"eval" => r##"
"eval" => {
r##"
agent-browser eval - Execute JavaScript
Usage: agent-browser eval <script>
@@ -695,10 +744,12 @@ Examples:
agent-browser eval "document.title"
agent-browser eval "window.location.href"
agent-browser eval "document.querySelectorAll('a').length"
"##,
"##
}
// === Close ===
"close" | "quit" | "exit" => r##"
"close" | "quit" | "exit" => {
r##"
agent-browser close - Close the browser
Usage: agent-browser close
@@ -714,10 +765,12 @@ Global Options:
Examples:
agent-browser close
agent-browser close --session mysession
"##,
"##
}
// === Get ===
"get" => r##"
"get" => {
r##"
agent-browser get - Retrieve information from elements or page
Usage: agent-browser get <subcommand> [args]
@@ -750,10 +803,12 @@ Examples:
agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##,
"##
}
// === Is ===
"is" => r##"
"is" => {
r##"
agent-browser is - Check element state
Usage: agent-browser is <subcommand> <selector>
@@ -773,10 +828,12 @@ Examples:
agent-browser is visible "#modal"
agent-browser is enabled "#submit-btn"
agent-browser is checked "#agree-checkbox"
"##,
"##
}
// === Find ===
"find" => r##"
"find" => {
r##"
agent-browser find - Find and interact with elements by locator
Usage: agent-browser find <locator> <value> [action] [text]
@@ -814,10 +871,12 @@ Examples:
agent-browser find testid "login-form" click
agent-browser find first "li.item" click
agent-browser find nth 2 ".card" hover
"##,
"##
}
// === Mouse ===
"mouse" => r##"
"mouse" => {
r##"
agent-browser mouse - Low-level mouse operations
Usage: agent-browser mouse <subcommand> [args]
@@ -841,10 +900,12 @@ Examples:
agent-browser mouse down right
agent-browser mouse wheel 100
agent-browser mouse wheel -50 0
"##,
"##
}
// === Set ===
"set" => r##"
"set" => {
r##"
agent-browser set - Configure browser settings
Usage: agent-browser set <setting> [args]
@@ -874,10 +935,12 @@ Examples:
agent-browser set credentials admin secret123
agent-browser set media dark
agent-browser set media light reduced-motion
"##,
"##
}
// === Network ===
"network" => r##"
"network" => {
r##"
agent-browser network - Network interception and monitoring
Usage: agent-browser network <subcommand> [args]
@@ -904,10 +967,12 @@ Examples:
agent-browser network requests
agent-browser network requests --filter "api"
agent-browser network requests --clear
"##,
"##
}
// === Storage ===
"storage" => r##"
"storage" => {
r##"
agent-browser storage - Manage web storage
Usage: agent-browser storage <type> [operation] [key] [value]
@@ -933,10 +998,12 @@ Examples:
agent-browser storage local set theme "dark"
agent-browser storage local clear
agent-browser storage session get userId
"##,
"##
}
// === Cookies ===
"cookies" => r##"
"cookies" => {
r##"
agent-browser cookies - Manage browser cookies
Usage: agent-browser cookies [operation] [args]
@@ -957,10 +1024,12 @@ Examples:
agent-browser cookies get
agent-browser cookies set session_id "abc123"
agent-browser cookies clear
"##,
"##
}
// === Tabs ===
"tab" => r##"
"tab" => {
r##"
agent-browser tab - Manage browser tabs
Usage: agent-browser tab [operation] [args]
@@ -985,10 +1054,12 @@ Examples:
agent-browser tab 2
agent-browser tab close
agent-browser tab close 1
"##,
"##
}
// === Window ===
"window" => r##"
"window" => {
r##"
agent-browser window - Manage browser windows
Usage: agent-browser window <operation>
@@ -1004,10 +1075,12 @@ Global Options:
Examples:
agent-browser window new
"##,
"##
}
// === Frame ===
"frame" => r##"
"frame" => {
r##"
agent-browser frame - Switch frame context
Usage: agent-browser frame <selector|main>
@@ -1026,10 +1099,12 @@ Examples:
agent-browser frame "#embed-iframe"
agent-browser frame "iframe[name='content']"
agent-browser frame main
"##,
"##
}
// === Dialog ===
"dialog" => r##"
"dialog" => {
r##"
agent-browser dialog - Handle browser dialogs
Usage: agent-browser dialog <response> [text]
@@ -1048,10 +1123,12 @@ Examples:
agent-browser dialog accept
agent-browser dialog accept "my input"
agent-browser dialog dismiss
"##,
"##
}
// === Trace ===
"trace" => r##"
"trace" => {
r##"
agent-browser trace - Record execution trace
Usage: agent-browser trace <operation> [path]
@@ -1071,7 +1148,8 @@ Examples:
agent-browser trace start ./my-trace
agent-browser trace stop
agent-browser trace stop ./debug-trace.zip
"##,
"##
}
// === Record (video) ===
"record" => r##"
@@ -1110,7 +1188,8 @@ Examples:
"##,
// === Console/Errors ===
"console" => r##"
"console" => {
r##"
agent-browser console - View console logs
Usage: agent-browser console [--clear]
@@ -1127,8 +1206,10 @@ Global Options:
Examples:
agent-browser console
agent-browser console --clear
"##,
"errors" => r##"
"##
}
"errors" => {
r##"
agent-browser errors - View page errors
Usage: agent-browser errors [--clear]
@@ -1145,10 +1226,12 @@ Global Options:
Examples:
agent-browser errors
agent-browser errors --clear
"##,
"##
}
// === Highlight ===
"highlight" => r##"
"highlight" => {
r##"
agent-browser highlight - Highlight an element
Usage: agent-browser highlight <selector>
@@ -1162,10 +1245,12 @@ Global Options:
Examples:
agent-browser highlight "#target-element"
agent-browser highlight @e5
"##,
"##
}
// === State ===
"state" => r##"
"state" => {
r##"
agent-browser state - Save/load browser state
Usage: agent-browser state <operation> <path>
@@ -1183,10 +1268,12 @@ Global Options:
Examples:
agent-browser state save ./auth-state.json
agent-browser state load ./auth-state.json
"##,
"##
}
// === Session ===
"session" => r##"
"session" => {
r##"
agent-browser session - Manage sessions
Usage: agent-browser session [operation]
@@ -1209,10 +1296,12 @@ Examples:
agent-browser session
agent-browser session list
agent-browser --session test open example.com
"##,
"##
}
// === Install ===
"install" => r##"
"install" => {
r##"
agent-browser install - Install browser binaries
Usage: agent-browser install [--with-deps]
@@ -1225,7 +1314,8 @@ Options:
Examples:
agent-browser install
agent-browser install --with-deps
"##,
"##
}
_ => return false,
};
@@ -1324,8 +1414,14 @@ Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--extension <path> Load browser extensions (repeatable)
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
e.g., --args "--no-sandbox,--disable-blink-features=AutomationControlled"
--user-agent <ua> Custom User-Agent (or AGENT_BROWSER_USER_AGENT)
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY)
e.g., --proxy "http://user:pass@127.0.0.1:7890"
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS)
e.g., --proxy-bypass "localhost,*.internal.com"
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
+7 -1
View File
@@ -865,14 +865,18 @@ export class BrowserManager {
if (hasExtensions) {
const extPaths = options.extensions!.join(',');
const session = process.env.AGENT_BROWSER_SESSION || 'default';
// Combine extension args with custom args
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
const allArgs = options.args ? [...extArgs, ...options.args] : extArgs;
context = await launcher.launchPersistentContext(
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{
headless: false,
executablePath: options.executablePath,
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
args: allArgs,
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
...(options.proxy && { proxy: options.proxy }),
}
);
@@ -881,11 +885,13 @@ export class BrowserManager {
this.browser = await launcher.launch({
headless: options.headless ?? true,
executablePath: options.executablePath,
args: options.args,
});
this.cdpEndpoint = null;
context = await this.browser.newContext({
viewport,
extraHTTPHeaders: options.headers,
userAgent: options.userAgent,
...(options.proxy && { proxy: options.proxy }),
});
}
+24 -1
View File
@@ -197,12 +197,35 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
.map((p) => p.trim())
.filter(Boolean)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
await browser.launch({
id: 'auto',
action: 'launch',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
});
}
+2
View File
@@ -42,6 +42,8 @@ const launchSchema = baseCommandSchema.extend({
password: z.string().optional(),
})
.optional(),
args: z.array(z.string()).optional(),
userAgent: z.string().optional(),
provider: z.string().optional(),
});
+2
View File
@@ -23,6 +23,8 @@ export interface LaunchCommand extends BaseCommand {
username?: string;
password?: string;
};
args?: string[];
userAgent?: string;
provider?: string;
}
+388
View File
@@ -0,0 +1,388 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { execSync } from 'child_process';
import { resolve } from 'path';
import { existsSync } from 'fs';
const CLI_PATH = resolve(__dirname, '../cli/target/release/agent-browser');
// Check if binary exists before running any tests
beforeAll(() => {
if (!existsSync(CLI_PATH)) {
throw new Error(`CLI binary not found at: ${CLI_PATH}. Please build the project first with 'cargo build --release'.`);
}
});
// Generate unique session for each test
let testCounter = 0;
function getUniqueSession(): string {
return `e2e-${Date.now()}-${testCounter++}`;
}
function runCli(session: string, args: string): string {
return execSync(`${CLI_PATH} --session ${session} ${args}`, {
encoding: 'utf-8',
timeout: 30000,
}).trim();
}
function runCliWithEnv(session: string, args: string, envVars: Record<string, string>): string {
const env = { ...process.env, ...envVars };
return execSync(`${CLI_PATH} --session ${session} ${args}`, {
encoding: 'utf-8',
env,
timeout: 30000,
}).trim();
}
function runCliJson(session: string, args: string): {
success: boolean;
data?: Record<string, unknown>;
error?: string;
} {
const output = runCli(session, `${args} --json`);
return JSON.parse(output);
}
function closeBrowser(session: string) {
try {
execSync(`${CLI_PATH} --session ${session} close`, {
encoding: 'utf-8',
timeout: 10000,
});
} catch {
// Ignore if already closed
}
}
// Helper to ensure browser is closed before test
function ensureBrowserClosed(session: string) {
closeBrowser(session);
// Wait a bit to ensure daemon is fully stopped
execSync('sleep 0.2', { timeout: 1000 });
}
describe('E2E: Launch Options', () => {
let session: string;
beforeEach(() => {
session = getUniqueSession();
ensureBrowserClosed(session);
});
afterEach(() => {
closeBrowser(session);
});
describe('--args flag', () => {
it('should disable webdriver detection with --args', () => {
runCli(session, '--args "--disable-blink-features=AutomationControlled" open https://example.com');
const result = runCliJson(session, 'eval "navigator.webdriver"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(false);
});
it('should support multiple comma-separated args', () => {
runCli(session, '--args "--disable-blink-features=AutomationControlled,--disable-dev-shm-usage" open https://example.com');
const result = runCliJson(session, 'eval "navigator.webdriver"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(false);
});
it('should have webdriver=true without --args (default behavior)', () => {
runCli(session, 'open https://example.com');
const result = runCliJson(session, 'eval "navigator.webdriver"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(true);
});
});
describe('--user-agent flag', () => {
it('should set custom user-agent', () => {
const customUA = 'E2ETestBot/1.0';
runCli(session, `--user-agent "${customUA}" open https://example.com`);
const result = runCliJson(session, 'eval "navigator.userAgent"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(customUA);
});
it('should use default Chrome user-agent when not specified', () => {
runCli(session, 'open https://example.com');
const result = runCliJson(session, 'eval "navigator.userAgent"');
expect(result.success).toBe(true);
expect(String(result.data?.result)).toContain('Chrome');
});
});
describe('--proxy flag', () => {
it('should fail navigation when proxy is unreachable (proves proxy is used)', () => {
// Launch with unreachable proxy - navigation should fail immediately
// This proves proxy is being used
let failed = false;
try {
runCli(session, '--proxy "http://127.0.0.1:59999" open https://example.com');
} catch (e) {
failed = true;
expect(String(e)).toContain('ERR_PROXY_CONNECTION_FAILED');
}
expect(failed).toBe(true);
});
});
describe('environment variables', () => {
it('should read AGENT_BROWSER_ARGS from environment', () => {
runCliWithEnv(session, 'open https://example.com', {
AGENT_BROWSER_ARGS: '--disable-blink-features=AutomationControlled',
});
const result = runCliJson(session, 'eval "navigator.webdriver"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(false);
});
it('should read AGENT_BROWSER_USER_AGENT from environment', () => {
const customUA = 'EnvTestBot/2.0';
runCliWithEnv(session, 'open https://example.com', {
AGENT_BROWSER_USER_AGENT: customUA,
});
const result = runCliJson(session, 'eval "navigator.userAgent"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(customUA);
});
});
describe('warning for already running daemon', () => {
it('should warn when launch-time options are ignored', () => {
// First, start daemon with default options
runCli(session, 'open https://example.com');
// Try to use --user-agent with already running daemon
const output = execSync(
`${CLI_PATH} --session ${session} --user-agent "IgnoredUA" get url 2>&1`,
{ encoding: 'utf-8' }
);
expect(output).toContain('--user-agent ignored');
expect(output).toContain('daemon already running');
});
});
describe('Single parameter tests', () => {
it('--args only', () => {
runCli(session, '--args "--disable-blink-features=AutomationControlled" open https://example.com');
const result = runCliJson(session, 'eval "navigator.webdriver"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(false);
});
it('--user-agent only', () => {
const ua = 'SingleParam/1.0';
runCli(session, `--user-agent "${ua}" open https://example.com`);
const result = runCliJson(session, 'eval "navigator.userAgent"');
expect(result.success).toBe(true);
expect(result.data?.result).toBe(ua);
});
it('--proxy only', () => {
// Use real proxy to verify it works
runCli(session, '--proxy "http://localhost:7890" open https://httpbin.org/ip');
const urlResult = runCliJson(session, 'get url');
expect(urlResult.success).toBe(true);
expect(urlResult.data?.url).toBe('https://httpbin.org/ip');
});
});
describe('Two parameter combinations', () => {
it('--args + --user-agent', () => {
const ua = 'TwoParam/1.0';
runCli(session, `--args "--disable-blink-features=AutomationControlled" --user-agent "${ua}" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
it('--proxy + --user-agent', () => {
const ua = 'ProxyUA/1.0';
runCli(session, `--proxy "http://localhost:7890" --user-agent "${ua}" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
});
it('--proxy + --args', () => {
runCli(session, '--proxy "http://localhost:7890" --args "--disable-blink-features=AutomationControlled" open https://example.com');
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
it('--proxy + --proxy-bypass', () => {
// example.com should bypass proxy
runCli(session, '--proxy "http://localhost:7890" --proxy-bypass "example.com" open https://example.com');
const urlResult = runCliJson(session, 'get url');
expect(urlResult.success).toBe(true);
expect(urlResult.data?.url).toBe('https://example.com/');
const titleResult = runCliJson(session, 'get title');
expect(titleResult.success).toBe(true);
expect(String(titleResult.data?.title)).toContain('Example Domain');
});
});
describe('Three parameter combinations', () => {
it('--proxy + --user-agent + --args', () => {
const ua = 'ThreeParam/1.0';
runCli(session, `--proxy "http://localhost:7890" --user-agent "${ua}" --args "--disable-blink-features=AutomationControlled" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
it('--proxy + --proxy-bypass + --user-agent', () => {
const ua = 'BypassUA/1.0';
runCli(session, `--proxy "http://localhost:7890" --proxy-bypass "example.com" --user-agent "${ua}" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
const urlResult = runCliJson(session, 'get url');
expect(urlResult.success).toBe(true);
expect(urlResult.data?.url).toBe('https://example.com/');
});
it('--proxy + --proxy-bypass + --args', () => {
runCli(session, '--proxy "http://localhost:7890" --proxy-bypass "example.com" --args "--disable-blink-features=AutomationControlled" open https://example.com');
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
it('--user-agent + --args + --proxy-bypass (no proxy)', () => {
const ua = 'NoProxy/1.0';
// Without proxy, proxy-bypass should be ignored
runCli(session, `--user-agent "${ua}" --args "--disable-blink-features=AutomationControlled" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
});
describe('Four parameter combinations (all parameters)', () => {
it('--proxy + --proxy-bypass + --user-agent + --args', () => {
const ua = 'AllParams/1.0';
runCli(session, `--proxy "http://localhost:7890" --proxy-bypass "example.com" --user-agent "${ua}" --args "--disable-blink-features=AutomationControlled" open https://example.com`);
// Verify user-agent
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
// Verify webdriver hidden (args works)
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
// Verify page loaded (proxy + bypass works)
const urlResult = runCliJson(session, 'get url');
expect(urlResult.success).toBe(true);
expect(urlResult.data?.url).toBe('https://example.com/');
// Navigate to httpbin through proxy (not bypassed)
runCli(session, 'goto https://httpbin.org/headers');
const bodyResult = runCliJson(session, 'get text body');
expect(bodyResult.success).toBe(true);
const text = String(bodyResult.data?.text);
expect(text).toContain(ua);
});
});
describe('Multiple args formats', () => {
it('comma-separated args with proxy and user-agent', () => {
const ua = 'CommaArgs/1.0';
runCli(session, `--proxy "http://localhost:7890" --user-agent "${ua}" --args "--disable-blink-features=AutomationControlled,--disable-dev-shm-usage" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(ua);
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
});
describe('Real-world scenarios', () => {
it('mobile device simulation with proxy', () => {
const mobileUA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15';
runCli(session, `--proxy "http://localhost:7890" --proxy-bypass "example.com" --user-agent "${mobileUA}" --args "--disable-blink-features=AutomationControlled" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(String(uaResult.data?.result)).toContain('iPhone');
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
});
it('stealth browsing with all anti-detection features', () => {
const stealthUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
runCli(session, `--proxy "http://localhost:7890" --proxy-bypass "example.com,localhost" --user-agent "${stealthUA}" --args "--disable-blink-features=AutomationControlled,--disable-web-security" open https://example.com`);
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(stealthUA);
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
const pluginsResult = runCliJson(session, 'eval "typeof navigator.plugins"');
expect(pluginsResult.success).toBe(true);
expect(pluginsResult.data?.result).toBe('object');
});
it('combine proxy with user-agent and args', () => {
const customUA = 'ProxyTestBot/1.0';
runCli(session, `--proxy "http://localhost:7890" --user-agent "${customUA}" --args "--disable-blink-features=AutomationControlled" open https://example.com`);
// navigator.userAgent
const uaResult = runCliJson(session, 'eval "navigator.userAgent"');
expect(uaResult.success).toBe(true);
expect(uaResult.data?.result).toBe(customUA);
// webdriver
const wdResult = runCliJson(session, 'eval "navigator.webdriver"');
expect(wdResult.success).toBe(true);
expect(wdResult.data?.result).toBe(false);
// proxy well
const urlResult = runCliJson(session, 'get url');
expect(urlResult.success).toBe(true);
expect(urlResult.data?.url).toBe('https://example.com/');
// proxy httpbin validate User-Agent
runCli(session, 'goto https://httpbin.org/headers');
const bodyResult = runCliJson(session, 'get text body');
expect(bodyResult.success).toBe(true);
const text = String(bodyResult.data?.text);
expect(text).toContain(customUA);
});
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, afterEach } from 'vitest';
import { BrowserManager } from '../src/browser.js';
describe('Launch Options', () => {
let browser: BrowserManager;
afterEach(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
describe('browser args', () => {
it('should launch with custom args to disable webdriver detection', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
});
const page = browser.getPage();
await page.goto('about:blank');
// Check that navigator.webdriver is false
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
it('should launch with multiple args', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
],
});
expect(browser.isLaunched()).toBe(true);
});
it('should launch without args (default behavior)', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
});
const page = browser.getPage();
await page.goto('about:blank');
// Default Playwright behavior - webdriver is true
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(true);
});
});
describe('custom user-agent', () => {
it('should launch with custom user-agent', async () => {
const customUA = 'CustomTestBot/1.0';
browser = new BrowserManager();
await browser.launch({
headless: true,
userAgent: customUA,
});
const page = browser.getPage();
await page.goto('about:blank');
const ua = await page.evaluate(() => navigator.userAgent);
expect(ua).toBe(customUA);
});
it('should use default user-agent when not specified', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
});
const page = browser.getPage();
await page.goto('about:blank');
const ua = await page.evaluate(() => navigator.userAgent);
// Default UA should contain Chrome/Chromium
expect(ua).toContain('Chrome');
});
});
describe('proxy configuration', () => {
it('should accept proxy configuration', async () => {
browser = new BrowserManager();
// Note: This test just verifies the proxy option is accepted without error
// Actual proxy testing requires a running proxy server
await browser.launch({
headless: true,
proxy: {
server: 'http://localhost:8080',
},
});
expect(browser.isLaunched()).toBe(true);
});
it('should accept proxy with bypass list', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
proxy: {
server: 'http://localhost:8080',
bypass: 'localhost,*.internal.com',
},
});
expect(browser.isLaunched()).toBe(true);
});
it('should fail connection when proxy is unreachable (proves proxy is being used)', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
proxy: {
server: 'http://127.0.0.1:59999', // Non-existent proxy
},
});
const page = browser.getPage();
// Navigation should fail because proxy is unreachable
// This proves the proxy setting is actually being used
await expect(page.goto('https://example.com', { timeout: 5000 })).rejects.toThrow();
});
});
describe('combined options', () => {
it('should launch with args, user-agent, and proxy combined', async () => {
const customUA = 'CombinedTestBot/2.0';
browser = new BrowserManager();
await browser.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
userAgent: customUA,
proxy: {
server: 'http://localhost:8080',
bypass: 'localhost',
},
});
const page = browser.getPage();
await page.goto('about:blank');
// Verify user-agent
const ua = await page.evaluate(() => navigator.userAgent);
expect(ua).toBe(customUA);
// Verify webdriver is hidden
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
});
});