* fix(cli): honor AGENT_BROWSER_DEFAULT_TIMEOUT env var for wait commands The `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable was being ignored by CLI wait commands, causing them to use hardcoded 30-second timeouts instead of the configured default. ## Changes Made - **Centralized timeout injection**: Modified `parse_command()` to automatically inject `flags.default_timeout` into any wait-family command that doesn't already have an explicit `--timeout` flag - **Environment variable parsing**: Added `default_timeout` field to `Flags` struct that reads from `AGENT_BROWSER_DEFAULT_TIMEOUT` env var - **Daemon propagation**: Updated daemon spawning to pass through the default timeout via environment variables - **Unified timeout handling**: Added `timeout_ms()` helper method in `DaemonState` that all wait handlers now use instead of scattered `unwrap_or()` calls - **Comprehensive test coverage**: Added 10 regression tests covering all wait command variants and edge cases ## Implementation Details The fix uses a two-stage approach: 1. CLI parses the env var and injects timeout values into command JSON for any `wait*` action 2. Daemon reads the env var and provides a centralized fallback via `timeout_ms()` helper This ensures new wait variants automatically inherit the default timeout without requiring per-variant wiring. Fixes #1147 * fix: preserve 30s default timeout for backward compatibility The default_timeout_ms fallback was set to 25_000ms, which silently changes the existing 30_000ms behavior for users who haven't set AGENT_BROWSER_DEFAULT_TIMEOUT. Restore the original 30s default. --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
1419 lines
48 KiB
Rust
1419 lines
48 KiB
Rust
use crate::color;
|
|
use serde::Deserialize;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
const CONFIG_DIR: &str = ".agent-browser";
|
|
const CONFIG_FILENAME: &str = "config.json";
|
|
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
|
|
|
/// Parse idle timeout from user-friendly format.
|
|
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
|
|
fn parse_idle_timeout(s: &str) -> Result<String, String> {
|
|
let s = s.trim();
|
|
if s.is_empty() {
|
|
return Err("Empty idle timeout".to_string());
|
|
}
|
|
|
|
// If the value ends with a unit suffix, convert it to milliseconds.
|
|
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
|
|
let (num_str, unit) = s.split_at(s.len() - 1);
|
|
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
|
|
|
|
let ms = match unit {
|
|
"s" => num * 1000,
|
|
"m" => num * 60 * 1000,
|
|
"h" => num * 60 * 60 * 1000,
|
|
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
|
|
};
|
|
return Ok(ms.to_string());
|
|
}
|
|
|
|
// Pure numbers are already expressed in milliseconds.
|
|
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
|
|
Ok(s.to_string())
|
|
}
|
|
|
|
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
|
|
value.and_then(|raw| match parse_idle_timeout(&raw) {
|
|
Ok(ms) => Some(ms),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"{} invalid idle timeout from {}: {}",
|
|
color::warning_indicator(),
|
|
source,
|
|
e
|
|
);
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
pub struct Config {
|
|
pub headed: Option<bool>,
|
|
pub json: Option<bool>,
|
|
pub debug: Option<bool>,
|
|
pub session: Option<String>,
|
|
pub session_name: Option<String>,
|
|
pub executable_path: Option<String>,
|
|
pub extensions: Option<Vec<String>>,
|
|
pub profile: Option<String>,
|
|
pub state: Option<String>,
|
|
pub proxy: Option<String>,
|
|
pub proxy_bypass: Option<String>,
|
|
pub args: Option<String>,
|
|
pub user_agent: Option<String>,
|
|
pub provider: Option<String>,
|
|
pub device: Option<String>,
|
|
pub ignore_https_errors: Option<bool>,
|
|
pub allow_file_access: Option<bool>,
|
|
pub cdp: Option<String>,
|
|
pub auto_connect: Option<bool>,
|
|
pub headers: Option<String>,
|
|
pub annotate: Option<bool>,
|
|
pub color_scheme: Option<String>,
|
|
pub download_path: Option<String>,
|
|
pub content_boundaries: Option<bool>,
|
|
pub max_output: Option<usize>,
|
|
pub allowed_domains: Option<Vec<String>>,
|
|
pub action_policy: Option<String>,
|
|
pub confirm_actions: Option<String>,
|
|
pub confirm_interactive: Option<bool>,
|
|
pub engine: Option<String>,
|
|
pub screenshot_dir: Option<String>,
|
|
pub screenshot_quality: Option<u32>,
|
|
pub screenshot_format: Option<String>,
|
|
pub idle_timeout: Option<String>,
|
|
pub no_auto_dialog: Option<bool>,
|
|
}
|
|
|
|
impl Config {
|
|
fn merge(self, other: Config) -> Config {
|
|
Config {
|
|
headed: other.headed.or(self.headed),
|
|
json: other.json.or(self.json),
|
|
debug: other.debug.or(self.debug),
|
|
session: other.session.or(self.session),
|
|
session_name: other.session_name.or(self.session_name),
|
|
executable_path: other.executable_path.or(self.executable_path),
|
|
extensions: match (self.extensions, other.extensions) {
|
|
(Some(mut a), Some(b)) => {
|
|
a.extend(b);
|
|
Some(a)
|
|
}
|
|
(a, b) => b.or(a),
|
|
},
|
|
profile: other.profile.or(self.profile),
|
|
state: other.state.or(self.state),
|
|
proxy: other.proxy.or(self.proxy),
|
|
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
|
|
args: other.args.or(self.args),
|
|
user_agent: other.user_agent.or(self.user_agent),
|
|
provider: other.provider.or(self.provider),
|
|
device: other.device.or(self.device),
|
|
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
|
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
|
cdp: other.cdp.or(self.cdp),
|
|
auto_connect: other.auto_connect.or(self.auto_connect),
|
|
headers: other.headers.or(self.headers),
|
|
annotate: other.annotate.or(self.annotate),
|
|
color_scheme: other.color_scheme.or(self.color_scheme),
|
|
download_path: other.download_path.or(self.download_path),
|
|
content_boundaries: other.content_boundaries.or(self.content_boundaries),
|
|
max_output: other.max_output.or(self.max_output),
|
|
allowed_domains: other.allowed_domains.or(self.allowed_domains),
|
|
action_policy: other.action_policy.or(self.action_policy),
|
|
confirm_actions: other.confirm_actions.or(self.confirm_actions),
|
|
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
|
|
engine: other.engine.or(self.engine),
|
|
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
|
|
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
|
|
screenshot_format: other.screenshot_format.or(self.screenshot_format),
|
|
idle_timeout: other.idle_timeout.or(self.idle_timeout),
|
|
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_config_file(path: &Path) -> Option<Config> {
|
|
let content = fs::read_to_string(path).ok()?;
|
|
match serde_json::from_str::<Config>(&content) {
|
|
Ok(mut config) => {
|
|
config.idle_timeout = parse_idle_timeout_value(
|
|
config.idle_timeout.take(),
|
|
&format!("config file {}", path.display()),
|
|
);
|
|
Some(config)
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"{} invalid config file {}: {}",
|
|
color::warning_indicator(),
|
|
path.display(),
|
|
e
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if a boolean environment variable is set to a truthy value.
|
|
/// Returns false when unset, empty, or set to "0", "false", or "no" (case-insensitive).
|
|
fn env_var_is_truthy(name: &str) -> bool {
|
|
match env::var(name) {
|
|
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
|
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
|
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
|
if let Some(v) = args.get(i + 1) {
|
|
match v.as_str() {
|
|
"true" => (true, true),
|
|
"false" => (false, true),
|
|
_ => (true, false),
|
|
}
|
|
} else {
|
|
(true, false)
|
|
}
|
|
}
|
|
|
|
/// Extract --config <path> from args before full flag parsing.
|
|
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
|
|
/// was the last arg with no value, `None` if --config not present.
|
|
///
|
|
/// Only flags that consume a following argument need to be listed here.
|
|
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
|
|
/// intentionally absent -- they don't take a value, so they can't cause
|
|
/// the next argument to be mis-consumed.
|
|
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
|
const FLAGS_WITH_VALUE: &[&str] = &[
|
|
"--session",
|
|
"--headers",
|
|
"--executable-path",
|
|
"--cdp",
|
|
"--extension",
|
|
"--profile",
|
|
"--state",
|
|
"--proxy",
|
|
"--proxy-bypass",
|
|
"--args",
|
|
"--user-agent",
|
|
"-p",
|
|
"--provider",
|
|
"--device",
|
|
"--session-name",
|
|
"--color-scheme",
|
|
"--download-path",
|
|
"--max-output",
|
|
"--allowed-domains",
|
|
"--action-policy",
|
|
"--confirm-actions",
|
|
"--engine",
|
|
"--screenshot-dir",
|
|
"--screenshot-quality",
|
|
"--screenshot-format",
|
|
"--idle-timeout",
|
|
];
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
if args[i] == "--config" {
|
|
return Some(args.get(i + 1).cloned());
|
|
}
|
|
if FLAGS_WITH_VALUE.contains(&args[i].as_str()) {
|
|
i += 1;
|
|
}
|
|
i += 1;
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn load_config(args: &[String]) -> Result<Config, String> {
|
|
let explicit = extract_config_path(args)
|
|
.map(|p| ("--config", p))
|
|
.or_else(|| {
|
|
env::var("AGENT_BROWSER_CONFIG")
|
|
.ok()
|
|
.map(|p| ("AGENT_BROWSER_CONFIG", Some(p)))
|
|
});
|
|
|
|
if let Some((source, maybe_path)) = explicit {
|
|
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
|
|
let path = PathBuf::from(&path_str);
|
|
if !path.exists() {
|
|
return Err(format!("config file not found: {}", path_str));
|
|
}
|
|
return read_config_file(&path)
|
|
.ok_or_else(|| format!("failed to load config from {}", path_str));
|
|
}
|
|
|
|
let user_config = dirs::home_dir()
|
|
.map(|d| d.join(CONFIG_DIR).join(CONFIG_FILENAME))
|
|
.and_then(|p| read_config_file(&p))
|
|
.unwrap_or_default();
|
|
|
|
let project_config = read_config_file(&PathBuf::from(PROJECT_CONFIG_FILENAME));
|
|
|
|
Ok(match project_config {
|
|
Some(project) => user_config.merge(project),
|
|
None => user_config,
|
|
})
|
|
}
|
|
|
|
pub struct Flags {
|
|
pub json: bool,
|
|
pub headed: bool,
|
|
pub debug: bool,
|
|
pub session: String,
|
|
pub headers: Option<String>,
|
|
pub executable_path: Option<String>,
|
|
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>,
|
|
pub user_agent: Option<String>,
|
|
pub provider: Option<String>,
|
|
pub ignore_https_errors: bool,
|
|
pub allow_file_access: bool,
|
|
pub device: Option<String>,
|
|
pub auto_connect: bool,
|
|
pub session_name: Option<String>,
|
|
pub annotate: bool,
|
|
pub color_scheme: Option<String>,
|
|
pub download_path: Option<String>,
|
|
pub content_boundaries: bool,
|
|
pub max_output: Option<usize>,
|
|
pub allowed_domains: Option<Vec<String>>,
|
|
pub action_policy: Option<String>,
|
|
pub confirm_actions: Option<String>,
|
|
pub confirm_interactive: bool,
|
|
pub engine: Option<String>,
|
|
pub screenshot_dir: Option<String>,
|
|
pub screenshot_quality: Option<u32>,
|
|
pub screenshot_format: Option<String>,
|
|
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
|
pub default_timeout: Option<u64>, // AGENT_BROWSER_DEFAULT_TIMEOUT in ms
|
|
pub no_auto_dialog: bool,
|
|
|
|
// Track which launch-time options were explicitly passed via CLI
|
|
// (as opposed to being set only via environment variables)
|
|
pub cli_executable_path: bool,
|
|
pub cli_extensions: bool,
|
|
pub cli_profile: bool,
|
|
pub cli_state: bool,
|
|
pub cli_args: bool,
|
|
pub cli_user_agent: bool,
|
|
pub cli_proxy: bool,
|
|
pub cli_proxy_bypass: bool,
|
|
pub cli_allow_file_access: bool,
|
|
pub cli_annotate: bool,
|
|
pub cli_download_path: bool,
|
|
pub cli_headed: bool,
|
|
}
|
|
|
|
pub fn parse_flags(args: &[String]) -> Flags {
|
|
let config = load_config(args).unwrap_or_else(|e| {
|
|
eprintln!("{} {}", color::warning_indicator(), e);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
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<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let extensions = if !extensions_env.is_empty() {
|
|
extensions_env
|
|
} else {
|
|
config.extensions.unwrap_or_default()
|
|
};
|
|
|
|
let mut flags = Flags {
|
|
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
|
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
|
|
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
|
session: env::var("AGENT_BROWSER_SESSION")
|
|
.ok()
|
|
.or(config.session)
|
|
.unwrap_or_else(|| "default".to_string()),
|
|
headers: config.headers,
|
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
|
.ok()
|
|
.or(config.executable_path),
|
|
cdp: config.cdp,
|
|
extensions,
|
|
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
|
|
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
|
|
proxy: env::var("AGENT_BROWSER_PROXY")
|
|
.ok()
|
|
.or(config.proxy)
|
|
.or_else(|| env::var("HTTP_PROXY").ok())
|
|
.or_else(|| env::var("http_proxy").ok())
|
|
.or_else(|| env::var("HTTPS_PROXY").ok())
|
|
.or_else(|| env::var("https_proxy").ok())
|
|
.or_else(|| env::var("ALL_PROXY").ok())
|
|
.or_else(|| env::var("all_proxy").ok()),
|
|
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
|
|
.ok()
|
|
.or(config.proxy_bypass)
|
|
.or_else(|| env::var("NO_PROXY").ok())
|
|
.or_else(|| env::var("no_proxy").ok()),
|
|
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
|
|
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
|
|
.ok()
|
|
.or(config.user_agent),
|
|
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
|
|
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|
|
|| config.ignore_https_errors.unwrap_or(false),
|
|
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
|
|| config.allow_file_access.unwrap_or(false),
|
|
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
|
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
|
|| config.auto_connect.unwrap_or(false),
|
|
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
|
.ok()
|
|
.or(config.session_name),
|
|
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
|
|
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
|
.ok()
|
|
.or(config.color_scheme),
|
|
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
|
|
.ok()
|
|
.or(config.download_path),
|
|
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|
|
|| config.content_boundaries.unwrap_or(false),
|
|
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.or(config.max_output),
|
|
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
|
|
.ok()
|
|
.map(|s| {
|
|
s.split(',')
|
|
.map(|d| d.trim().to_lowercase())
|
|
.filter(|d| !d.is_empty())
|
|
.collect()
|
|
})
|
|
.or(config.allowed_domains),
|
|
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
|
|
.ok()
|
|
.or(config.action_policy),
|
|
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
|
|
.ok()
|
|
.or(config.confirm_actions),
|
|
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|
|
|| config.confirm_interactive.unwrap_or(false),
|
|
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
|
|
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
|
|
.ok()
|
|
.or(config.screenshot_dir),
|
|
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.or(config.screenshot_quality),
|
|
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
|
|
.ok()
|
|
.or(config.screenshot_format)
|
|
.filter(|s| s == "png" || s == "jpeg"),
|
|
idle_timeout: parse_idle_timeout_value(
|
|
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
|
|
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
|
)
|
|
.or(config.idle_timeout),
|
|
default_timeout: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u64>().ok()),
|
|
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
|
|| config.no_auto_dialog.unwrap_or(false),
|
|
cli_executable_path: false,
|
|
cli_extensions: false,
|
|
cli_profile: false,
|
|
cli_state: false,
|
|
cli_args: false,
|
|
cli_user_agent: false,
|
|
cli_proxy: false,
|
|
cli_proxy_bypass: false,
|
|
cli_allow_file_access: false,
|
|
cli_annotate: false,
|
|
cli_download_path: false,
|
|
cli_headed: false,
|
|
};
|
|
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--json" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.json = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--headed" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.headed = val;
|
|
flags.cli_headed = true;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--debug" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.debug = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--session" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.session = s.clone();
|
|
i += 1;
|
|
}
|
|
}
|
|
"--idle-timeout" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
match parse_idle_timeout(s) {
|
|
Ok(ms) => flags.idle_timeout = Some(ms),
|
|
Err(e) => eprintln!(
|
|
"{} Invalid --idle-timeout: {}",
|
|
color::warning_indicator(),
|
|
e
|
|
),
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
"--headers" => {
|
|
if let Some(h) = args.get(i + 1) {
|
|
flags.headers = Some(h.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--executable-path" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.executable_path = Some(s.clone());
|
|
flags.cli_executable_path = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--extension" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.extensions.push(s.clone());
|
|
flags.cli_extensions = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--cdp" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.cdp = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--profile" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.profile = Some(s.clone());
|
|
flags.cli_profile = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--state" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.state = Some(s.clone());
|
|
flags.cli_state = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--proxy" => {
|
|
if let Some(p) = args.get(i + 1) {
|
|
flags.proxy = Some(p.clone());
|
|
flags.cli_proxy = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--proxy-bypass" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.proxy_bypass = Some(s.clone());
|
|
flags.cli_proxy_bypass = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--args" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.args = Some(s.clone());
|
|
flags.cli_args = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--user-agent" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.user_agent = Some(s.clone());
|
|
flags.cli_user_agent = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"-p" | "--provider" => {
|
|
if let Some(p) = args.get(i + 1) {
|
|
flags.provider = Some(p.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--ignore-https-errors" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.ignore_https_errors = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--allow-file-access" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.allow_file_access = val;
|
|
flags.cli_allow_file_access = true;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--device" => {
|
|
if let Some(d) = args.get(i + 1) {
|
|
flags.device = Some(d.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--auto-connect" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.auto_connect = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--session-name" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.session_name = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--annotate" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.annotate = val;
|
|
flags.cli_annotate = true;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--color-scheme" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.color_scheme = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--download-path" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.download_path = Some(s.clone());
|
|
flags.cli_download_path = true;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--content-boundaries" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.content_boundaries = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--max-output" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
if let Ok(n) = s.parse::<usize>() {
|
|
flags.max_output = Some(n);
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
"--allowed-domains" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.allowed_domains = Some(
|
|
s.split(',')
|
|
.map(|d| d.trim().to_lowercase())
|
|
.filter(|d| !d.is_empty())
|
|
.collect(),
|
|
);
|
|
i += 1;
|
|
}
|
|
}
|
|
"--action-policy" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.action_policy = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--confirm-actions" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.confirm_actions = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--confirm-interactive" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.confirm_interactive = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--engine" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.engine = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--screenshot-dir" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
flags.screenshot_dir = Some(s.clone());
|
|
i += 1;
|
|
}
|
|
}
|
|
"--screenshot-quality" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
if let Ok(n) = s.parse::<u32>() {
|
|
if n <= 100 {
|
|
flags.screenshot_quality = Some(n);
|
|
} else {
|
|
eprintln!(
|
|
"{} --screenshot-quality must be 0-100, got {}",
|
|
color::warning_indicator(),
|
|
n
|
|
);
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
"--screenshot-format" => {
|
|
if let Some(s) = args.get(i + 1) {
|
|
if s == "png" || s == "jpeg" {
|
|
flags.screenshot_format = Some(s.clone());
|
|
} else {
|
|
eprintln!(
|
|
"{} --screenshot-format must be png or jpeg, got '{}'",
|
|
color::warning_indicator(),
|
|
s
|
|
);
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
"--no-auto-dialog" => {
|
|
let (val, consumed) = parse_bool_arg(args, i);
|
|
flags.no_auto_dialog = val;
|
|
if consumed {
|
|
i += 1;
|
|
}
|
|
}
|
|
"--config" => {
|
|
// Already handled by load_config(); skip the value
|
|
i += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
flags
|
|
}
|
|
|
|
pub fn clean_args(args: &[String]) -> Vec<String> {
|
|
let mut result = Vec::new();
|
|
let mut skip_next = false;
|
|
|
|
// Boolean flags that optionally take true/false
|
|
const GLOBAL_BOOL_FLAGS: &[&str] = &[
|
|
"--json",
|
|
"--headed",
|
|
"--debug",
|
|
"--ignore-https-errors",
|
|
"--allow-file-access",
|
|
"--auto-connect",
|
|
"--annotate",
|
|
"--content-boundaries",
|
|
"--confirm-interactive",
|
|
"--no-auto-dialog",
|
|
];
|
|
// Global flags that always take a value (need to skip the next arg too)
|
|
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
|
"--session",
|
|
"--headers",
|
|
"--executable-path",
|
|
"--cdp",
|
|
"--extension",
|
|
"--profile",
|
|
"--state",
|
|
"--proxy",
|
|
"--proxy-bypass",
|
|
"--args",
|
|
"--user-agent",
|
|
"-p",
|
|
"--provider",
|
|
"--device",
|
|
"--session-name",
|
|
"--color-scheme",
|
|
"--download-path",
|
|
"--max-output",
|
|
"--allowed-domains",
|
|
"--action-policy",
|
|
"--confirm-actions",
|
|
"--config",
|
|
"--engine",
|
|
"--screenshot-dir",
|
|
"--screenshot-quality",
|
|
"--screenshot-format",
|
|
"--idle-timeout",
|
|
];
|
|
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let arg = &args[i];
|
|
if skip_next {
|
|
skip_next = false;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
|
skip_next = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
|
|
if let Some(v) = args.get(i + 1) {
|
|
if matches!(v.as_str(), "true" | "false") {
|
|
i += 1;
|
|
}
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
result.push(arg.clone());
|
|
i += 1;
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn args(s: &str) -> Vec<String> {
|
|
s.split_whitespace().map(String::from).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_headers_flag() {
|
|
let flags = parse_flags(&args(r#"open example.com --headers {"Auth":"token"}"#));
|
|
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_raw_ms() {
|
|
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_seconds() {
|
|
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_minutes() {
|
|
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_hours() {
|
|
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_rejects_capital_m() {
|
|
assert!(parse_idle_timeout("10M").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_rejects_unknown_unit() {
|
|
assert!(parse_idle_timeout("10x").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_headers_flag_with_spaces() {
|
|
// Headers JSON is passed as a single quoted argument in shell
|
|
let input: Vec<String> = vec![
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
"--headers".to_string(),
|
|
r#"{"Authorization": "Bearer token"}"#.to_string(),
|
|
];
|
|
let flags = parse_flags(&input);
|
|
assert_eq!(
|
|
flags.headers,
|
|
Some(r#"{"Authorization": "Bearer token"}"#.to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_no_headers_flag() {
|
|
let flags = parse_flags(&args("open example.com"));
|
|
assert!(flags.headers.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_headers() {
|
|
let input: Vec<String> = vec![
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
"--headers".to_string(),
|
|
r#"{"Auth":"token"}"#.to_string(),
|
|
];
|
|
let clean = clean_args(&input);
|
|
assert_eq!(clean, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_headers_at_start() {
|
|
let input: Vec<String> = vec![
|
|
"--headers".to_string(),
|
|
r#"{"Auth":"token"}"#.to_string(),
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
];
|
|
let clean = clean_args(&input);
|
|
assert_eq!(clean, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_headers_with_other_flags() {
|
|
let input: Vec<String> = vec![
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
"--headers".to_string(),
|
|
r#"{"Auth":"token"}"#.to_string(),
|
|
"--json".to_string(),
|
|
"--headed".to_string(),
|
|
];
|
|
let flags = parse_flags(&input);
|
|
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",
|
|
));
|
|
assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_executable_path_flag_no_value() {
|
|
let flags = parse_flags(&args("--executable-path"));
|
|
assert_eq!(flags.executable_path, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_executable_path() {
|
|
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",
|
|
));
|
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_idle_timeout_before_command() {
|
|
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
|
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_idle_timeout_flag_converts_to_ms() {
|
|
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
|
|
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_flags_with_session_and_executable_path() {
|
|
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()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_executable_path_tracking() {
|
|
// When --executable-path is passed via CLI, cli_executable_path should be true
|
|
let flags = parse_flags(&args("--executable-path /path/to/chrome snapshot"));
|
|
assert!(flags.cli_executable_path);
|
|
assert_eq!(flags.executable_path, Some("/path/to/chrome".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_executable_path_not_set_without_flag() {
|
|
// When no --executable-path is passed, cli_executable_path should be false
|
|
// (even if env var sets executable_path to Some value, which we can't test here)
|
|
let flags = parse_flags(&args("snapshot"));
|
|
assert!(!flags.cli_executable_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_extension_tracking() {
|
|
let flags = parse_flags(&args("--extension /path/to/ext snapshot"));
|
|
assert!(flags.cli_extensions);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_profile_tracking() {
|
|
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
|
|
assert!(flags.cli_profile);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_annotate_tracking() {
|
|
let flags = parse_flags(&args("--annotate screenshot"));
|
|
assert!(flags.cli_annotate);
|
|
assert!(flags.annotate);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_annotate_not_set_without_flag() {
|
|
let flags = parse_flags(&args("screenshot"));
|
|
assert!(!flags.cli_annotate);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_download_path_tracking() {
|
|
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
|
assert!(flags.cli_download_path);
|
|
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_download_path_not_set_without_flag() {
|
|
let flags = parse_flags(&args("snapshot"));
|
|
assert!(!flags.cli_download_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_multiple_flags_tracking() {
|
|
let flags = parse_flags(&args(
|
|
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
|
|
));
|
|
assert!(flags.cli_executable_path);
|
|
assert!(flags.cli_profile);
|
|
assert!(flags.cli_proxy);
|
|
assert!(!flags.cli_extensions);
|
|
assert!(!flags.cli_state);
|
|
}
|
|
|
|
// === Config file tests ===
|
|
|
|
#[test]
|
|
fn test_config_deserialize_full() {
|
|
let json = r#"{
|
|
"headed": true,
|
|
"json": true,
|
|
"debug": true,
|
|
"session": "test-session",
|
|
"sessionName": "my-app",
|
|
"executablePath": "/usr/bin/chromium",
|
|
"extensions": ["/ext1", "/ext2"],
|
|
"profile": "/tmp/profile",
|
|
"state": "/tmp/state.json",
|
|
"proxy": "http://proxy:8080",
|
|
"proxyBypass": "localhost",
|
|
"args": "--no-sandbox",
|
|
"userAgent": "test-agent",
|
|
"provider": "ios",
|
|
"device": "iPhone 15",
|
|
"ignoreHttpsErrors": true,
|
|
"allowFileAccess": true,
|
|
"cdp": "9222",
|
|
"autoConnect": true,
|
|
"headers": "{\"Auth\":\"token\"}"
|
|
}"#;
|
|
let config: Config = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.headed, Some(true));
|
|
assert_eq!(config.json, Some(true));
|
|
assert_eq!(config.debug, Some(true));
|
|
assert_eq!(config.session.as_deref(), Some("test-session"));
|
|
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
|
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
|
|
assert_eq!(
|
|
config.extensions,
|
|
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
|
|
);
|
|
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
|
|
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
|
|
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
|
|
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
|
|
assert_eq!(config.args.as_deref(), Some("--no-sandbox"));
|
|
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
|
assert_eq!(config.provider.as_deref(), Some("ios"));
|
|
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
|
assert_eq!(config.ignore_https_errors, Some(true));
|
|
assert_eq!(config.allow_file_access, Some(true));
|
|
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
|
assert_eq!(config.auto_connect, Some(true));
|
|
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_deserialize_partial() {
|
|
let json = r#"{"headed": true, "proxy": "http://localhost:8080"}"#;
|
|
let config: Config = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.headed, Some(true));
|
|
assert_eq!(config.proxy.as_deref(), Some("http://localhost:8080"));
|
|
assert_eq!(config.session, None);
|
|
assert_eq!(config.extensions, None);
|
|
assert_eq!(config.debug, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_deserialize_empty() {
|
|
let config: Config = serde_json::from_str("{}").unwrap();
|
|
assert_eq!(config.headed, None);
|
|
assert_eq!(config.session, None);
|
|
assert_eq!(config.proxy, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_ignores_unknown_keys() {
|
|
let json = r#"{"headed": true, "unknownFutureKey": "value", "anotherOne": 42}"#;
|
|
let config: Config = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.headed, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_merge_project_overrides_user() {
|
|
let user = Config {
|
|
headed: Some(true),
|
|
proxy: Some("http://user-proxy:8080".to_string()),
|
|
profile: Some("/user/profile".to_string()),
|
|
..Config::default()
|
|
};
|
|
let project = Config {
|
|
proxy: Some("http://project-proxy:9090".to_string()),
|
|
debug: Some(true),
|
|
..Config::default()
|
|
};
|
|
let merged = user.merge(project);
|
|
assert_eq!(merged.headed, Some(true)); // kept from user
|
|
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
|
|
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
|
|
assert_eq!(merged.debug, Some(true)); // added by project
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_merge_none_does_not_override() {
|
|
let user = Config {
|
|
headed: Some(true),
|
|
proxy: Some("http://proxy:8080".to_string()),
|
|
..Config::default()
|
|
};
|
|
let project = Config::default();
|
|
let merged = user.merge(project);
|
|
assert_eq!(merged.headed, Some(true));
|
|
assert_eq!(merged.proxy.as_deref(), Some("http://proxy:8080"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_from_file() {
|
|
use std::io::Write;
|
|
let dir = std::env::temp_dir().join("ab-test-config");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let config_path = dir.join("test-config.json");
|
|
let mut f = fs::File::create(&config_path).unwrap();
|
|
writeln!(f, r#"{{"headed": true, "proxy": "http://test:1234"}}"#).unwrap();
|
|
|
|
let config = read_config_file(&config_path).unwrap();
|
|
assert_eq!(config.headed, Some(true));
|
|
assert_eq!(config.proxy.as_deref(), Some("http://test:1234"));
|
|
|
|
let _ = fs::remove_file(&config_path);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_from_file_parses_idle_timeout() {
|
|
use std::io::Write;
|
|
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let config_path = dir.join("test-config.json");
|
|
let mut f = fs::File::create(&config_path).unwrap();
|
|
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
|
|
|
|
let config = read_config_file(&config_path).unwrap();
|
|
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
|
|
|
|
let _ = fs::remove_file(&config_path);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_missing_file_returns_none() {
|
|
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_malformed_json_returns_none() {
|
|
use std::io::Write;
|
|
let dir = std::env::temp_dir().join("ab-test-malformed");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let config_path = dir.join("bad-config.json");
|
|
let mut f = fs::File::create(&config_path).unwrap();
|
|
writeln!(f, "{{not valid json}}").unwrap();
|
|
|
|
let result = read_config_file(&config_path);
|
|
assert!(result.is_none());
|
|
|
|
let _ = fs::remove_file(&config_path);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_config_path() {
|
|
assert_eq!(
|
|
extract_config_path(&args("--config ./my-config.json open example.com")),
|
|
Some(Some("./my-config.json".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_config_path_missing() {
|
|
assert_eq!(extract_config_path(&args("open example.com")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_config_path_no_value() {
|
|
assert_eq!(extract_config_path(&args("--config")), Some(None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_config_path_skips_flag_values() {
|
|
assert_eq!(extract_config_path(&args("--args --config open")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_config() {
|
|
let cleaned = clean_args(&args("--config ./config.json open example.com"));
|
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_with_config_flag() {
|
|
use std::io::Write;
|
|
let dir = std::env::temp_dir().join("ab-test-flag-config");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let config_path = dir.join("custom.json");
|
|
let mut f = fs::File::create(&config_path).unwrap();
|
|
writeln!(f, r#"{{"headed": true, "session": "custom"}}"#).unwrap();
|
|
|
|
let flag_args = vec![
|
|
"--config".to_string(),
|
|
config_path.to_string_lossy().to_string(),
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
];
|
|
let config = load_config(&flag_args).unwrap();
|
|
assert_eq!(config.headed, Some(true));
|
|
assert_eq!(config.session.as_deref(), Some("custom"));
|
|
|
|
let _ = fs::remove_file(&config_path);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_error_missing_config_value() {
|
|
let result = load_config(&args("--config"));
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("requires a file path"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_error_nonexistent_file() {
|
|
let result = load_config(&args("--config /nonexistent/config.json open"));
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("config file not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_error_malformed_explicit() {
|
|
use std::io::Write;
|
|
let dir = std::env::temp_dir().join("ab-test-explicit-malformed");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let config_path = dir.join("bad.json");
|
|
let mut f = fs::File::create(&config_path).unwrap();
|
|
writeln!(f, "{{not valid}}").unwrap();
|
|
|
|
let flag_args = vec![
|
|
"--config".to_string(),
|
|
config_path.to_string_lossy().to_string(),
|
|
];
|
|
let result = load_config(&flag_args);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("failed to load config"));
|
|
|
|
let _ = fs::remove_file(&config_path);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
// === Boolean flag value tests ===
|
|
|
|
#[test]
|
|
fn test_headed_false() {
|
|
let flags = parse_flags(&args("--headed false open example.com"));
|
|
assert!(!flags.headed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_headed_true_explicit() {
|
|
let flags = parse_flags(&args("--headed true open example.com"));
|
|
assert!(flags.headed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_headed_bare_defaults_true() {
|
|
let flags = parse_flags(&args("--headed open example.com"));
|
|
assert!(flags.headed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_debug_false() {
|
|
let flags = parse_flags(&args("--debug false open example.com"));
|
|
assert!(!flags.debug);
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_false() {
|
|
let flags = parse_flags(&args("--json false open example.com"));
|
|
assert!(!flags.json);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ignore_https_errors_false() {
|
|
let flags = parse_flags(&args("--ignore-https-errors false open"));
|
|
assert!(!flags.ignore_https_errors);
|
|
}
|
|
|
|
#[test]
|
|
fn test_allow_file_access_false() {
|
|
let flags = parse_flags(&args("--allow-file-access false open"));
|
|
assert!(!flags.allow_file_access);
|
|
assert!(flags.cli_allow_file_access);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_connect_false() {
|
|
let flags = parse_flags(&args("--auto-connect false open"));
|
|
assert!(!flags.auto_connect);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_bool_flag_with_value() {
|
|
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_bare_bool_flag() {
|
|
let cleaned = clean_args(&args("--headed --debug open example.com"));
|
|
assert_eq!(cleaned, vec!["open", "example.com"]);
|
|
}
|
|
|
|
// === Extensions merge tests ===
|
|
|
|
#[test]
|
|
fn test_config_merge_extensions_concatenated() {
|
|
let user = Config {
|
|
extensions: Some(vec!["/ext1".to_string()]),
|
|
..Config::default()
|
|
};
|
|
let project = Config {
|
|
extensions: Some(vec!["/ext2".to_string(), "/ext3".to_string()]),
|
|
..Config::default()
|
|
};
|
|
let merged = user.merge(project);
|
|
assert_eq!(
|
|
merged.extensions,
|
|
Some(vec![
|
|
"/ext1".to_string(),
|
|
"/ext2".to_string(),
|
|
"/ext3".to_string()
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_merge_extensions_user_only() {
|
|
let user = Config {
|
|
extensions: Some(vec!["/ext1".to_string()]),
|
|
..Config::default()
|
|
};
|
|
let project = Config::default();
|
|
let merged = user.merge(project);
|
|
assert_eq!(merged.extensions, Some(vec!["/ext1".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_merge_extensions_project_only() {
|
|
let user = Config::default();
|
|
let project = Config {
|
|
extensions: Some(vec!["/ext2".to_string()]),
|
|
..Config::default()
|
|
};
|
|
let merged = user.merge(project);
|
|
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_auto_dialog_flag() {
|
|
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
|
|
assert!(flags.no_auto_dialog);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_auto_dialog_default_false() {
|
|
let flags = parse_flags(&args("open example.com"));
|
|
assert!(!flags.no_auto_dialog);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_args_removes_no_auto_dialog() {
|
|
let input: Vec<String> = vec![
|
|
"open".to_string(),
|
|
"example.com".to_string(),
|
|
"--no-auto-dialog".to_string(),
|
|
];
|
|
let clean = clean_args(&input);
|
|
assert_eq!(clean, vec!["open", "example.com"]);
|
|
}
|
|
}
|