Fix: CLI: state load / profile persistence not usable in v0.7.6 (#268)

* Fix: CLI: state load / profile persistence not usable in v0.7.6

This PR addresses issue #259

* Fix issues identified in code review
This commit is contained in:
Chris Tate
2026-01-25 13:45:17 -06:00
committed by GitHub
parent ae09fdd431
commit 79863a5180
10 changed files with 439 additions and 134 deletions
+181 -78
View File
@@ -18,7 +18,10 @@ pub enum ParseError {
usage: &'static str,
},
/// Argument exists but has an invalid value
InvalidValue { message: String, usage: &'static str },
InvalidValue {
message: String,
usage: &'static str,
},
}
impl ParseError {
@@ -81,11 +84,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
usage: "open <url>",
})?;
let url_lower = url.to_lowercase();
let url = if url_lower.starts_with("http://")
let url = if url_lower.starts_with("http://")
|| url_lower.starts_with("https://")
|| url_lower.starts_with("about:")
|| url_lower.starts_with("data:")
|| url_lower.starts_with("file:") {
|| url_lower.starts_with("about:")
|| url_lower.starts_with("data:")
|| url_lower.starts_with("file:")
{
url.to_string()
} else {
format!("https://{}", url)
@@ -299,7 +303,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
if rest.iter().any(|&s| s == "--download" || s == "-d") {
let mut cmd = json!({ "id": id, "action": "waitfordownload" });
// Check for optional path (first non-flag argument after --download)
let download_idx = rest.iter().position(|&s| s == "--download" || s == "-d").unwrap();
let download_idx = rest
.iter()
.position(|&s| s == "--download" || s == "-d")
.unwrap();
if let Some(path) = rest.get(download_idx + 1) {
if !path.starts_with("--") {
cmd["path"] = json!(path);
@@ -347,7 +354,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// One arg: determine if it's a selector or a path
let is_relative_path = first.starts_with("./") || first.starts_with("../");
let is_selector = !is_relative_path
&& (first.starts_with('.') || first.starts_with('#') || first.starts_with('@'));
&& (first.starts_with('.')
|| first.starts_with('#')
|| first.starts_with('@'));
let has_path_extension = first.ends_with(".png")
|| first.ends_with(".jpg")
|| first.ends_with(".jpeg")
@@ -361,7 +370,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
_ => (None, None),
};
Ok(json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full }))
Ok(
json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full }),
)
}
"pdf" => {
let path = rest.get(0).ok_or_else(|| ParseError::MissingArguments {
@@ -542,7 +553,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"--sameSite" => {
if let Some(same_site) = rest.get(i + 1) {
// Validate sameSite value
if *same_site == "Strict" || *same_site == "Lax" || *same_site == "None" {
if *same_site == "Strict"
|| *same_site == "Lax"
|| *same_site == "None"
{
cookie["sameSite"] = json!(same_site);
i += 2;
} else {
@@ -583,9 +597,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
Ok(
json!({ "id": id, "action": "cookies_set", "cookies": [cookie] }),
)
Ok(json!({ "id": id, "action": "cookies_set", "cookies": [cookie] }))
}
"clear" => Ok(json!({ "id": id, "action": "cookies_clear" })),
_ => Ok(json!({ "id": id, "action": "cookies_get" })),
@@ -593,28 +605,26 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
// === Tabs ===
"tab" => {
match rest.get(0).map(|s| *s) {
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
"tab" => match rest.get(0).map(|s| *s) {
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
}
Ok(cmd)
}
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_list" })),
Ok(cmd)
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
}
Ok(cmd)
}
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_list" })),
},
// === Window ===
@@ -679,7 +689,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
usage: "trace stop <path>",
})?;
Ok(json!({ "id": id, "action": "trace_stop", "path": path }))
},
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
@@ -796,8 +806,10 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"];
const VALID: &[&str] = &[
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
];
match rest.get(0).map(|s| *s) {
Some("text") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -952,35 +964,53 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
match *locator {
"role" => {
let mut cmd = json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "name": name, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
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" => {
let mut cmd = json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "exact": exact });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
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); }
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 })),
"title" => Ok(json!({ "id": id, "action": "getbytitle", "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 }),
),
"testid" => {
let mut cmd = json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction });
if let Some(v) = fill_value { cmd["value"] = json!(v); }
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); }
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); }
if let Some(v) = fill_value {
cmd["value"] = json!(v);
}
Ok(cmd)
}
_ => unreachable!(),
@@ -1008,7 +1038,9 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
None
};
let mut cmd = json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub });
if let Some(v) = fv { cmd["value"] = json!(v); }
if let Some(v) = fv {
cmd["value"] = json!(v);
}
Ok(cmd)
}
_ => Err(ParseError::UnknownSubcommand {
@@ -1181,7 +1213,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
"no-preference"
};
Ok(json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }))
Ok(
json!({ "id": id, "action": "emulatemedia", "colorScheme": color, "reducedMotion": reduced }),
)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
@@ -1214,7 +1248,7 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
cmd["url"] = json!(url);
}
Ok(cmd)
},
}
Some("requests") => {
let clear = rest.iter().any(|&s| s == "--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter");
@@ -1299,6 +1333,7 @@ mod tests {
extensions: Vec::new(),
cdp: None,
profile: None,
state: None,
proxy: None,
proxy_bypass: None,
args: None,
@@ -1348,7 +1383,11 @@ mod tests {
#[test]
fn test_cookies_set_with_url() {
let cmd = parse_command(&args("cookies set mycookie myvalue --url https://example.com"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --url https://example.com"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1357,7 +1396,11 @@ mod tests {
#[test]
fn test_cookies_set_with_domain() {
let cmd = parse_command(&args("cookies set mycookie myvalue --domain example.com"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --domain example.com"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1366,7 +1409,11 @@ mod tests {
#[test]
fn test_cookies_set_with_path() {
let cmd = parse_command(&args("cookies set mycookie myvalue --path /api"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --path /api"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1375,7 +1422,11 @@ mod tests {
#[test]
fn test_cookies_set_with_httponly() {
let cmd = parse_command(&args("cookies set mycookie myvalue --httpOnly"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --httpOnly"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1384,7 +1435,11 @@ mod tests {
#[test]
fn test_cookies_set_with_secure() {
let cmd = parse_command(&args("cookies set mycookie myvalue --secure"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --secure"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1393,7 +1448,11 @@ mod tests {
#[test]
fn test_cookies_set_with_samesite() {
let cmd = parse_command(&args("cookies set mycookie myvalue --sameSite Strict"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --sameSite Strict"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1402,7 +1461,11 @@ mod tests {
#[test]
fn test_cookies_set_with_expires() {
let cmd = parse_command(&args("cookies set mycookie myvalue --expires 1234567890"), &default_flags()).unwrap();
let cmd = parse_command(
&args("cookies set mycookie myvalue --expires 1234567890"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
@@ -1438,7 +1501,10 @@ mod tests {
#[test]
fn test_cookies_set_invalid_samesite() {
let result = parse_command(&args("cookies set mycookie myvalue --sameSite Invalid"), &default_flags());
let result = parse_command(
&args("cookies set mycookie myvalue --sameSite Invalid"),
&default_flags(),
);
assert!(result.is_err());
}
@@ -1658,11 +1724,7 @@ mod tests {
#[test]
fn test_select_multiple_values() {
let cmd = parse_command(
&args("select #menu opt1 opt2 opt3"),
&default_flags(),
)
.unwrap();
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"]));
@@ -1680,7 +1742,10 @@ mod tests {
fn test_tab_new() {
let cmd = parse_command(&args("tab new"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_new");
assert!(cmd.get("url").is_none(), "url should not be present when not provided");
assert!(
cmd.get("url").is_none(),
"url should not be present when not provided"
);
}
#[test]
@@ -1872,7 +1937,11 @@ mod tests {
#[test]
fn test_record_start_with_url() {
let cmd = parse_command(&args("record start demo.webm https://example.com"), &default_flags()).unwrap();
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");
@@ -1880,7 +1949,11 @@ mod tests {
#[test]
fn test_record_start_with_url_no_protocol() {
let cmd = parse_command(&args("record start demo.webm example.com"), &default_flags()).unwrap();
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");
@@ -1890,7 +1963,10 @@ mod tests {
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 { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
#[test]
@@ -1909,7 +1985,11 @@ mod tests {
#[test]
fn test_record_restart_with_url() {
let cmd = parse_command(&args("record restart demo.webm https://example.com"), &default_flags()).unwrap();
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");
@@ -1919,21 +1999,30 @@ mod tests {
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 { .. }));
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 { .. }));
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 { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
#[test]
@@ -2058,14 +2147,20 @@ mod tests {
fn test_download_missing_path() {
let result = parse_command(&args("download #btn"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
#[test]
fn test_download_missing_selector() {
let result = parse_command(&args("download"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
// === Wait for Download Tests ===
@@ -2086,14 +2181,19 @@ mod tests {
#[test]
fn test_wait_download_with_timeout() {
let cmd = parse_command(&args("wait --download --timeout 30000"), &default_flags()).unwrap();
let cmd =
parse_command(&args("wait --download --timeout 30000"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "waitfordownload");
assert_eq!(cmd["timeout"], 30000);
}
#[test]
fn test_wait_download_with_path_and_timeout() {
let cmd = parse_command(&args("wait --download ./file.pdf --timeout 30000"), &default_flags()).unwrap();
let cmd = parse_command(
&args("wait --download ./file.pdf --timeout 30000"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "waitfordownload");
assert_eq!(cmd["path"], "./file.pdf");
assert_eq!(cmd["timeout"], 30000);
@@ -2136,16 +2236,16 @@ mod tests {
];
let cmd = parse_command(&input, &default_flags()).unwrap();
assert_eq!(cmd["action"], "launch");
assert_eq!(cmd["cdpUrl"], "wss://remote-browser.example.com/cdp?token=xyz");
assert_eq!(
cmd["cdpUrl"],
"wss://remote-browser.example.com/cdp?token=xyz"
);
assert!(cmd.get("cdpPort").is_none());
}
#[test]
fn test_connect_with_http_url() {
let input: Vec<String> = vec![
"connect".to_string(),
"http://localhost:9222".to_string(),
];
let input: Vec<String> = vec!["connect".to_string(), "http://localhost:9222".to_string()];
let cmd = parse_command(&input, &default_flags()).unwrap();
assert_eq!(cmd["action"], "launch");
assert_eq!(cmd["cdpUrl"], "http://localhost:9222");
@@ -2156,7 +2256,10 @@ mod tests {
fn test_connect_missing_argument() {
let result = parse_command(&args("connect"), &default_flags());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
assert!(matches!(
result.unwrap_err(),
ParseError::MissingArguments { .. }
));
}
#[test]
+35 -7
View File
@@ -195,6 +195,8 @@ pub fn ensure_daemon(
proxy: Option<&str>,
proxy_bypass: Option<&str>,
ignore_https_errors: bool,
profile: Option<&str>,
state: Option<&str>,
) -> Result<DaemonResult, String> {
if is_daemon_running(session) && daemon_ready(session) {
return Ok(DaemonResult {
@@ -205,7 +207,8 @@ pub fn ensure_daemon(
// Ensure socket directory exists
let socket_dir = get_socket_dir();
if !socket_dir.exists() {
fs::create_dir_all(&socket_dir).map_err(|e| format!("Failed to create socket directory: {}", e))?;
fs::create_dir_all(&socket_dir)
.map_err(|e| format!("Failed to create socket directory: {}", e))?;
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
@@ -271,6 +274,14 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -290,7 +301,7 @@ pub fn ensure_daemon(
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
@@ -326,10 +337,18 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
@@ -446,7 +465,9 @@ mod tests {
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::remove_var("XDG_RUNTIME_DIR");
assert!(get_socket_dir().to_string_lossy().ends_with(".agent-browser"));
assert!(get_socket_dir()
.to_string_lossy()
.ends_with(".agent-browser"));
}
#[test]
@@ -456,7 +477,10 @@ mod tests {
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
assert_eq!(get_socket_dir(), PathBuf::from("/run/user/1000/agent-browser"));
assert_eq!(
get_socket_dir(),
PathBuf::from("/run/user/1000/agent-browser")
);
}
#[test]
@@ -466,7 +490,9 @@ mod tests {
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::set_var("XDG_RUNTIME_DIR", "");
assert!(get_socket_dir().to_string_lossy().ends_with(".agent-browser"));
assert!(get_socket_dir()
.to_string_lossy()
.ends_with(".agent-browser"));
}
#[test]
@@ -478,6 +504,8 @@ mod tests {
let result = get_socket_dir();
assert!(result.to_string_lossy().ends_with(".agent-browser"));
assert!(result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users"));
assert!(
result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")
);
}
}
+24 -4
View File
@@ -11,6 +11,7 @@ pub struct Flags {
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub args: Option<String>,
@@ -22,7 +23,12 @@ pub struct Flags {
pub fn parse_flags(args: &[String]) -> Flags {
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
.ok()
.map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>())
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut flags = Flags {
@@ -36,6 +42,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
cdp: None,
extensions: extensions_env,
profile: env::var("AGENT_BROWSER_PROFILE").ok(),
state: env::var("AGENT_BROWSER_STATE").ok(),
proxy: env::var("AGENT_BROWSER_PROXY").ok(),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
args: env::var("AGENT_BROWSER_ARGS").ok(),
@@ -68,13 +75,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
flags.executable_path = Some(s.clone());
i += 1;
}
},
}
"--extension" => {
if let Some(s) = args.get(i + 1) {
flags.extensions.push(s.clone());
i += 1;
}
},
}
"--cdp" => {
if let Some(s) = args.get(i + 1) {
flags.cdp = Some(s.clone());
@@ -87,6 +94,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--state" => {
if let Some(s) = args.get(i + 1) {
flags.state = Some(s.clone());
i += 1;
}
}
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
@@ -130,7 +143,13 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
let mut skip_next = false;
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug", "--ignore-https-errors"];
const GLOBAL_FLAGS: &[&str] = &[
"--json",
"--full",
"--headed",
"--debug",
"--ignore-https-errors",
];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
"--session",
@@ -139,6 +158,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--cdp",
"--extension",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
"--args",
+22 -7
View File
@@ -100,7 +100,10 @@ pub fn run_install(with_deps: bool) {
],
)
} else {
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
@@ -128,7 +131,10 @@ pub fn run_install(with_deps: bool) {
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
} else {
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
@@ -136,7 +142,7 @@ pub fn run_install(with_deps: bool) {
}
println!("{}", color::cyan("Installing Chromium browser..."));
// 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.
// Pass the entire command as a single string to /c to handle paths with spaces.
@@ -144,7 +150,7 @@ pub fn run_install(with_deps: bool) {
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
@@ -152,17 +158,26 @@ pub fn run_install(with_deps: bool) {
match status {
Ok(s) if s.success() => {
println!("{} Chromium installed successfully", color::success_indicator());
println!(
"{} Chromium installed successfully",
color::success_indicator()
);
if is_linux && !with_deps {
println!();
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
println!(
"{} If you see \"shared library\" errors when running, use:",
color::yellow("Note:")
);
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
println!(
"{} Try installing system dependencies first:",
color::yellow("Tip:")
);
println!(" agent-browser install --with-deps");
}
exit(1);
+39 -9
View File
@@ -106,7 +106,11 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session { color::cyan("") } else { " ".to_string() };
let marker = if s == session {
color::cyan("")
} else {
" ".to_string()
};
println!("{} {}", marker, s);
}
}
@@ -201,6 +205,8 @@ fn main() {
flags.proxy.as_deref(),
flags.proxy_bypass.as_deref(),
flags.ignore_https_errors,
flags.profile.as_deref(),
flags.state.as_deref(),
) {
Ok(result) => result,
Err(e) => {
@@ -218,8 +224,13 @@ fn main() {
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 },
if has_extensions {
Some("--extension")
} else {
None
},
flags.profile.as_ref().map(|_| "--profile"),
flags.state.as_ref().map(|_| "--state"),
flags.args.as_ref().map(|_| "--args"),
flags.user_agent.as_ref().map(|_| "--user-agent"),
flags.proxy.as_ref().map(|_| "--proxy"),
@@ -356,7 +367,10 @@ fn main() {
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(resp.error.unwrap_or_else(|| "Provider connection failed".to_string())),
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "Provider connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
@@ -371,14 +385,23 @@ fn main() {
}
// Launch headed browser or configure browser options (without CDP or provider)
if (flags.headed || flags.profile.is_some() || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some()) && flags.cdp.is_none() && flags.provider.is_none() {
if (flags.headed
|| flags.profile.is_some()
|| flags.state.is_some()
|| 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()
let cmd_obj = launch_cmd
.as_object_mut()
.expect("json! macro guarantees object type");
// Add profile path if specified
@@ -386,6 +409,11 @@ fn main() {
cmd_obj.insert("profile".to_string(), json!(profile_path));
}
// Add state path if specified
if let Some(ref state_path) = flags.state {
cmd_obj.insert("storageState".to_string(), json!(state_path));
}
if let Some(ref proxy_str) = flags.proxy {
let mut proxy_obj = parse_proxy(proxy_str);
// Add bypass if specified
@@ -417,7 +445,11 @@ fn main() {
if let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json {
eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e);
eprintln!(
"{} Could not configure browser: {}",
color::warning_indicator(),
e
);
}
}
}
@@ -426,9 +458,7 @@ fn main() {
Ok(resp) => {
let success = resp.success;
// Extract action for context-specific output handling
let action = cmd
.get("action")
.and_then(|v| v.as_str());
let action = cmd.get("action").and_then(|v| v.as_str());
print_response(&resp, flags.json, action);
if !success {
exit(1);
+119 -29
View File
@@ -87,7 +87,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
.unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active { color::cyan("") } else { " ".to_string() };
let marker = if active {
color::cyan("")
} else {
" ".to_string()
};
println!("{} [{}] {} - {}", marker, i, title, url);
}
return;
@@ -126,7 +130,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
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("");
let resource_type = req
.get("resourceType")
.and_then(|v| v.as_str())
.unwrap_or("");
println!("{} {} ({})", method, url, resource_type);
}
}
@@ -153,7 +160,7 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
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);
@@ -161,15 +168,30 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
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 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("");
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);
@@ -199,9 +221,17 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
// 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");
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);
println!(
"{} Recording restarted: {} (previous saved to {})",
color::success_indicator(),
path,
prev_path
);
} else {
println!("{} Recording started: {}", color::success_indicator(), path);
}
@@ -211,7 +241,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
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);
println!(
"{} Recording saved to {} - {}",
color::warning_indicator(),
path,
error
);
} else {
println!("{} Recording saved to {}", color::success_indicator(), path);
}
@@ -223,14 +258,24 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
// Download response (has "suggestedFilename" or "filename" field)
if data.get("suggestedFilename").is_some() || data.get("filename").is_some() {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
let filename = data.get("suggestedFilename")
let filename = data
.get("suggestedFilename")
.or_else(|| data.get("filename"))
.and_then(|v| v.as_str())
.unwrap_or("");
if filename.is_empty() {
println!("{} Downloaded to {}", color::success_indicator(), color::green(path));
println!(
"{} Downloaded to {}",
color::success_indicator(),
color::green(path)
);
} else {
println!("{} Downloaded to {} ({})", color::success_indicator(), color::green(path), filename);
println!(
"{} Downloaded to {} ({})",
color::success_indicator(),
color::green(path),
filename
);
}
return;
}
@@ -243,18 +288,50 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
// Path-based operations (screenshot/pdf/trace/har/download/state/video)
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
match action.unwrap_or("") {
"screenshot" => println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path)),
"pdf" => println!("{} PDF saved to {}", color::success_indicator(), color::green(path)),
"trace_stop" => println!("{} Trace saved to {}", color::success_indicator(), color::green(path)),
"har_stop" => println!("{} HAR saved to {}", color::success_indicator(), color::green(path)),
"download" | "waitfordownload" => println!("{} Download saved to {}", color::success_indicator(), color::green(path)),
"video_stop" => println!("{} Video saved to {}", color::success_indicator(), color::green(path)),
"state_save" => println!("{} State saved to {}", color::success_indicator(), color::green(path)),
"screenshot" => println!(
"{} Screenshot saved to {}",
color::success_indicator(),
color::green(path)
),
"pdf" => println!(
"{} PDF saved to {}",
color::success_indicator(),
color::green(path)
),
"trace_stop" => println!(
"{} Trace saved to {}",
color::success_indicator(),
color::green(path)
),
"har_stop" => println!(
"{} HAR saved to {}",
color::success_indicator(),
color::green(path)
),
"download" | "waitfordownload" => println!(
"{} Download saved to {}",
color::success_indicator(),
color::green(path)
),
"video_stop" => println!(
"{} Video saved to {}",
color::success_indicator(),
color::green(path)
),
"state_save" => println!(
"{} State saved to {}",
color::success_indicator(),
color::green(path)
),
"state_load" => {
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
println!("{}", note);
}
println!("{} State path set to {}", color::success_indicator(), color::green(path));
println!(
"{} State path set to {}",
color::success_indicator(),
color::green(path)
);
}
// video_start and other commands that provide a path with a note
"video_start" => {
@@ -263,7 +340,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
println!("Path: {}", path);
}
_ => println!("{} Saved to {}", color::success_indicator(), color::green(path)),
_ => println!(
"{} Saved to {}",
color::success_indicator(),
color::green(path)
),
}
return;
}
@@ -1251,7 +1332,8 @@ Examples:
}
// === Record (video) ===
"record" => r##"
"record" => {
r##"
agent-browser record - Record browser session to video
Usage: agent-browser record start <path.webm> [url]
@@ -1284,7 +1366,8 @@ Examples:
# Restart recording with a new file (stops previous, starts new)
agent-browser record restart ./take2.webm
"##,
"##
}
// === Console/Errors ===
"console" => {
@@ -1358,7 +1441,13 @@ Save or restore browser state (cookies, localStorage, sessionStorage).
Operations:
save <path> Save current state to file
load <path> Load state from file
load <path> Note: State must be loaded at browser launch via --state flag
Applying State:
Use --state flag when launching browser to load saved state:
agent-browser --state ./auth-state.json open https://example.com
Or set AGENT_BROWSER_STATE environment variable.
Global Options:
--json Output as JSON
@@ -1366,7 +1455,7 @@ Global Options:
Examples:
agent-browser state save ./auth-state.json
agent-browser state load ./auth-state.json
agent-browser --state ./auth-state.json open https://example.com
"##
}
@@ -1553,6 +1642,7 @@ Snapshot Options:
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE 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)
+14
View File
@@ -819,6 +819,7 @@ export class BrowserManager {
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
const hasExtensions = !!options.extensions?.length;
const hasProfile = !!options.profile;
const hasStorageState = !!options.storageState;
if (hasExtensions && cdpEndpoint) {
throw new Error('Extensions cannot be used with CDP connection');
@@ -828,6 +829,18 @@ export class BrowserManager {
throw new Error('Profile cannot be used with CDP connection');
}
if (hasStorageState && hasProfile) {
throw new Error(
'Storage state cannot be used with profile (profile is already persistent storage)'
);
}
if (hasStorageState && hasExtensions) {
throw new Error(
'Storage state cannot be used with extensions (extensions require persistent context)'
);
}
if (this.isLaunched()) {
const needsRelaunch =
(!cdpEndpoint && this.cdpEndpoint !== null) ||
@@ -912,6 +925,7 @@ export class BrowserManager {
userAgent: options.userAgent,
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(options.storageState && { storageState: options.storageState }),
});
}
+2
View File
@@ -255,6 +255,8 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
+2
View File
@@ -46,6 +46,8 @@ const launchSchema = baseCommandSchema.extend({
userAgent: z.string().optional(),
provider: z.string().optional(),
ignoreHTTPSErrors: z.boolean().optional(),
profile: z.string().optional(),
storageState: z.string().optional(),
});
const navigateSchema = baseCommandSchema.extend({
+1
View File
@@ -18,6 +18,7 @@ export interface LaunchCommand extends BaseCommand {
cdpUrl?: string;
extensions?: string[];
profile?: string; // Path to persistent browser profile directory
storageState?: string; // Path to storage state JSON file
proxy?: {
server: string;
bypass?: string;