use std::collections::{BTreeSet, HashMap};
use serde_json::{json, Value};
use super::running::SecurityView;
use super::*;
use crate::flags::{split_launch_args, try_parse_flags, Flags};
use crate::test_utils::EnvGuard;
fn blank_flags() -> Flags {
let names: Vec<String> = std::env::vars()
.map(|(name, _)| name)
.filter(|name| name.starts_with("AGENT_BROWSER_"))
.collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
let guard = EnvGuard::new(&refs);
for name in &refs {
guard.remove(name);
}
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
std::fs::write(&config, "{}").unwrap();
let args = vec![
"--config".to_string(),
config.to_string_lossy().into_owned(),
];
try_parse_flags(&args).unwrap()
}
pub(crate) fn full_flags() -> Flags {
let mut f = blank_flags();
f.headed = true;
f.cli_headed = true;
f.debug = true;
f.executable_path = Some("/bin/chrome".to_string());
f.cdp = Some("ws://127.0.0.1:9333/devtools".to_string());
f.cdp_headers = Some(r#"{"Authorization":"Bearer x"}"#.to_string());
f.extensions = vec!["/ext/a".to_string(), "/ext/b".to_string()];
f.init_scripts = vec!["/init/a.js".to_string()];
f.enable = vec!["react-devtools".to_string()];
f.profile = Some("/profile".to_string());
f.state = Some("/state.json".to_string());
f.proxy = Some("http://user:pw@proxy.example:8080".to_string());
f.proxy_bypass = Some("localhost".to_string());
f.args = Some("--no-first-run,--window-size=1280,720".to_string());
f.user_agent = Some("ua/1".to_string());
f.provider = Some("browserless".to_string());
f.ignore_https_errors = true;
f.ca_cert = Some("/ca.pem".to_string());
f.clear_ca_cert = true;
f.allow_file_access = true;
f.hide_scrollbars = false;
f.cli_hide_scrollbars = true;
f.webgpu = true;
f.cli_webgpu = true;
f.no_webmcp = true;
f.cli_no_webmcp = true;
f.no_xvfb = true;
f.device = Some("iPhone 15".to_string());
f.auto_connect = true;
f.pin_tab = true;
f.cli_pin_tab = true;
f.restore = Some("work".to_string());
f.restore_save = Some("always".to_string());
f.restore_check_url = Some("https://x.test/".to_string());
f.restore_check_text = Some("Welcome".to_string());
f.restore_check_fn = Some("() => true".to_string());
f.color_scheme = Some("dark".to_string());
f.download_path = Some("/downloads".to_string());
f.capture = Some(vec!["flow".to_string()]);
f.allowed_domains = Some(vec!["a.test".to_string(), "*.b.test".to_string()]);
f.action_policy = Some("/policy.json".to_string());
f.confirm_actions = Some("click,fill".to_string());
f.engine = Some("chrome".to_string());
f.idle_timeout = Some("120000".to_string());
f.default_timeout = Some(7000);
f.no_auto_dialog = true;
f.plugins = vec![serde_json::from_value(json!({"name": "p", "path": "/p"})).unwrap()];
f
}
fn set(patch: &Patch, key: Key) -> Value {
match patch.get(key) {
Some(Requested::Set(v)) => v.clone(),
other => panic!("{:?} not set: {:?}", key, other),
}
}
#[test]
fn table_has_one_row_per_key_in_key_order() {
assert_eq!(SPECS.len(), Key::ALL.len());
for (row, key) in SPECS.iter().zip(Key::ALL) {
assert_eq!(row.key, *key);
assert_eq!(spec(*key).key, *key);
}
}
#[test]
fn table_names_are_unique_kebab_case() {
let mut seen = BTreeSet::new();
for row in SPECS {
assert!(seen.insert(row.name), "duplicate name {}", row.name);
assert!(
row.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"{} is not kebab-case",
row.name
);
assert!(row.flag.is_empty() || row.flag.starts_with("--"));
assert_eq!(spec_by_name(row.name).map(|s| s.key), Some(row.key));
}
}
#[test]
fn only_security_settings_are_secured() {
let secured: Vec<Key> = SPECS
.iter()
.filter(|s| s.persist == Persist::Secured)
.map(|s| s.key)
.collect();
assert_eq!(
secured,
vec![Key::AllowedDomains, Key::ActionPolicy, Key::ConfirmActions]
);
}
#[test]
fn credentials_are_secret() {
let secret: Vec<Key> = SPECS.iter().filter(|s| s.secret).map(|s| s.key).collect();
assert_eq!(secret, vec![Key::CdpHeaders, Key::ProxyPassword]);
}
#[test]
fn launch_class_is_what_changes_the_browser() {
for key in [Key::Profile, Key::Args, Key::Engine, Key::Proxy, Key::Cdp] {
assert_eq!(spec(key).effect, Effect::Launch, "{:?}", key);
}
for key in [
Key::DefaultTimeout,
Key::IdleTimeout,
Key::PinTab,
Key::NoAutoDialog,
] {
assert_eq!(spec(key).effect, Effect::Live, "{:?}", key);
}
}
#[test]
fn blank_flags_make_a_noop_patch() {
let patch = Patch::from_flags(&blank_flags());
assert!(patch.is_noop());
assert_eq!(patch.to_wire(), None);
}
#[test]
fn full_flags_set_every_key() {
let patch = Patch::from_flags(&full_flags());
let present: BTreeSet<Key> = patch.iter().map(|(k, _)| k).collect();
let all: BTreeSet<Key> = Key::ALL.iter().copied().collect();
assert_eq!(
present.difference(&all).count() + all.difference(&present).count(),
0,
"missing from the patch: {:?}",
all.difference(&present).collect::<Vec<_>>()
);
}
#[test]
fn every_value_has_the_shape_its_row_declares() {
for (key, requested) in Patch::from_flags(&full_flags()).iter() {
if let Requested::Set(v) = requested {
assert!(spec(key).shape.accepts(v), "{:?} = {}", key, v);
}
}
}
#[test]
fn patch_carries_every_daemon_options_field() {
let f = full_flags();
let p = Patch::from_flags(&f);
let parsed = parse_proxy(f.proxy.as_deref().unwrap());
assert_eq!(set(&p, Key::Headed), json!(true));
assert_eq!(set(&p, Key::Debug), json!(true));
assert_eq!(set(&p, Key::ExecutablePath), json!("/bin/chrome"));
assert_eq!(set(&p, Key::Extensions), json!(f.extensions));
assert_eq!(set(&p, Key::InitScripts), json!(f.init_scripts));
assert_eq!(set(&p, Key::Enable), json!(f.enable));
assert_eq!(
set(&p, Key::Args),
json!(split_launch_args(f.args.as_deref().unwrap()))
);
assert_eq!(set(&p, Key::UserAgent), json!("ua/1"));
assert_eq!(set(&p, Key::Proxy), json!(parsed.server));
assert_eq!(set(&p, Key::ProxyBypass), json!("localhost"));
assert_eq!(set(&p, Key::ProxyUsername), json!("user"));
assert_eq!(set(&p, Key::ProxyPassword), json!("pw"));
assert_eq!(set(&p, Key::IgnoreHttpsErrors), json!(true));
assert_eq!(set(&p, Key::AllowFileAccess), json!(true));
assert_eq!(set(&p, Key::HideScrollbars), json!(false));
assert_eq!(set(&p, Key::Webgpu), json!(true));
assert_eq!(set(&p, Key::Profile), json!("/profile"));
assert_eq!(set(&p, Key::StorageState), json!("/state.json"));
assert_eq!(set(&p, Key::Provider), json!("browserless"));
assert_eq!(set(&p, Key::Device), json!("iPhone 15"));
assert_eq!(set(&p, Key::RestoreKey), json!("work"));
assert_eq!(set(&p, Key::RestoreSave), json!("always"));
assert_eq!(set(&p, Key::RestoreCheckUrl), json!("https://x.test/"));
assert_eq!(set(&p, Key::RestoreCheckText), json!("Welcome"));
assert_eq!(set(&p, Key::RestoreCheckFn), json!("() => true"));
assert_eq!(set(&p, Key::DownloadPath), json!("/downloads"));
assert_eq!(set(&p, Key::Capture), json!(["flow"]));
assert_eq!(set(&p, Key::AllowedDomains), json!(["a.test", "*.b.test"]));
assert_eq!(set(&p, Key::ActionPolicy), json!("/policy.json"));
assert_eq!(set(&p, Key::ConfirmActions), json!("click,fill"));
assert_eq!(set(&p, Key::Engine), json!("chrome"));
assert_eq!(set(&p, Key::AutoConnect), json!(true));
assert_eq!(set(&p, Key::PinTab), json!(true));
assert_eq!(set(&p, Key::IdleTimeout), json!("120000"));
assert_eq!(set(&p, Key::DefaultTimeout), json!(7000));
assert_eq!(set(&p, Key::Cdp), json!("ws://127.0.0.1:9333/devtools"));
assert_eq!(
set(&p, Key::CdpHeaders),
json!(r#"{"Authorization":"Bearer x"}"#)
);
assert_eq!(set(&p, Key::NoAutoDialog), json!(true));
assert_eq!(
set(&p, Key::Plugins),
serde_json::to_value(&f.plugins).unwrap()
);
}
#[test]
fn patch_carries_every_launch_envelope_field() {
let f = full_flags();
let p = Patch::from_flags(&f);
assert_eq!(set(&p, Key::Headed), json!(f.headed));
assert_eq!(set(&p, Key::InitScripts), json!(["/init/a.js"]));
assert_eq!(set(&p, Key::Enable), json!(["react-devtools"]));
assert_eq!(set(&p, Key::Webmcp), json!(false));
assert_eq!(set(&p, Key::AllowedDomains), json!(["a.test", "*.b.test"]));
assert_eq!(set(&p, Key::PinTab), json!(true));
assert_eq!(set(&p, Key::RestoreKey), json!("work"));
assert_eq!(set(&p, Key::IgnoreHttpsErrors), json!(true));
assert_eq!(set(&p, Key::CaCert), json!("/ca.pem"));
assert_eq!(set(&p, Key::ClearCaCert), json!(true));
assert_eq!(set(&p, Key::ColorScheme), json!("dark"));
assert_eq!(set(&p, Key::DownloadPath), json!("/downloads"));
assert_eq!(set(&p, Key::HideScrollbars), json!(false));
assert_eq!(set(&p, Key::Webgpu), json!(true));
assert_eq!(set(&p, Key::NoXvfb), json!(true));
assert_eq!(set(&p, Key::ExecutablePath), json!("/bin/chrome"));
assert_eq!(set(&p, Key::Profile), json!("/profile"));
assert_eq!(set(&p, Key::StorageState), json!("/state.json"));
assert_eq!(set(&p, Key::UserAgent), json!("ua/1"));
assert_eq!(
set(&p, Key::Args),
json!(["--no-first-run", "--window-size=1280,720"])
);
assert_eq!(set(&p, Key::Extensions), json!(["/ext/a", "/ext/b"]));
assert_eq!(set(&p, Key::Proxy), json!("http://proxy.example:8080"));
assert_eq!(set(&p, Key::ProxyUsername), json!("user"));
assert_eq!(set(&p, Key::ProxyPassword), json!("pw"));
assert_eq!(set(&p, Key::ProxyBypass), json!("localhost"));
assert_eq!(set(&p, Key::Engine), json!("chrome"));
assert_eq!(set(&p, Key::Provider), json!("browserless"));
assert_eq!(set(&p, Key::AutoConnect), json!(true));
assert_eq!(set(&p, Key::Cdp), json!("ws://127.0.0.1:9333/devtools"));
}
#[test]
fn proxy_without_credentials_sets_no_credentials() {
let mut f = blank_flags();
f.proxy = Some("socks5://proxy.example:1080".to_string());
let p = Patch::from_flags(&f);
assert_eq!(set(&p, Key::Proxy), json!("socks5://proxy.example:1080"));
assert!(p.get(Key::ProxyUsername).is_none());
assert!(p.get(Key::ProxyPassword).is_none());
}
#[test]
fn obscura_clears_an_unset_proxy_bypass() {
let mut f = blank_flags();
f.engine = Some("obscura".to_string());
assert_eq!(
Patch::from_flags(&f).get(Key::ProxyBypass),
Some(&Requested::Default)
);
f.engine = Some("chrome".to_string());
assert_eq!(Patch::from_flags(&f).get(Key::ProxyBypass), None);
}
#[test]
fn no_auto_connect_resets_the_connection_and_beats_env_and_config() {
let mut f = blank_flags();
f.auto_connect = true;
assert_eq!(set(&Patch::from_flags(&f), Key::AutoConnect), json!(true));
f.no_auto_connect = true;
f.auto_connect = false;
assert_eq!(
Patch::from_flags(&f).get(Key::AutoConnect),
Some(&Requested::Default)
);
f.no_auto_connect = false;
assert_eq!(Patch::from_flags(&f).get(Key::AutoConnect), None);
}
#[test]
fn restore_checks_clear_when_a_restore_key_is_set() {
let mut f = blank_flags();
f.restore = Some("work".to_string());
let p = Patch::from_flags(&f);
assert_eq!(set(&p, Key::RestoreSave), json!("auto"));
assert_eq!(p.get(Key::RestoreCheckUrl), Some(&Requested::Default));
assert_eq!(p.get(Key::RestoreCheckText), Some(&Requested::Default));
assert_eq!(p.get(Key::RestoreCheckFn), Some(&Requested::Default));
f.restore = None;
f.session_name = Some("legacy".to_string());
assert_eq!(
set(&Patch::from_flags(&f), Key::RestoreKey),
json!("legacy")
);
}
#[test]
fn only_explicit_booleans_are_sent() {
let mut f = blank_flags();
assert!(Patch::from_flags(&f).is_noop());
f.hide_scrollbars = false;
assert_eq!(
set(&Patch::from_flags(&f), Key::HideScrollbars),
json!(false)
);
f.hide_scrollbars = true;
f.cli_hide_scrollbars = true;
assert_eq!(
set(&Patch::from_flags(&f), Key::HideScrollbars),
json!(true)
);
let mut g = blank_flags();
g.cli_pin_tab = true;
assert_eq!(set(&Patch::from_flags(&g), Key::PinTab), json!(false));
let mut h = blank_flags();
h.ignore_https_errors = false;
h.allow_file_access = false;
assert!(Patch::from_flags(&h).is_noop());
}
#[test]
fn wire_round_trips_the_full_patch() {
let patch = Patch::from_flags(&full_flags());
let wire = patch.to_wire().unwrap();
assert_eq!(Patch::from_wire(Some(&wire)).unwrap(), patch);
}
#[test]
fn wire_uses_row_names_and_null_for_defaults() {
let mut f = blank_flags();
f.restore = Some("work".to_string());
f.profile = Some("/p".to_string());
let wire = Patch::from_flags(&f).to_wire().unwrap();
assert_eq!(wire["profile"], json!("/p"));
assert_eq!(wire["restore-key"], json!("work"));
assert_eq!(wire["restore-check-url"], Value::Null);
}
#[test]
fn absent_wire_is_a_noop_patch() {
assert!(Patch::from_wire(None).unwrap().is_noop());
assert!(Patch::from_wire(Some(&json!({}))).unwrap().is_noop());
}
#[test]
fn wire_rejects_bad_input() {
assert_eq!(
Patch::from_wire(Some(&json!([1]))),
Err(SettingsError::NotAnObject)
);
assert_eq!(
Patch::from_wire(Some(&json!({"nope": 1}))),
Err(SettingsError::UnknownKey("nope".to_string()))
);
assert!(matches!(
Patch::from_wire(Some(&json!({"headed": "yes"}))),
Err(SettingsError::Invalid(Key::Headed, _))
));
assert!(matches!(
Patch::from_wire(Some(&json!({"args": [1]}))),
Err(SettingsError::Invalid(Key::Args, _))
));
assert!(matches!(
Patch::from_wire(Some(&json!({"default-timeout": -1}))),
Err(SettingsError::Invalid(Key::DefaultTimeout, _))
));
}
fn seeded(pairs: &[(&str, &str)]) -> Running {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Running::from_lookup(|name| map.get(name).cloned())
}
#[test]
fn defaults_view() {
let r = seeded(&[]);
let o = r.launch_options();
assert!(o.headless);
assert!(o.args.is_empty());
assert!(o.extensions.is_none());
assert!(!o.allow_file_access);
assert!(!o.ignore_https_errors);
assert!(o.hide_scrollbars);
assert!(!o.webgpu);
assert!(o.webmcp);
assert!(!o.no_xvfb);
assert!(!o.restrict_webrtc);
assert_eq!(r.connection(), Connection::Local);
assert_eq!(r.default_timeout_ms(), 25_000);
assert_eq!(r.engine(), None);
assert!(!r.pin_tab());
assert!(!r.no_auto_dialog());
assert_eq!(r.security(), SecurityView::default());
assert_eq!(r.restore().save, "auto");
assert_eq!(r.restore().key, None);
assert!(r.enable_features().is_empty());
assert!(r.init_scripts().is_empty());
assert_eq!(r.extensions_file_contents(), None);
}
#[test]
fn launch_options_view_reads_every_launch_variable() {
let r = seeded(&[
("AGENT_BROWSER_HEADED", "true"),
("AGENT_BROWSER_EXECUTABLE_PATH", "/bin/chrome"),
("AGENT_BROWSER_PROXY", "http://proxy:1"),
("AGENT_BROWSER_PROXY_BYPASS", "localhost"),
("AGENT_BROWSER_PROXY_USERNAME", "u"),
("AGENT_BROWSER_PROXY_PASSWORD", "p"),
("AGENT_BROWSER_PROFILE", "/profile"),
("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"),
(
"AGENT_BROWSER_ARGS",
"--disable-features=A,B,--no-first-run\n--window-size=1280,720",
),
("AGENT_BROWSER_EXTENSIONS", " /e/a, /e/b\n/e/c "),
("AGENT_BROWSER_STATE", "/state.json"),
("AGENT_BROWSER_USER_AGENT", "ua"),
("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "true"),
("AGENT_BROWSER_COLOR_SCHEME", "dark"),
("AGENT_BROWSER_DOWNLOAD_PATH", "/dl"),
("AGENT_BROWSER_HIDE_SCROLLBARS", "no"),
("AGENT_BROWSER_WEBGPU", "1"),
("AGENT_BROWSER_NO_WEBMCP", "yes"),
("AGENT_BROWSER_NO_XVFB", "true"),
("AGENT_BROWSER_ALLOWED_DOMAINS", "example.com"),
]);
let o = r.launch_options();
assert!(!o.headless);
assert_eq!(o.executable_path.as_deref(), Some("/bin/chrome"));
assert_eq!(o.proxy.as_deref(), Some("http://proxy:1"));
assert_eq!(o.proxy_bypass.as_deref(), Some("localhost"));
assert_eq!(o.proxy_username.as_deref(), Some("u"));
assert_eq!(o.proxy_password.as_deref(), Some("p"));
assert_eq!(o.profile.as_deref(), Some("/profile"));
assert!(o.allow_file_access);
assert_eq!(
o.args,
vec![
"--disable-features=A,B",
"--no-first-run",
"--window-size=1280,720"
]
);
assert_eq!(
o.extensions,
Some(vec!["/e/a".into(), "/e/b".into(), "/e/c".into()])
);
assert_eq!(o.storage_state.as_deref(), Some("/state.json"));
assert_eq!(o.user_agent.as_deref(), Some("ua"));
assert!(o.ignore_https_errors);
assert_eq!(o.color_scheme.as_deref(), Some("dark"));
assert_eq!(o.download_path.as_deref(), Some("/dl"));
assert!(!o.hide_scrollbars);
assert!(o.webgpu);
assert!(!o.webmcp);
assert!(o.no_xvfb);
assert!(o.restrict_webrtc);
assert_eq!(o.ca_cert, None);
assert_eq!(o.viewport_size, None);
assert!(!o.use_real_keychain);
}
#[test]
fn boolean_spellings_match_each_variable() {
assert!(!seeded(&[("AGENT_BROWSER_HEADED", "yes")]).headed());
assert!(
!seeded(&[("AGENT_BROWSER_WEBGPU", "yes")])
.launch_options()
.webgpu
);
assert!(
seeded(&[("AGENT_BROWSER_WEBGPU", "true")])
.launch_options()
.webgpu
);
assert!(seeded(&[("AGENT_BROWSER_PIN_TAB", "yes")]).pin_tab());
assert!(!seeded(&[("AGENT_BROWSER_PIN_TAB", "on")]).pin_tab());
assert!(seeded(&[("AGENT_BROWSER_NO_AUTO_DIALOG", "true")]).no_auto_dialog());
assert!(seeded(&[("AGENT_BROWSER_NO_WEBMCP", "0")]).webmcp());
for off in ["0", "false", "No", "FALSE", ""] {
assert!(!seeded(&[("AGENT_BROWSER_HIDE_SCROLLBARS", off)]).hide_scrollbars());
}
assert!(seeded(&[("AGENT_BROWSER_HIDE_SCROLLBARS", "1")]).hide_scrollbars());
assert!(seeded(&[("AGENT_BROWSER_HIDE_SCROLLBARS", "anything")]).hide_scrollbars());
}
fn connected(pairs: &[(Key, Value)]) -> Running {
let mut r = seeded(&[]);
for (key, value) in pairs {
r.set(*key, value.clone());
}
r
}
#[test]
fn connection_keys_are_not_seeded_from_the_environment() {
let r = seeded(&[
("AGENT_BROWSER_CDP", "9222"),
("AGENT_BROWSER_CDP_HEADERS", r#"{"A":"b"}"#),
("AGENT_BROWSER_AUTO_CONNECT", "1"),
("AGENT_BROWSER_PROVIDER", "kernel"),
]);
assert_eq!(r.connection(), Connection::Local);
assert_eq!(r.cdp_headers(), None);
assert_eq!(r.provider(), None);
}
#[test]
fn device_is_still_seeded_from_the_environment() {
let r = seeded(&[("AGENT_BROWSER_IOS_DEVICE", "iPhone 15")]);
assert_eq!(
r.get(Key::Device).and_then(Value::as_str),
Some("iPhone 15")
);
}
#[test]
fn connection_view_follows_precedence() {
assert_eq!(
connected(&[(Key::Cdp, json!("9222"))]).connection(),
Connection::Cdp("9222".into())
);
assert_eq!(
connected(&[(Key::Provider, json!("kernel"))]).connection(),
Connection::Provider("kernel".into())
);
assert_eq!(
connected(&[
(Key::Provider, json!("kernel")),
(Key::AutoConnect, json!(true)),
(Key::Cdp, json!("9222")),
])
.connection(),
Connection::Cdp("9222".into())
);
assert_eq!(
connected(&[
(Key::Provider, json!("kernel")),
(Key::AutoConnect, json!(true)),
])
.connection(),
Connection::AutoConnect
);
}
#[test]
fn timeout_views() {
assert_eq!(
seeded(&[("AGENT_BROWSER_DEFAULT_TIMEOUT", "7000")]).default_timeout_ms(),
7000
);
assert_eq!(
seeded(&[("AGENT_BROWSER_DEFAULT_TIMEOUT", "soon")]).default_timeout_ms(),
25_000
);
assert_eq!(
seeded(&[]).idle_timeout(),
Some(IdleTimeout {
ms: DEFAULT_IDLE_TIMEOUT_MS,
is_default: true
})
);
assert_eq!(
seeded(&[("AGENT_BROWSER_IDLE_TIMEOUT_MS", " 5000 ")]).idle_timeout(),
Some(IdleTimeout {
ms: 5000,
is_default: false
})
);
assert_eq!(
seeded(&[("AGENT_BROWSER_IDLE_TIMEOUT_MS", "0")]).idle_timeout(),
None
);
assert_eq!(
seeded(&[("AGENT_BROWSER_IDLE_TIMEOUT_MS", "x")])
.idle_timeout()
.map(|t| t.is_default),
Some(true)
);
}
#[test]
fn security_view() {
let r = seeded(&[
("AGENT_BROWSER_ALLOWED_DOMAINS", " a.test , ,*.b.test "),
("AGENT_BROWSER_CONFIRM_ACTIONS", "click,fill"),
("AGENT_BROWSER_ACTION_POLICY", "/policy.json"),
]);
assert_eq!(
r.security(),
SecurityView {
allowed_domains: Some(vec!["a.test".into(), "*.b.test".into()]),
action_policy_path: Some("/policy.json".into()),
confirm_actions: Some("click,fill".into()),
}
);
}
#[test]
fn blank_allowed_domains_do_not_restrict() {
let r = seeded(&[("AGENT_BROWSER_ALLOWED_DOMAINS", " ")]);
assert_eq!(r.security().allowed_domains, None);
assert!(!r.launch_options().restrict_webrtc);
}
#[test]
fn separator_only_allowed_domains_seed_an_empty_list() {
let r = seeded(&[("AGENT_BROWSER_ALLOWED_DOMAINS", " , ")]);
assert_eq!(r.security().allowed_domains, Some(vec![]));
}
#[test]
fn headed_for_plugin_providers_accepts_any_value_but_off_spellings() {
for on in ["1", "true", "yes", "on", "anything"] {
assert!(
seeded(&[("AGENT_BROWSER_HEADED", on)]).plugin_provider_headed(),
"{on}"
);
}
for off in ["0", "false", "No", ""] {
assert!(
!seeded(&[("AGENT_BROWSER_HEADED", off)]).plugin_provider_headed(),
"{off}"
);
}
assert!(!seeded(&[]).plugin_provider_headed());
assert!(!seeded(&[("AGENT_BROWSER_HEADED", "yes")]).headed());
}
#[test]
fn provider_view_ignores_connection_precedence() {
let r = connected(&[(Key::Provider, json!("kernel")), (Key::Cdp, json!("9222"))]);
assert_eq!(r.provider().as_deref(), Some("kernel"));
assert_eq!(r.connection(), Connection::Cdp("9222".into()));
}
#[test]
fn legacy_policy_variable_applies_only_when_the_current_one_is_unset() {
let legacy = seeded(&[("AGENT_BROWSER_POLICY", "/old.json")]);
assert_eq!(
legacy.security().action_policy_path.as_deref(),
Some("/old.json")
);
let both = seeded(&[
("AGENT_BROWSER_POLICY", "/old.json"),
("AGENT_BROWSER_ACTION_POLICY", "/new.json"),
]);
assert_eq!(
both.security().action_policy_path.as_deref(),
Some("/new.json")
);
}
#[test]
fn restore_view() {
let r = seeded(&[
("AGENT_BROWSER_SESSION_NAME", "work"),
("AGENT_BROWSER_RESTORE_SAVE", "never"),
("AGENT_BROWSER_RESTORE_CHECK_URL", "https://x.test/"),
("AGENT_BROWSER_RESTORE_CHECK_TEXT", "Hi"),
("AGENT_BROWSER_RESTORE_CHECK_FN", "() => 1"),
]);
let v = r.restore();
assert_eq!(v.key.as_deref(), Some("work"));
assert_eq!(v.save, "never");
assert_eq!(v.check_url.as_deref(), Some("https://x.test/"));
assert_eq!(v.check_text.as_deref(), Some("Hi"));
assert_eq!(v.check_fn.as_deref(), Some("() => 1"));
}
#[test]
fn script_lists_split_on_commas_and_newlines() {
let r = seeded(&[
("AGENT_BROWSER_ENABLE", "react-devtools, other\n,"),
("AGENT_BROWSER_INIT_SCRIPTS", " /a.js,/b.js "),
]);
assert_eq!(r.enable_features(), vec!["react-devtools", "other"]);
assert_eq!(r.init_scripts(), vec!["/a.js", "/b.js"]);
}
#[test]
fn extensions_file_keeps_the_trimmed_original_value() {
let r = seeded(&[("AGENT_BROWSER_EXTENSIONS", " /e/a, /e/b ")]);
assert_eq!(r.extensions_file_contents(), Some("/e/a, /e/b"));
assert_eq!(
seeded(&[("AGENT_BROWSER_EXTENSIONS", " ")]).extensions_file_contents(),
None
);
}
#[test]
fn cdp_headers_view_is_the_raw_value() {
let raw = r#"{"Authorization":"Bearer x"}"#;
assert_eq!(
connected(&[(Key::CdpHeaders, json!(raw))])
.cdp_headers()
.as_deref(),
Some(raw)
);
}
#[test]
fn engine_view() {
assert_eq!(
seeded(&[("AGENT_BROWSER_ENGINE", "lightpanda")])
.engine()
.as_deref(),
Some("lightpanda")
);
}
#[test]
fn seeding_from_the_process_environment_reads_the_same_names() {
let guard = EnvGuard::new(&["AGENT_BROWSER_PROFILE"]);
guard.set("AGENT_BROWSER_PROFILE", "/from-env");
assert_eq!(
Running::from_env().launch_options().profile.as_deref(),
Some("/from-env")
);
}
#[test]
fn debug_output_never_contains_secrets() {
let r = seeded(&[
(
"AGENT_BROWSER_PROXY",
"http://user:hunter2@proxy.example:8080",
),
("AGENT_BROWSER_PROXY_PASSWORD", "hunter2"),
("AGENT_BROWSER_PROXY_USERNAME", "user"),
(
"AGENT_BROWSER_CDP_HEADERS",
r#"{"Authorization":"Bearer s3cret"}"#,
),
]);
let out = format!("{:?} {:#?}", r, r);
for secret in ["hunter2", "s3cret"] {
assert!(!out.contains(secret), "{secret} leaked: {out}");
}
assert!(out.contains("proxy.example"));
let mut f = full_flags();
f.proxy = Some("http://user:hunter2@proxy.example:8080".to_string());
f.cdp_headers = Some(r#"{"Authorization":"Bearer s3cret"}"#.to_string());
let patch = format!("{:?}", Patch::from_flags(&f));
for secret in ["hunter2", "s3cret"] {
assert!(!patch.contains(secret), "{secret} leaked: {patch}");
}
assert_eq!(
r.launch_options().proxy_password.as_deref(),
Some("hunter2")
);
}
fn patch_of(entries: &[(Key, Requested)]) -> Patch {
Patch::from_entries(entries.iter().cloned())
}
fn is(key: Key, value: Value) -> (Key, Requested) {
(key, Requested::Set(value))
}
fn running_with(entries: &[(Key, Value)]) -> Running {
let mut r = Running::defaults();
for (key, value) in entries {
r.set(*key, value.clone());
}
r
}
fn changed_keys(plan: &super::plan::Plan) -> Vec<Key> {
plan.changes.iter().map(|c| c.key).collect()
}
#[test]
fn headers_sent_with_a_provider_or_auto_connect_are_free_on_the_next_command() {
for target in [
is(Key::Provider, json!("browserbox")),
is(Key::AutoConnect, json!(true)),
] {
let patch = patch_of(&[target.clone(), is(Key::CdpHeaders, json!(r#"{"A":"b"}"#))]);
let first = connected(&[]).plan(&patch);
assert!(first.relaunch, "{:?}", target.0);
let second = first.next.plan(&patch);
assert!(
second.changes.is_empty() && !second.relaunch,
"{:?}: {:?}",
target.0,
second.changes.iter().map(|c| c.key).collect::<Vec<_>>()
);
}
}
#[test]
fn plan_table_running_by_patch() {
struct Case {
name: &'static str,
running: Vec<(Key, Value)>,
patch: Vec<(Key, Requested)>,
changed: Vec<Key>,
relaunch: bool,
}
let cases = vec![
Case {
name: "an empty patch changes nothing",
running: vec![(Key::Profile, json!("/a"))],
patch: vec![],
changed: vec![],
relaunch: false,
},
Case {
name: "a key the patch does not name is left alone",
running: vec![(Key::Profile, json!("/a")), (Key::Headed, json!(true))],
patch: vec![is(Key::Args, json!(["--x"]))],
changed: vec![Key::Args],
relaunch: true,
},
Case {
name: "equal value is free",
running: vec![(Key::Profile, json!("/a"))],
patch: vec![is(Key::Profile, json!("/a"))],
changed: vec![],
relaunch: false,
},
Case {
name: "a different launch value relaunches",
running: vec![(Key::Profile, json!("/a"))],
patch: vec![is(Key::Profile, json!("/b"))],
changed: vec![Key::Profile],
relaunch: true,
},
Case {
name: "a live value applies without a relaunch",
running: vec![(Key::DefaultTimeout, json!(5000))],
patch: vec![is(Key::DefaultTimeout, json!(9000))],
changed: vec![Key::DefaultTimeout],
relaunch: false,
},
Case {
name: "the default is equal to nothing running",
running: vec![],
patch: vec![
is(Key::Headed, json!(false)),
is(Key::HideScrollbars, json!(true)),
is(Key::Webmcp, json!(true)),
is(Key::RestoreSave, json!("auto")),
],
changed: vec![],
relaunch: false,
},
Case {
name: "turning a default-true setting off is a change",
running: vec![],
patch: vec![is(Key::HideScrollbars, json!(false))],
changed: vec![Key::HideScrollbars],
relaunch: true,
},
Case {
name: "an explicit reset of a set value is a change",
running: vec![(Key::ProxyBypass, json!("localhost"))],
patch: vec![(Key::ProxyBypass, Requested::Default)],
changed: vec![Key::ProxyBypass],
relaunch: true,
},
Case {
name: "an auto-connect reset of an attached session relaunches locally",
running: vec![(Key::AutoConnect, json!(true))],
patch: vec![(Key::AutoConnect, Requested::Default)],
changed: vec![Key::AutoConnect],
relaunch: true,
},
Case {
name: "a reset of a key another target already displaced is reported once",
running: vec![(Key::AutoConnect, json!(true))],
patch: vec![
is(Key::Cdp, json!("9447")),
(Key::AutoConnect, Requested::Default),
],
changed: vec![Key::Cdp, Key::AutoConnect],
relaunch: true,
},
Case {
name: "an auto-connect reset of a local session is free",
running: vec![],
patch: vec![(Key::AutoConnect, Requested::Default)],
changed: vec![],
relaunch: false,
},
Case {
name: "an explicit reset of an unset value is free",
running: vec![],
patch: vec![(Key::ProxyBypass, Requested::Default)],
changed: vec![],
relaunch: false,
},
Case {
name: "live and launch changes together relaunch once",
running: vec![
(Key::Profile, json!("/a")),
(Key::NoAutoDialog, json!(false)),
],
patch: vec![
is(Key::Profile, json!("/b")),
is(Key::NoAutoDialog, json!(true)),
],
changed: vec![Key::Profile, Key::NoAutoDialog],
relaunch: true,
},
Case {
name: "security settings follow their effect class",
running: vec![],
patch: vec![
is(Key::AllowedDomains, json!(["a.test"])),
is(Key::ActionPolicy, json!("/policy.json")),
is(Key::ConfirmActions, json!("click")),
],
changed: vec![Key::AllowedDomains, Key::ActionPolicy, Key::ConfirmActions],
relaunch: true,
},
Case {
name: "only live changes leave the browser alone",
running: vec![],
patch: vec![
is(Key::ActionPolicy, json!("/policy.json")),
is(Key::ConfirmActions, json!("click")),
is(Key::IdleTimeout, json!("60000")),
is(Key::PinTab, json!(true)),
],
changed: vec![
Key::ActionPolicy,
Key::ConfirmActions,
Key::IdleTimeout,
Key::PinTab,
],
relaunch: false,
},
];
for case in cases {
let plan = running_with(&case.running).plan(&patch_of(&case.patch));
assert_eq!(changed_keys(&plan), case.changed, "{}", case.name);
assert_eq!(plan.relaunch, case.relaunch, "{}", case.name);
assert_eq!(plan.is_empty(), case.changed.is_empty() && !case.relaunch);
}
}
#[test]
fn plan_is_pure_and_next_holds_the_requested_values() {
let running = running_with(&[(Key::Profile, json!("/a")), (Key::Args, json!(["--x"]))]);
let before = running.clone();
let plan = running.plan(&patch_of(&[
is(Key::Profile, json!("/b")),
(Key::Args, Requested::Default),
]));
assert_eq!(
running, before,
"planning must not touch the running values"
);
assert_eq!(plan.next.get(Key::Profile), Some(&json!("/b")));
assert_eq!(plan.next.get(Key::Args), None);
assert_eq!(
plan.changes[0],
super::plan::Change {
key: Key::Profile,
requested: Requested::Set(json!("/b")),
running: Some(json!("/a")),
}
);
}
#[test]
fn a_forced_relaunch_needs_no_change() {
let mut flags = blank_flags();
flags.relaunch = true;
let patch = Patch::from_flags(&flags);
assert!(!patch.is_noop());
let plan = running_with(&[(Key::Profile, json!("/a"))]).plan(&patch);
assert!(plan.changes.is_empty());
assert!(plan.relaunch);
assert!(!plan.is_empty());
let wire = patch.to_wire().unwrap();
assert_eq!(wire, json!({"relaunch": true}));
assert_eq!(Patch::from_wire(Some(&wire)).unwrap(), patch);
}
#[test]
fn a_connection_target_displaces_the_others() {
let running = running_with(&[
(Key::Cdp, json!("9222")),
(Key::CdpHeaders, json!(r#"{"Authorization":"x"}"#)),
]);
let plan = running.plan(&patch_of(&[is(Key::Provider, json!("browserless"))]));
assert_eq!(
changed_keys(&plan),
vec![Key::Provider, Key::Cdp, Key::CdpHeaders]
);
assert!(plan.relaunch);
assert_eq!(
plan.next.connection(),
Connection::Provider("browserless".to_string())
);
assert_eq!(plan.next.get(Key::CdpHeaders), None);
}
#[test]
fn no_ca_cert_drops_the_trust_in_effect() {
let running = running_with(&[(Key::CaCert, json!("/ca.pem"))]);
let plan = running.plan(&patch_of(&[is(Key::ClearCaCert, json!(true))]));
assert_eq!(changed_keys(&plan), vec![Key::ClearCaCert]);
assert!(plan.relaunch);
assert_eq!(plan.next.get(Key::CaCert), None);
assert_eq!(
plan.next.get(Key::ClearCaCert),
None,
"a one-shot is not kept"
);
let none_in_effect = Running::defaults().plan(&patch_of(&[is(Key::ClearCaCert, json!(true))]));
assert!(none_in_effect.is_empty());
}
#[test]
fn planning_keeps_the_seed_shims_in_step() {
let plan = Running::defaults().plan(&patch_of(&[
is(Key::Extensions, json!(["/a", "/b"])),
is(Key::Headed, json!(true)),
]));
assert_eq!(plan.next.extensions_file_contents(), Some("/a,/b"));
assert!(plan.next.plugin_provider_headed());
}
#[test]
fn changes_report_redacted_values() {
let running = running_with(&[(Key::ProxyPassword, json!("old-secret"))]);
let plan = running.plan(&patch_of(&[
is(Key::ProxyPassword, json!("new-secret")),
is(Key::CdpHeaders, json!(r#"{"Authorization":"Bearer t"}"#)),
is(Key::Proxy, json!("http://user:pw@proxy.example:8080")),
]));
let reported: Vec<Value> = plan.changes.iter().map(|c| c.to_json(true)).collect();
let text = serde_json::to_string(&reported).unwrap();
for secret in ["old-secret", "new-secret", "Bearer t", "user:pw"] {
assert!(!text.contains(secret), "{secret} leaked: {text}");
}
let password = reported
.iter()
.find(|c| c["option"] == "proxy-password")
.unwrap();
assert_eq!(password["requested"], json!("<redacted>"));
assert_eq!(password["running"], json!("<redacted>"));
assert_eq!(password["effect"], json!("relaunch"));
assert_eq!(password["applied"], json!(true));
}
#[test]
fn change_json_names_the_class_and_whether_it_took_effect() {
let plan = Running::defaults().plan(&patch_of(&[
is(Key::Profile, json!("/b")),
is(Key::DefaultTimeout, json!(9000)),
]));
let launch = plan.changes[0].to_json(false);
assert_eq!(launch["option"], json!("profile"));
assert_eq!(launch["requested"], json!("/b"));
assert_eq!(launch["running"], Value::Null);
assert_eq!(launch["effect"], json!("relaunch"));
assert_eq!(launch["applied"], json!(false));
let live = plan.changes[1].to_json(true);
assert_eq!(live["effect"], json!("live"));
}
#[test]
fn launch_identity_follows_launch_settings_only() {
let base = running_with(&[(Key::Profile, json!("/a"))]);
let live_change = base
.plan(&patch_of(&[is(Key::DefaultTimeout, json!(9000))]))
.next;
let launch_change = base.plan(&patch_of(&[is(Key::Profile, json!("/b"))])).next;
assert_eq!(base.launch_identity(), live_change.launch_identity());
assert_ne!(base.launch_identity(), launch_change.launch_identity());
}
#[test]
fn a_session_started_from_the_same_settings_plans_nothing() {
let mut flags = blank_flags();
flags.profile = Some("/work/profile".to_string());
flags.args = Some("--lang=en-US,--no-first-run".to_string());
flags.headed = true;
flags.cli_headed = true;
flags.default_timeout = Some(7000);
flags.idle_timeout = Some("120000".to_string());
let started = seeded(&[
("AGENT_BROWSER_PROFILE", "/work/profile"),
("AGENT_BROWSER_ARGS", "--lang=en-US,--no-first-run"),
("AGENT_BROWSER_HEADED", "1"),
("AGENT_BROWSER_DEFAULT_TIMEOUT", "7000"),
("AGENT_BROWSER_IDLE_TIMEOUT_MS", "120000"),
]);
assert!(started.plan(&Patch::from_flags(&flags)).is_empty());
}
#[test]
fn a_value_marked_changed_in_place_is_a_change_even_when_equal() {
let running = running_with(&[(Key::CaCert, json!("/ca.pem"))]);
let mut patch = patch_of(&[is(Key::CaCert, json!("/ca.pem"))]);
assert!(running.plan(&patch).is_empty());
patch.mark_changed_in_place(Key::CaCert);
let plan = running.plan(&patch);
assert_eq!(changed_keys(&plan), vec![Key::CaCert]);
assert!(plan.relaunch);
}
#[test]
fn cdp_headers_do_not_follow_the_session_to_another_endpoint() {
let running = running_with(&[
(Key::Cdp, json!("wss://a.example/cdp")),
(Key::CdpHeaders, json!(r#"{"Authorization":"Bearer a"}"#)),
]);
let moved = running.plan(&patch_of(&[is(Key::Cdp, json!("wss://b.example/cdp"))]));
assert_eq!(changed_keys(&moved), vec![Key::Cdp, Key::CdpHeaders]);
assert_eq!(moved.next.cdp_headers(), None);
let same = running.plan(&patch_of(&[is(Key::Cdp, json!("wss://a.example/cdp"))]));
assert!(same.is_empty());
assert!(same.next.cdp_headers().is_some());
let with_headers = running.plan(&patch_of(&[
is(Key::Cdp, json!("wss://b.example/cdp")),
is(Key::CdpHeaders, json!(r#"{"Authorization":"Bearer b"}"#)),
]));
assert_eq!(
with_headers.next.cdp_headers().as_deref(),
Some(r#"{"Authorization":"Bearer b"}"#)
);
}
#[test]
fn cdp_urls_are_reported_without_userinfo_or_query() {
let url = "wss://user:pw@host.example:9222/devtools/browser/abc?token=SECRET#frag";
let plan = Running::defaults().plan(&patch_of(&[is(Key::Cdp, json!(url))]));
let reported = plan.changes[0].to_json(true);
assert_eq!(
reported["requested"],
json!("wss://host.example:9222/devtools/browser/abc?<redacted>")
);
for leaked in ["SECRET", "user:pw", "frag"] {
assert!(!reported.to_string().contains(leaked), "{reported}");
}
assert!(!format!("{:?}", plan.next).contains("SECRET"));
assert!(!format!("{:?}", patch_of(&[is(Key::Cdp, json!(url))])).contains("SECRET"));
assert_eq!(super::spec::redact_url("9222"), "9222");
assert_eq!(
super::spec::redact_url("http://127.0.0.1:9222/json/version"),
"http://127.0.0.1:9222/json/version"
);
}
#[test]
fn storage_state_displaces_an_inherited_profile_and_extensions() {
let running = running_with(&[
(Key::Profile, json!("/p")),
(Key::Extensions, json!(["/e"])),
]);
let plan = running.plan(&patch_of(&[is(Key::StorageState, json!("/s.json"))]));
assert_eq!(
changed_keys(&plan),
vec![Key::StorageState, Key::Profile, Key::Extensions]
);
assert_eq!(plan.next.get(Key::Profile), None);
assert_eq!(plan.next.get(Key::Extensions), None);
assert!(plan.relaunch);
let back = plan.next.plan(&patch_of(&[is(Key::Profile, json!("/p"))]));
assert_eq!(back.next.get(Key::StorageState), None);
}
#[test]
fn a_conflict_inside_one_patch_is_left_for_the_launch_to_reject() {
let plan = Running::defaults().plan(&patch_of(&[
is(Key::Profile, json!("/p")),
is(Key::StorageState, json!("/s.json")),
]));
assert_eq!(plan.next.get(Key::Profile), Some(&json!("/p")));
assert_eq!(plan.next.get(Key::StorageState), Some(&json!("/s.json")));
}
#[test]
fn security_settings_cannot_be_cleared_with_a_null() {
for name in ["allowed-domains", "action-policy", "confirm-actions"] {
let wire = json!({ name: null });
assert!(
matches!(
Patch::from_wire(Some(&wire)),
Err(SettingsError::Invalid(_, _))
),
"{name}"
);
}
assert!(Patch::from_wire(Some(&json!({"proxy-bypass": null}))).is_ok());
}
#[test]
fn a_patch_without_launch_settings_keeps_only_live_ones() {
let patch = patch_of(&[
is(Key::Profile, json!("/p")),
is(Key::DefaultTimeout, json!(5000)),
]);
let live = patch.without_launch_settings();
assert!(live.get(Key::Profile).is_none());
assert!(live.get(Key::DefaultTimeout).is_some());
}
#[test]
fn local_only_settings_are_ignored_for_external_sessions() {
for target in [
(Key::Cdp, json!("ws://127.0.0.1:9999/devtools/browser/x")),
(Key::Provider, json!("browserbase")),
(Key::AutoConnect, json!(true)),
] {
let running = running_with(&[target]);
let plan = running.plan(&patch_of(&[
is(Key::Args, json!(["--x"])),
is(Key::Profile, json!("/p")),
is(Key::ExecutablePath, json!("/chrome")),
is(Key::Extensions, json!(["/e"])),
is(Key::Proxy, json!("http://proxy:1")),
is(Key::UserAgent, json!("ua")),
is(Key::IgnoreHttpsErrors, json!(true)),
]));
assert_eq!(changed_keys(&plan), vec![Key::IgnoreHttpsErrors]);
let ignored: Vec<Key> = plan.ignored.iter().map(|c| c.key).collect();
assert_eq!(
ignored,
vec![
Key::Profile,
Key::Args,
Key::ExecutablePath,
Key::Extensions,
Key::Proxy,
Key::UserAgent
]
);
assert_eq!(plan.next.get(Key::Args), None);
assert!(plan.ignored[0].to_ignored_json()["reason"].is_string());
let only_local = running.plan(&patch_of(&[is(Key::Args, json!(["--x"]))]));
assert!(
!only_local.relaunch,
"env args never start a new cloud session"
);
assert!(only_local.is_empty());
}
}
#[test]
fn local_only_settings_apply_to_a_local_session() {
let plan = Running::defaults().plan(&patch_of(&[is(Key::Args, json!(["--x"]))]));
assert!(plan.relaunch && plan.ignored.is_empty());
let plan = Running::defaults().plan(&patch_of(&[
is(Key::Provider, json!("browserbase")),
is(Key::Args, json!(["--x"])),
]));
assert_eq!(plan.ignored.len(), 1);
}
#[test]
fn no_xvfb_can_be_turned_off() {
let mut flags = blank_flags();
flags.no_xvfb_set = true;
flags.no_xvfb = false;
let patch = Patch::from_flags(&flags);
assert_eq!(patch.get(Key::NoXvfb), Some(&Requested::Set(json!(false))));
let running = running_with(&[(Key::NoXvfb, json!(true))]);
assert_eq!(changed_keys(&running.plan(&patch)), vec![Key::NoXvfb]);
assert!(Patch::from_flags(&blank_flags()).get(Key::NoXvfb).is_none());
}
#[test]
fn inherited_local_settings_are_dropped_when_the_session_goes_external() {
let local = [
(Key::StorageState, json!("/s.json")),
(Key::Profile, json!("/p")),
(Key::Extensions, json!(["/e"])),
(Key::Proxy, json!("http://proxy:1")),
(Key::ProxyUsername, json!("user")),
(Key::ProxyPassword, json!("pw")),
(Key::ExecutablePath, json!("/chrome")),
(Key::Args, json!(["--x"])),
];
for target in [
(Key::Cdp, json!("ws://127.0.0.1:9999/devtools/browser/x")),
(Key::Provider, json!("browserbase")),
(Key::AutoConnect, json!(true)),
] {
let running = running_with(&local);
let plan = running.plan(&patch_of(&[is(target.0, target.1.clone())]));
for (key, _) in &local {
assert_eq!(plan.next.get(*key), None, "{key:?} survives {:?}", target.0);
}
let dropped: Vec<Key> = plan.ignored.iter().map(|c| c.key).collect();
assert!(dropped.contains(&Key::StorageState) && dropped.contains(&Key::ProxyPassword));
assert_eq!(changed_keys(&plan), vec![target.0]);
}
}
#[test]
fn local_settings_are_kept_while_the_session_stays_local() {
let running = running_with(&[
(Key::StorageState, json!("/s.json")),
(Key::ProxyUsername, json!("user")),
]);
let plan = running.plan(&patch_of(&[is(Key::DefaultTimeout, json!(5000))]));
assert_eq!(plan.next.get(Key::StorageState), Some(&json!("/s.json")));
assert_eq!(plan.next.get(Key::ProxyUsername), Some(&json!("user")));
assert!(plan.ignored.is_empty());
}
#[test]
fn an_empty_provider_resets_the_session_to_local() {
let running = running_with(&[(Key::Cdp, json!("9222"))]);
let plan = running.plan(&patch_of(&[is(Key::Provider, json!(""))]));
assert_eq!(plan.next.connection(), Connection::Local);
assert!(plan.relaunch);
}