use anyhow::{anyhow, Result};
use base64::Engine as _;
use rustenium::browsers::{
firefox, BidiBrowser, EvaluateScriptOptionsBuilder, FirefoxBrowser, FirefoxCapabilities,
FirefoxConfig, FirefoxLaunchMode,
};
use rustenium::input::{
Mouse, MouseButton, MouseClickOptions, MouseMoveOptions, MouseOptions, MouseWheelOptions, Point,
};
use rustenium::nodes::Node;
use rustenium_bidi_definitions::browsing_context::commands::HandleUserPrompt;
use rustenium_bidi_definitions::browsing_context::types::{CssLocator, CssLocatorType, Locator};
use rustenium_bidi_definitions::network::types::{BytesValue, SameSite, StringValue, StringValueType};
use rustenium_bidi_definitions::input::commands::SetFiles;
use rustenium_bidi_definitions::script::types::{ContextTarget, RemoteValue, SharedReference, Target};
use rustenium_bidi_definitions::session::types::{UnhandledPromptBehavior, UserPromptHandlerType};
use rustenium_bidi_definitions::storage::commands::{GetCookies, SetCookie, SetCookieParams};
use rustenium_bidi_definitions::storage::types::PartialCookie;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
pub struct Page {
browser: tokio::sync::Mutex<Option<FoxBrowser>>,
profile_dir: Option<String>,
child: std::sync::Mutex<Option<std::process::Child>>,
}
impl Drop for Page {
fn drop(&mut self) {
if let Ok(mut guard) = self.browser.try_lock() {
let _ = guard.take();
}
if let Ok(mut child) = self.child.try_lock() {
if let Some(mut c) = child.take() {
let _ = c.kill();
}
}
}
}
pub type FrameId = rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct FrameInfo {
pub id: String,
pub url: String,
pub name: String,
pub is_main: bool,
}
#[derive(Debug, Clone, PartialEq)]
enum FrameSpec {
Main,
Index(usize),
IdOrIndex(String, usize),
Id(String),
UrlContains(String),
NameEquals(String),
}
impl FrameSpec {
fn parse(spec: &str) -> Self {
let s = spec.trim();
if s.is_empty() || s.eq_ignore_ascii_case("main") || s.eq_ignore_ascii_case("top") {
return FrameSpec::Main;
}
if let Some(rest) = s.strip_prefix("url:") {
return FrameSpec::UrlContains(rest.trim().to_string());
}
if let Some(rest) = s.strip_prefix("name:") {
return FrameSpec::NameEquals(rest.trim().to_string());
}
if let Some(rest) = s.strip_prefix("index:") {
if let Ok(n) = rest.trim().parse::<usize>() {
return FrameSpec::Index(n);
}
}
if let Ok(n) = s.parse::<usize>() {
return FrameSpec::IdOrIndex(s.to_string(), n);
}
FrameSpec::Id(s.to_string())
}
}
#[derive(Debug, Clone)]
pub struct EvaluationResult {
inner: RemoteValue,
}
impl EvaluationResult {
pub fn new(inner: RemoteValue) -> Self {
Self { inner }
}
pub fn into_value<T: DeserializeOwned>(self) -> serde_json::Result<T> {
let json = remote_value_to_json(&self.inner);
serde_json::from_value(json)
}
pub fn remote_value(&self) -> &RemoteValue {
&self.inner
}
}
fn bidi_wire_value_to_json(v: &serde_json::Value) -> serde_json::Value {
match v.get("type").and_then(|t| t.as_str()) {
Some("string") => v
.get("value")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null),
Some("number") => v.get("value").cloned().unwrap_or(serde_json::Value::Null),
Some("boolean") => v
.get("value")
.and_then(|v| v.as_bool())
.map(serde_json::Value::Bool)
.unwrap_or(serde_json::Value::Null),
Some("null") | Some("undefined") => serde_json::Value::Null,
Some("bigint") => v
.get("value")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null),
Some("object") => {
let mut map = serde_json::Map::new();
if let Some(serde_json::Value::Array(pairs)) = v.get("value") {
for pair in pairs {
if let Some(serde_json::Value::Array(items)) = Some(pair) {
if items.len() >= 2 {
if let (Some(k), Some(val)) =
(items[0].as_str(), items.get(1))
{
map.insert(k.to_string(), bidi_wire_value_to_json(val));
}
}
}
}
}
serde_json::Value::Object(map)
}
Some("array") => {
let arr: Vec<serde_json::Value> = v
.get("value")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(bidi_wire_value_to_json).collect())
.unwrap_or_default();
serde_json::Value::Array(arr)
}
_ => v.clone(),
}
}
fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
match rv {
RemoteValue::PrimitiveProtocolValue(p) => match p {
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
serde_json::Value::String(s.value.clone())
}
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
match &n.value {
serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
_ => serde_json::Value::Null,
}
}
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
serde_json::Value::Bool(b.value)
}
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
serde_json::Value::Null
}
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(_) => {
serde_json::Value::Null
}
rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
serde_json::Value::String(b.value.clone())
}
},
RemoteValue::ArrayRemoteValue(a) => {
let arr: Vec<serde_json::Value> = a
.value
.as_ref()
.map(|v| v.inner().iter().map(remote_value_to_json).collect())
.unwrap_or_default();
serde_json::Value::Array(arr)
}
RemoteValue::ObjectRemoteValue(o) => {
let mut map = serde_json::Map::new();
if let Some(ref mapping) = o.value {
for pair in mapping.inner() {
if pair.len() >= 2 {
if let (Some(serde_json::Value::String(k)), Some(v)) =
(pair.first(), pair.get(1))
{
map.insert(k.clone(), bidi_wire_value_to_json(v));
}
}
}
}
serde_json::Value::Object(map)
}
_ => serde_json::Value::Null,
}
}
pub struct Element {
pub(crate) node: tokio::sync::Mutex<FoxNode>,
pub(crate) selector: String,
}
impl Element {
pub async fn click(&self) -> Result<()> {
let mut node = self.node.lock().await;
node.mouse_click()
.await
.map_err(|e| anyhow!("element click failed: {e:?}"))?;
Ok(())
}
pub fn selector(&self) -> &str {
&self.selector
}
pub async fn type_text(&self, text: &str) -> Result<()> {
let mut node = self.node.lock().await;
node.type_text(text.to_string())
.await
.map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
Ok(())
}
pub async fn type_str(&self, text: &str) -> Result<()> {
self.type_text(text).await
}
}
type FoxBrowser = FirefoxBrowser;
type FoxNode = rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Up,
Down,
}
impl Page {
pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
launch_firefox(config.unwrap_or_default()).await
}
pub async fn goto(&self, url: &str) -> Result<()> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
browser
.navigate(url)
.await
.map_err(|e| anyhow!("navigate failed: {e:?}"))?;
Ok(())
}
pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let result = browser
.evaluate_script(expr.into(), false)
.await
.map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
Ok(EvaluationResult::new(result.result))
}
pub async fn evaluate_in_context(
&self,
expr: impl Into<String>,
context: &FrameId,
) -> Result<EvaluationResult> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let options = EvaluateScriptOptionsBuilder::default()
.target(Target::ContextTarget(ContextTarget::new(context.clone())))
.build();
let result = browser
.evaluate_script_with_options(expr.into(), false, options)
.await
.map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
Ok(EvaluationResult::new(result.result))
}
pub async fn find_element(&self, selector: &str) -> Result<Element> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let locator = Locator::CssLocator(CssLocator::new(
CssLocatorType::Css,
selector.to_string(),
));
match browser.find_node(locator).await {
Ok(Some(node)) => {
Ok(Element {
node: tokio::sync::Mutex::new(node),
selector: selector.to_string(),
})
}
Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
Err(e) => Err(anyhow!("find_element failed: {e:?}")),
}
}
pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let locator = Locator::CssLocator(CssLocator::new(
CssLocatorType::Css,
selector.to_string(),
));
let nodes = browser
.find_nodes(locator)
.await
.map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
Ok(nodes
.into_iter()
.map(|n| Element {
node: tokio::sync::Mutex::new(n),
selector: selector.to_string(),
})
.collect())
}
pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
if files.is_empty() {
return Err(anyhow!("set_files: no files provided"));
}
let element = self.find_element(selector).await?;
let (shared_id, context) = {
let node = element.node.lock().await;
let id = node.get_shared_id().cloned().ok_or_else(|| {
anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
})?;
(id, node.get_context_id().clone())
};
let element_ref: SharedReference = SharedReference::builder()
.shared_id(shared_id)
.build()
.map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
let command = SetFiles::builder()
.context(context)
.element(element_ref)
.files(files)
.build()
.map_err(|e| anyhow!("set_files: build command: {e}"))?;
let mut browser = self.browser.lock().await;
let browser = match &mut *browser {
Some(b) => b,
None => return Err(anyhow!("browser closed")),
};
let response = browser
.driver_mut()
.send_command(command)
.await
.map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
.result
.try_into()
.map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
Ok(())
}
pub async fn screenshot(&self) -> Result<Vec<u8>> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let b64 = browser
.screenshot()
.await
.map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| anyhow!("base64 decode failed: {e}"))
}
pub async fn reload(&self) -> Result<()> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
browser
.evaluate_script("location.reload()".to_string(), false)
.await
.map_err(|e| anyhow!("reload failed: {e:?}"))?;
Ok(())
}
pub async fn url(&self) -> Result<String> {
let eval = self.evaluate("document.URL").await?;
eval.into_value::<String>()
.map_err(|e| anyhow!("url deserialize failed: {e}"))
}
pub async fn title(&self) -> Result<String> {
let eval = self.evaluate("document.title").await?;
eval.into_value::<String>()
.map_err(|e| anyhow!("title deserialize failed: {e}"))
}
pub async fn frames(&self) -> Result<Vec<FrameId>> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let contexts = browser
.driver()
.browsing_contexts
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|c| c.id().clone())
.collect();
Ok(contexts)
}
pub async fn mainframe(&self) -> Result<Option<FrameId>> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
match browser.driver().get_active_context_id() {
Ok(ctx) => Ok(Some(ctx)),
Err(e) => {
tracing::debug!("get_active_context_id failed: {e:?}");
Ok(None)
}
}
}
pub async fn frame_execution_context(
&self,
frame_id: FrameId,
) -> Result<Option<FrameId>> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let exists = browser
.driver()
.browsing_contexts
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.any(|c| c.id() == &frame_id);
Ok(if exists { Some(frame_id) } else { None })
}
pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
let frame_ids = self.frames().await?;
let main = self.mainframe().await?;
let mut out = Vec::with_capacity(frame_ids.len());
for fid in frame_ids {
let (url, name) = match self
.evaluate_in_context(
"({u: document.URL, n: (window.name || \"\")})",
&fid,
)
.await
{
Ok(eval) => match eval.into_value::<serde_json::Value>() {
Ok(v) => (
v["u"].as_str().unwrap_or("").to_string(),
v["n"].as_str().unwrap_or("").to_string(),
),
Err(_) => (String::new(), String::new()),
},
Err(e) => {
tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
(String::new(), String::new())
}
};
out.push(FrameInfo {
is_main: Some(&fid) == main.as_ref(),
id: fid.inner().to_string(),
url,
name,
});
}
Ok(out)
}
pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
.await
}
pub async fn resolve_frame_within(
&self,
spec: &str,
timeout: std::time::Duration,
) -> Result<FrameId> {
let parsed = FrameSpec::parse(spec);
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(fid) = self.try_resolve_frame(&parsed).await? {
return Ok(fid);
}
if std::time::Instant::now() >= deadline {
return Err(anyhow!(
"resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
));
}
tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
}
}
async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
if matches!(parsed, FrameSpec::Main) {
return Ok(self.mainframe().await?);
}
let frames = self.frames().await?;
match parsed {
FrameSpec::Main => unreachable!(),
FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
FrameSpec::IdOrIndex(id, idx) => {
if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
return Ok(Some(fid.clone()));
}
Ok(frames.get(*idx).cloned())
}
FrameSpec::Id(id) => {
if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
return Ok(Some(fid.clone()));
}
self.frame_by_url_contains(id).await
}
FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
FrameSpec::NameEquals(name) => {
for info in self.list_frames().await? {
if &info.name == name {
return Ok(Some(FrameId::new(info.id)));
}
}
Ok(None)
}
}
}
async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
for info in self.list_frames().await? {
if info.url.contains(sub) {
return Ok(Some(FrameId::new(info.id)));
}
}
Ok(None)
}
pub async fn eval_in_frame(
&self,
spec: &str,
expr: impl Into<String>,
) -> Result<EvaluationResult> {
let fid = self.resolve_frame(spec).await?;
self.evaluate_in_context(expr, &fid).await
}
pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
let fid = self.resolve_frame(spec).await?;
let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
let js = format!(
r#"(function() {{
const el = document.querySelector('{escaped}');
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
}})()"#
);
let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
loop {
if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
if let Ok(val) = eval.into_value::<serde_json::Value>() {
if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
return self.click_at_in(&fid, x, y).await;
}
}
}
if std::time::Instant::now() >= deadline {
return Err(anyhow!(
"click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
));
}
tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
}
}
pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
let fid = self.resolve_frame(spec).await?;
let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
let focus_js = format!(
r#"(function() {{
const el = document.querySelector('{escaped}');
if (!el) return false;
el.focus();
return document.activeElement === el;
}})()"#
);
let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
loop {
let focused = self
.evaluate_in_context(&focus_js, &fid)
.await
.ok()
.and_then(|e| e.into_value::<bool>().ok())
.unwrap_or(false);
if focused {
break;
}
if std::time::Instant::now() >= deadline {
return Err(anyhow!(
"type_in_frame: could not focus '{selector}' in frame '{spec}'"
));
}
tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
}
let browser = self.browser.lock().await;
let browser = match &*browser {
Some(b) => b,
None => return Err(anyhow!("browser closed")),
};
browser
.keyboard()
.type_text(text, &fid, None)
.await
.map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
Ok(())
}
pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser {
Some(b) => b,
None => return Err(anyhow!("browser closed")),
};
let log = crate::dialog::DialogLog::new();
let handler = crate::dialog::make_dialog_handler(log.clone());
let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
browser
.subscribe_events(events, handler)
.await
.map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
Ok(log)
}
pub async fn start_sensors(&self) -> Result<String> {
let id = self.add_preload_script(crate::sensors::SENSOR_SCRIPT).await?;
let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
Ok(id)
}
pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
eval.into_value::<serde_json::Value>()
.map_err(|e| anyhow!("read_signals: decode failed: {e}"))
}
pub async fn handle_user_prompt(
&self,
context: Option<&FrameId>,
accept: bool,
user_text: Option<&str>,
) -> Result<()> {
let ctx = match context {
Some(c) => c.clone(),
None => self
.mainframe()
.await?
.ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
};
let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
if let Some(text) = user_text {
builder = builder.user_text(text.to_string());
}
let command = builder
.build()
.map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
let mut browser = self.browser.lock().await;
let browser = match &mut *browser {
Some(b) => b,
None => return Err(anyhow!("browser closed")),
};
let response = browser
.driver_mut()
.send_command(command)
.await
.map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
response
.result
.try_into()
.map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
Ok(())
}
pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
let hm = browser.human_mouse();
hm.set_last_position(Point { x: x0, y: y0 });
hm.move_to(Point { x: x1, y: y1 }, &context, MouseMoveOptions::default())
.await
.map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
Ok(())
}
pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
let hm = browser.human_mouse();
hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
.await
.map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
hm.down(&context, MouseOptions {
button: Some(MouseButton::Left),
})
.await
.map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
Ok(())
}
pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
let hm = browser.human_mouse();
hm.up(&context, MouseOptions {
button: Some(MouseButton::Left),
})
.await
.map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
Ok(())
}
pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
let context = self
.mainframe()
.await?
.ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
self.click_at_in(&context, x, y).await
}
pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let hm = browser.human_mouse();
hm.set_last_position(Point { x, y });
let options = MouseClickOptions {
button: Some(MouseButton::Left),
count: Some(1),
delay: Some(80),
origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
};
hm.click(Some(Point { x, y }), context, options)
.await
.map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
Ok(())
}
pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.mouse()
.move_to(
Point { x, y },
&context,
MouseMoveOptions {
steps: Some(0),
origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
},
)
.await
.map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
Ok(())
}
pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.mouse()
.wheel(
&context,
MouseWheelOptions {
delta_x: Some(dx),
delta_y: Some(dy),
},
)
.await
.map_err(|e| anyhow!("scroll failed: {e:?}"))?;
Ok(())
}
pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
let y_distance = match direction {
ScrollDirection::Down => amount as i32,
ScrollDirection::Up => -(amount as i32),
};
browser
.human_mouse()
.scroll(y_distance, 0, &context)
.await
.map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
Ok(())
}
pub async fn type_text(&self, text: &str) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.keyboard()
.type_text(text, &context, None)
.await
.map_err(|e| anyhow!("type_text failed: {e:?}"))?;
Ok(())
}
pub async fn key_down(&self, key: &str) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.keyboard()
.down(key, &context)
.await
.map_err(|e| anyhow!("key_down failed: {e:?}"))?;
Ok(())
}
pub async fn key_up(&self, key: &str) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.keyboard()
.up(key, &context)
.await
.map_err(|e| anyhow!("key_up failed: {e:?}"))?;
Ok(())
}
pub async fn key_press(&self, key: &str) -> Result<()> {
let browser = self.browser.lock().await;
let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let context = browser
.driver()
.get_active_context_id()
.map_err(|e| anyhow!("{e:?}"))?;
browser
.keyboard()
.press(key, &context, None)
.await
.map_err(|e| anyhow!("key_press failed: {e:?}"))?;
Ok(())
}
pub async fn add_preload_script(&self, source: &str) -> Result<String> {
let function_declaration = format!("() => {{\n{source}\n}}");
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let id = browser
.add_preload_script(function_declaration)
.await
.map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
Ok(id)
}
pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let response = browser
.driver_mut()
.send_command(GetCookies {
method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
params: Default::default(),
})
.await
.map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
let result: rustenium_bidi_definitions::storage::results::GetCookiesResult =
response
.result
.try_into()
.map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
Ok(result
.cookies
.into_iter()
.map(|c| crate::cookies::CapturedCookie {
name: c.name,
value: match c.value {
BytesValue::StringValue(s) => s.value,
BytesValue::Base64Value(b) => b.value,
},
domain: c.domain,
path: c.path,
expires: c.expiry.map(|e| e as i64),
secure: c.secure,
http_only: c.http_only,
same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
})
.collect())
}
pub async fn set_cookie(
&self,
name: &str,
value: &str,
domain: &str,
path: Option<&str>,
expires: Option<u64>,
secure: Option<bool>,
http_only: Option<bool>,
same_site: Option<SameSite>,
) -> Result<()> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
let cookie = PartialCookie {
name: name.to_string(),
value: BytesValue::StringValue(StringValue::new(
StringValueType::String,
value.to_string(),
)),
domain: domain.to_string(),
path: path.map(|p| p.to_string()),
http_only,
secure,
same_site,
expiry: expires,
extensible: Default::default(),
};
let response = browser
.driver_mut()
.send_command(SetCookie {
method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
params: SetCookieParams::new(cookie),
})
.await
.map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
.result
.try_into()
.map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
Ok(())
}
pub fn profile_dir(&self) -> Option<&str> {
self.profile_dir.as_deref()
}
pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
let mut browser = self.browser.lock().await;
let browser = match &mut *browser {
Some(b) => b,
None => return Err(anyhow!("browser closed")),
};
let log = crate::network::NetworkLog::new();
let handler = crate::network::make_network_handler(log.clone());
let events: HashSet<&str> = [
"network.beforeRequestSent",
"network.responseCompleted",
"network.fetchError",
]
.into_iter()
.collect();
browser
.subscribe_events(events, handler)
.await
.map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
Ok(log)
}
pub async fn close(&self) -> Result<()> {
if let Some(browser) = self.browser.lock().await.take() {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
}
if let Ok(mut child) = self.child.lock() {
if let Some(mut c) = child.take() {
let _ = c.kill();
let _ = c.wait();
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProxyScheme {
#[default]
Http,
Socks5,
}
#[derive(Debug, Clone, Default)]
pub struct ProxyConfig {
pub scheme: ProxyScheme,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
impl ProxyConfig {
pub fn from_url(url: &str) -> Result<Self> {
let (scheme, rest) = match url.split_once("://") {
Some((s, r)) => (s.to_ascii_lowercase(), r),
None => ("http".to_string(), url),
};
let scheme = match scheme.as_str() {
"socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
"http" | "https" => ProxyScheme::Http,
other => return Err(anyhow!("unsupported proxy scheme: {other}")),
};
let (auth, hostport) = match rest.rsplit_once('@') {
Some((a, hp)) => (Some(a), hp),
None => (None, rest),
};
let (username, password) = match auth {
Some(a) => match a.split_once(':') {
Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
None => (Some(a.to_string()), None),
},
None => (None, None),
};
let (host, port) = hostport
.rsplit_once(':')
.ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
let port: u16 = port
.parse()
.map_err(|_| anyhow!("invalid proxy port in {url}"))?;
if host.is_empty() {
return Err(anyhow!("proxy URL missing host: {url}"));
}
Ok(Self {
scheme,
host: host.to_string(),
port,
username,
password,
})
}
}
pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
if proxy.username.is_some() || proxy.password.is_some() {
tracing::warn!(
"ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
foxdriver at that. Emitting host:port prefs only."
);
}
let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
match proxy.scheme {
ProxyScheme::Http => {
lines.push(format!(
r#"user_pref("network.proxy.http", "{}");"#,
proxy.host
));
lines.push(format!(
r#"user_pref("network.proxy.http_port", {});"#,
proxy.port
));
lines.push(format!(r#"user_pref("network.proxy.ssl", "{}");"#, proxy.host));
lines.push(format!(
r#"user_pref("network.proxy.ssl_port", {});"#,
proxy.port
));
lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
}
ProxyScheme::Socks5 => {
lines.push(format!(
r#"user_pref("network.proxy.socks", "{}");"#,
proxy.host
));
lines.push(format!(
r#"user_pref("network.proxy.socks_port", {});"#,
proxy.port
));
lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
}
}
lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
lines.push('\n'.to_string());
lines.join("\n")
}
#[derive(Debug, Clone, Default)]
pub struct FoxBrowserConfig {
pub executable_path: Option<String>,
pub profile_dir: Option<String>,
pub headless: bool,
pub viewport_width: u32,
pub viewport_height: u32,
pub user_agent: Option<String>,
pub user_js_content: Option<String>,
pub proxy: Option<ProxyConfig>,
pub unhandled_prompt_behavior: Option<String>,
}
fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
let handler = match s.trim().to_ascii_lowercase().as_str() {
"accept" | "accept and notify" => UserPromptHandlerType::Accept,
"dismiss" => UserPromptHandlerType::Dismiss,
"ignore" => UserPromptHandlerType::Ignore,
"dismiss and notify" | "dismiss_and_notify" | "notify" => {
UserPromptHandlerType::DismissAndNotify
}
_ => return None,
};
Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
}
fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
let dir = std::path::Path::new(profile_dir);
std::fs::create_dir_all(dir)
.map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
let path = dir.join("user.js");
std::fs::write(&path, content)
.map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
Ok(())
}
pub async fn launch_firefox(config: FoxBrowserConfig) -> Result<Page> {
let mut caps = FirefoxCapabilities::default();
caps.accept_insecure_certs(true);
if let Some(behavior) = config
.unhandled_prompt_behavior
.as_deref()
.and_then(prompt_behavior_capability)
{
caps.unhandled_prompt_behavior(behavior);
}
let mut args = Vec::new();
if config.headless {
args.push("--headless".to_string());
}
if let Some(ref ua) = config.user_agent {
args.push(format!("--user-agent={}", ua));
}
if config.viewport_width > 0 {
args.push(format!("--width={}", config.viewport_width));
}
if config.viewport_height > 0 {
args.push(format!("--height={}", config.viewport_height));
}
let mut user_js = config.user_js_content.clone().unwrap_or_default();
if let Some(ref proxy) = config.proxy {
if !user_js.is_empty() && !user_js.ends_with('\n') {
user_js.push('\n');
}
user_js.push_str(&proxy_prefs(proxy));
}
if !user_js.is_empty() {
if let Some(ref profile_dir) = &config.profile_dir {
if let Err(e) = write_user_js(profile_dir, &user_js) {
tracing::warn!("failed to write user.js for profile: {e}");
}
} else {
tracing::warn!("user.js prefs (incl. proxy) ignored because profile_dir is not set");
}
}
let profile_dir = config.profile_dir.clone();
let cfg = FirefoxConfig {
capabilities: caps,
firefox_executable_path: config.executable_path,
profile_dir: config.profile_dir,
browser_flags: Some(args),
..Default::default()
};
let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
.await
.map_err(|_| anyhow!("Firefox launch timed out after 30s — check that Firefox is installed and not already running with a locked profile"))?;
Ok(Page {
browser: tokio::sync::Mutex::new(Some(browser)),
profile_dir,
child: std::sync::Mutex::new(None),
})
}
fn reserve_local_port() -> Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| anyhow!("failed to read reserved port: {e}"))?
.port();
Ok(port)
}
fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
if let Some(p) = explicit {
return Ok(p);
}
const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path.split(sep).filter(|d| !d.is_empty()) {
for name in NAMES {
let cand = std::path::Path::new(dir).join(name);
if cand.is_file() {
return Ok(cand.to_string_lossy().into_owned());
}
}
}
}
const FIXED: &[&str] = &[
"/usr/local/bin/firefox",
"/usr/bin/firefox",
"/opt/firefox/firefox",
"/snap/bin/firefox",
"/Applications/Firefox.app/Contents/MacOS/firefox",
"C:\\Program Files\\Mozilla Firefox\\firefox.exe",
"C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
];
for p in FIXED {
if std::path::Path::new(p).is_file() {
return Ok((*p).to_string());
}
}
Err(anyhow!(
"could not find a Firefox binary — set FoxBrowserConfig.executable_path or install Firefox on PATH"
))
}
pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
let exe = resolve_firefox_binary(config.executable_path.clone())?;
let host = "127.0.0.1".to_string();
let port = reserve_local_port()?;
let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
std::env::temp_dir()
.join(format!("foxdriver-self-{}-{}", std::process::id(), port))
.display()
.to_string()
});
std::fs::create_dir_all(&profile_dir)
.map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
let mut user_js = config.user_js_content.clone().unwrap_or_default();
if let Some(ref proxy) = config.proxy {
if !user_js.is_empty() && !user_js.ends_with('\n') {
user_js.push('\n');
}
user_js.push_str(&proxy_prefs(proxy));
}
if !user_js.is_empty() {
write_user_js(&profile_dir, &user_js)?;
}
let mut args = vec![
format!("--remote-debugging-port={port}"),
"--profile".to_string(),
profile_dir.clone(),
"--no-remote".to_string(),
];
if config.headless {
args.push("--headless".to_string());
}
if let Some(ref ua) = config.user_agent {
args.push(format!("--user-agent={ua}"));
}
if config.viewport_width > 0 {
args.push(format!("--width={}", config.viewport_width));
}
if config.viewport_height > 0 {
args.push(format!("--height={}", config.viewport_height));
}
let child = std::process::Command::new(&exe)
.args(&args)
.env("MOZ_LAUNCHER_PROCESS", "0")
.spawn()
.map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
let addr: std::net::SocketAddr = format!("{host}:{port}")
.parse()
.map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
let start = std::time::Instant::now();
let ready_timeout = std::time::Duration::from_secs(30);
loop {
if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250)).is_ok()
{
break;
}
if start.elapsed() >= ready_timeout {
return Err(anyhow!(
"browser debug port {port} never came up within {}s — the spawn likely failed (check {exe:?})",
ready_timeout.as_secs()
));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let cfg = FirefoxConfig {
host: Some(host.clone()),
capabilities: {
let mut caps = FirefoxCapabilities::default();
caps.accept_insecure_certs(true);
if let Some(behavior) = config
.unhandled_prompt_behavior
.as_deref()
.and_then(prompt_behavior_capability)
{
caps.unhandled_prompt_behavior(behavior);
}
caps
},
launch_mode: FirefoxLaunchMode::Remote(port),
remote_debugging_port: Some(port),
..Default::default()
};
let attach = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
});
let browser = match attach.await {
Ok(Ok(b)) => b,
Ok(Err(_elapsed)) => return Err(anyhow!("BiDi attach to self-managed browser timed out after 30s")),
Err(join) => return Err(anyhow!("BiDi attach to self-managed browser failed: {join}")),
};
Ok(Page {
browser: tokio::sync::Mutex::new(Some(browser)),
profile_dir: Some(profile_dir),
child: std::sync::Mutex::new(Some(child)),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rustenium_bidi_definitions::script::types::{
ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType,
BooleanValue, BooleanValueType, ListRemoteValue, MappingRemoteValue,
NullValue, NullValueType, NumberValue, NumberValueType,
ObjectRemoteValue, ObjectRemoteValueType, PrimitiveProtocolValue,
StringValue, StringValueType, UndefinedValue, UndefinedValueType,
};
#[test]
fn frame_spec_main_aliases() {
assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
assert_eq!(FrameSpec::parse(" "), FrameSpec::Main);
assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
}
#[test]
fn frame_spec_index_forms() {
assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
assert_eq!(
FrameSpec::parse("10737418241"),
FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
);
assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
}
#[test]
fn frame_spec_url_and_name_prefixes() {
assert_eq!(
FrameSpec::parse("url:recaptcha/api2"),
FrameSpec::UrlContains("recaptcha/api2".into())
);
assert_eq!(
FrameSpec::parse("name:checkout-frame"),
FrameSpec::NameEquals("checkout-frame".into())
);
assert_eq!(
FrameSpec::parse("url: https://x.com "),
FrameSpec::UrlContains("https://x.com".into())
);
}
#[test]
fn frame_spec_bare_id_falls_through() {
assert_eq!(
FrameSpec::parse("10737418241-abc"),
FrameSpec::Id("10737418241-abc".into())
);
assert_eq!(
FrameSpec::parse("https://w.com/f"),
FrameSpec::Id("https://w.com/f".into())
);
}
#[test]
fn prompt_behavior_maps_known_values() {
for s in ["accept", "ACCEPT", "dismiss", "ignore", "dismiss and notify", "notify"] {
assert!(
prompt_behavior_capability(s).is_some(),
"'{s}' should map to a capability"
);
}
}
#[test]
fn prompt_behavior_rejects_unknown() {
assert!(prompt_behavior_capability("").is_none());
assert!(prompt_behavior_capability("bogus").is_none());
}
#[test]
fn prompt_behavior_ignore_is_user_prompt_handler_type() {
match prompt_behavior_capability("ignore") {
Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {}
other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
}
}
#[test]
fn wire_string_extracts_value() {
let v = serde_json::json!({"type": "string", "value": "hello"});
assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
}
#[test]
fn wire_number_passthrough() {
let v = serde_json::json!({"type": "number", "value": 42.5});
assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
}
#[test]
fn wire_boolean_extracts_bool() {
let v = serde_json::json!({"type": "boolean", "value": true});
assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
}
#[test]
fn wire_null_returns_null() {
let v = serde_json::json!({"type": "null"});
assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
}
#[test]
fn wire_undefined_returns_null() {
let v = serde_json::json!({"type": "undefined"});
assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
}
#[test]
fn wire_bigint_returns_string() {
let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
assert_eq!(
bidi_wire_value_to_json(&v),
serde_json::json!("9007199254740993")
);
}
#[test]
fn wire_object_recurse() {
let v = serde_json::json!({
"type": "object",
"value": [
["a", {"type": "string", "value": "alpha"}],
["b", {"type": "number", "value": 2}]
]
});
let out = bidi_wire_value_to_json(&v);
assert_eq!(out["a"], "alpha");
assert_eq!(out["b"], 2);
}
#[test]
fn wire_array_recurse() {
let v = serde_json::json!({
"type": "array",
"value": [
{"type": "string", "value": "x"},
{"type": "number", "value": 1}
]
});
let out = bidi_wire_value_to_json(&v);
assert_eq!(out, serde_json::json!(["x", 1]));
}
#[test]
fn wire_unknown_type_clones_raw() {
let v = serde_json::json!({"type": "special", "payload": 99});
assert_eq!(bidi_wire_value_to_json(&v), v);
}
#[test]
fn wire_missing_type_clones_raw() {
let v = serde_json::json!({"payload": 99});
assert_eq!(bidi_wire_value_to_json(&v), v);
}
#[test]
fn rv_string_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
StringValue::new(StringValueType::String, "hi"),
));
assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
}
#[test]
fn rv_number_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
NumberValue::new(NumberValueType::Number, 3.14),
));
assert_eq!(remote_value_to_json(&rv), serde_json::json!(3.14));
}
#[test]
fn rv_boolean_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
BooleanValue::new(BooleanValueType::Boolean, true),
));
assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
}
#[test]
fn rv_null_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
NullValue::new(NullValueType::Null),
));
assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
}
#[test]
fn rv_undefined_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
UndefinedValue::new(UndefinedValueType::Undefined),
));
assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
}
#[test]
fn rv_bigint_value() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
BigIntValue::new(BigIntValueType::Bigint, "999n"),
));
assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
}
#[test]
fn rv_array_value() {
let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
StringValue::new(StringValueType::String, "item"),
));
let arr = ArrayRemoteValue {
r#type: ArrayRemoteValueType::Array,
handle: None,
internal_id: None,
value: Some(ListRemoteValue::new(vec![inner])),
};
let rv = RemoteValue::ArrayRemoteValue(arr);
assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
}
#[test]
fn rv_object_value() {
let obj = ObjectRemoteValue {
r#type: ObjectRemoteValueType::Object,
handle: None,
internal_id: None,
value: Some(MappingRemoteValue::new(vec![vec![
serde_json::json!("key"),
serde_json::json!({"type": "string", "value": "val"}),
]])),
};
let rv = RemoteValue::ObjectRemoteValue(obj);
let out = remote_value_to_json(&rv);
assert_eq!(out["key"], "val");
}
#[test]
fn rv_unsupported_returns_null() {
let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
);
let rv = RemoteValue::SymbolRemoteValue(sym);
assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
}
#[test]
fn eval_result_into_value_deserializes() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
StringValue::new(StringValueType::String, "deserialized"),
));
let er = EvaluationResult::new(rv);
let s: String = er.into_value().unwrap();
assert_eq!(s, "deserialized");
}
#[test]
fn eval_result_into_value_number() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
NumberValue::new(NumberValueType::Number, 42i32),
));
let er = EvaluationResult::new(rv);
let n: i32 = er.into_value().unwrap();
assert_eq!(n, 42);
}
#[test]
fn eval_result_remote_value_accessor() {
let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
BooleanValue::new(BooleanValueType::Boolean, false),
));
let er = EvaluationResult::new(rv.clone());
assert_eq!(er.remote_value(), &rv);
}
#[test]
fn fox_browser_config_default_is_headless_false() {
let cfg = FoxBrowserConfig::default();
assert!(!cfg.headless);
assert!(cfg.executable_path.is_none());
assert!(cfg.profile_dir.is_none());
assert_eq!(cfg.viewport_width, 0);
assert_eq!(cfg.viewport_height, 0);
assert!(cfg.user_agent.is_none());
assert!(cfg.user_js_content.is_none());
}
#[test]
fn write_user_js_creates_file() {
let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let content = "user_pref(\"test\", true);\n";
write_user_js(tmp.to_str().unwrap(), content).unwrap();
let path = tmp.join("user.js");
assert!(path.exists());
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, content);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn write_user_js_creates_nested_dirs() {
let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let nested = tmp.join("a").join("b");
write_user_js(nested.to_str().unwrap(), "pref").unwrap();
assert!(nested.join("user.js").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn proxy_from_url_http_no_auth() {
let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
assert_eq!(p.scheme, ProxyScheme::Http);
assert_eq!(p.host, "1.2.3.4");
assert_eq!(p.port, 8080);
assert!(p.username.is_none() && p.password.is_none());
}
#[test]
fn proxy_from_url_socks5_with_auth() {
let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
assert_eq!(p.scheme, ProxyScheme::Socks5);
assert_eq!(p.host, "gw.residential.net");
assert_eq!(p.port, 1080);
assert_eq!(p.username.as_deref(), Some("user"));
assert_eq!(p.password.as_deref(), Some("pass"));
}
#[test]
fn proxy_from_url_bare_defaults_http() {
let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
assert_eq!(p.scheme, ProxyScheme::Http);
assert_eq!(p.host, "10.0.0.1");
assert_eq!(p.port, 3128);
}
#[test]
fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
assert!(ProxyConfig::from_url("http://nohost").is_err());
assert!(ProxyConfig::from_url("ftp://h:1").is_err());
assert!(ProxyConfig::from_url("http://h:notaport").is_err());
}
#[test]
fn proxy_prefs_http_emits_http_ssl_and_type() {
let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
assert!(!prefs.contains("network.proxy.socks"));
}
#[test]
fn proxy_prefs_socks5_emits_socks_and_version() {
let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
assert!(!prefs.contains("network.proxy.http_port"));
}
#[test]
fn scroll_direction_up_not_eq_down() {
assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
}
#[test]
fn scroll_direction_clone_copy() {
let a = ScrollDirection::Up;
let b = a;
assert_eq!(a, b); }
#[test]
fn scroll_direction_debug() {
let s = format!("{:?}", ScrollDirection::Down);
assert!(s.contains("Down"));
}
}