1use anyhow::{anyhow, Result};
4use base64::Engine as _;
5use rustenium::browsers::{
6 firefox, BidiBrowser, EvaluateScriptOptionsBuilder, FirefoxBrowser, FirefoxCapabilities,
7 FirefoxConfig, FirefoxLaunchMode,
8};
9use rustenium::input::{
10 Mouse, MouseButton, MouseClickOptions, MouseMoveOptions, MouseOptions, MouseWheelOptions, Point,
11};
12use rustenium::nodes::Node;
13use rustenium_bidi_definitions::browser::commands::{
14 BrowserCommand, Close as BrowserCloseCmd, CloseMethod as BrowserCloseMethod,
15 CloseParams as BrowserCloseParams,
16};
17use rustenium_bidi_definitions::browsing_context::commands::HandleUserPrompt;
18use rustenium_bidi_definitions::browsing_context::types::{CssLocator, CssLocatorType, Locator};
19use rustenium_bidi_definitions::input::commands::SetFiles;
20use rustenium_bidi_definitions::network::types::{
21 BytesValue, SameSite, StringValue, StringValueType,
22};
23use rustenium_bidi_definitions::script::types::{
24 ContextTarget, RemoteValue, SharedReference, Target,
25};
26use rustenium_bidi_definitions::session::types::{UnhandledPromptBehavior, UserPromptHandlerType};
27use rustenium_bidi_definitions::storage::commands::{GetCookies, SetCookie, SetCookieParams};
28use rustenium_bidi_definitions::storage::types::PartialCookie;
29use serde::de::DeserializeOwned;
30use std::collections::HashSet;
31
32pub struct Page {
34 browser: tokio::sync::Mutex<Option<FoxBrowser>>,
35 profile_dir: Option<String>,
36 child: std::sync::Mutex<Option<std::process::Child>>,
41}
42
43impl Drop for Page {
44 fn drop(&mut self) {
45 if let Ok(mut guard) = self.browser.try_lock() {
49 let _ = guard.take();
50 }
51 if let Ok(mut child) = self.child.try_lock() {
57 if let Some(c) = child.take() {
58 terminate_and_reap(c);
59 }
60 }
61 }
62}
63
64fn terminate_and_reap(mut child: std::process::Child) {
72 request_graceful_terminate(child.id());
73 for _ in 0..20 {
74 match child.try_wait() {
75 Ok(Some(_)) => return,
76 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
77 Err(_) => return,
78 }
79 }
80 let _ = child.kill();
81 let _ = child.wait();
82}
83
84#[cfg(unix)]
95fn request_graceful_terminate(pid: u32) {
96 let _ = nix::sys::signal::kill(
97 nix::unistd::Pid::from_raw(pid as i32),
98 nix::sys::signal::Signal::SIGTERM,
99 );
100}
101
102#[cfg(not(unix))]
103fn request_graceful_terminate(_pid: u32) {}
104
105async fn wait_for_exit(child: &mut std::process::Child, ticks: u32) -> bool {
109 for _ in 0..ticks {
110 match child.try_wait() {
111 Ok(Some(_)) => return true,
112 Ok(None) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
113 Err(_) => return false,
114 }
115 }
116 false
117}
118
119pub type FrameId = rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
121
122#[derive(Debug, Clone, PartialEq, serde::Serialize)]
126pub struct FrameInfo {
127 pub id: String,
129 pub url: String,
131 pub name: String,
133 pub is_main: bool,
135}
136
137#[derive(Debug, Clone, PartialEq)]
146pub struct FrameTreeNode {
147 pub id: FrameId,
150 pub url: String,
152 pub parent: Option<FrameId>,
154 pub depth: usize,
157}
158
159#[derive(Debug, Clone, PartialEq)]
163enum FrameSpec {
164 Main,
166 Index(usize),
168 IdOrIndex(String, usize),
172 Id(String),
174 UrlContains(String),
176 NameEquals(String),
178}
179
180impl FrameSpec {
181 fn parse(spec: &str) -> Self {
182 let s = spec.trim();
183 if s.is_empty() || s.eq_ignore_ascii_case("main") || s.eq_ignore_ascii_case("top") {
184 return FrameSpec::Main;
185 }
186 if let Some(rest) = s.strip_prefix("url:") {
187 return FrameSpec::UrlContains(rest.trim().to_string());
188 }
189 if let Some(rest) = s.strip_prefix("name:") {
190 return FrameSpec::NameEquals(rest.trim().to_string());
191 }
192 if let Some(rest) = s.strip_prefix("index:") {
193 if let Ok(n) = rest.trim().parse::<usize>() {
194 return FrameSpec::Index(n);
195 }
196 }
197 if let Ok(n) = s.parse::<usize>() {
202 return FrameSpec::IdOrIndex(s.to_string(), n);
203 }
204 FrameSpec::Id(s.to_string())
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct EvaluationResult {
211 inner: RemoteValue,
212}
213
214impl EvaluationResult {
215 pub fn new(inner: RemoteValue) -> Self {
216 Self { inner }
217 }
218
219 pub fn into_value<T: DeserializeOwned>(self) -> serde_json::Result<T> {
221 let json = remote_value_to_json(&self.inner);
222 serde_json::from_value(json)
223 }
224
225 pub fn remote_value(&self) -> &RemoteValue {
227 &self.inner
228 }
229}
230
231fn bidi_wire_value_to_json(v: &serde_json::Value) -> serde_json::Value {
233 match v.get("type").and_then(|t| t.as_str()) {
234 Some("string") => v
235 .get("value")
236 .and_then(|v| v.as_str())
237 .map(|s| serde_json::Value::String(s.to_string()))
238 .unwrap_or(serde_json::Value::Null),
239 Some("number") => v.get("value").cloned().unwrap_or(serde_json::Value::Null),
240 Some("boolean") => v
241 .get("value")
242 .and_then(|v| v.as_bool())
243 .map(serde_json::Value::Bool)
244 .unwrap_or(serde_json::Value::Null),
245 Some("null") | Some("undefined") => serde_json::Value::Null,
246 Some("bigint") => v
247 .get("value")
248 .and_then(|v| v.as_str())
249 .map(|s| serde_json::Value::String(s.to_string()))
250 .unwrap_or(serde_json::Value::Null),
251 Some("object") => {
252 let mut map = serde_json::Map::new();
253 if let Some(serde_json::Value::Array(pairs)) = v.get("value") {
254 for pair in pairs {
255 if let serde_json::Value::Array(items) = pair {
256 if items.len() >= 2 {
257 let key_opt = items[0].as_str().map(String::from).or_else(|| {
258 match bidi_wire_value_to_json(&items[0]) {
259 serde_json::Value::String(s) => Some(s),
260 serde_json::Value::Number(n) => Some(n.to_string()),
261 serde_json::Value::Bool(b) => Some(b.to_string()),
262 _ => None,
263 }
264 });
265 if let (Some(k), Some(val)) = (key_opt, items.get(1)) {
266 map.insert(k, bidi_wire_value_to_json(val));
267 }
268 }
269 }
270 }
271 }
272 serde_json::Value::Object(map)
273 }
274 Some("array") => {
275 let arr: Vec<serde_json::Value> = v
276 .get("value")
277 .and_then(|v| v.as_array())
278 .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
279 .unwrap_or_default();
280 serde_json::Value::Array(arr)
281 }
282 _ => v.clone(),
283 }
284}
285
286fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
288 match rv {
289 RemoteValue::PrimitiveProtocolValue(p) => match p {
290 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
291 serde_json::Value::String(s.value.clone())
292 }
293 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
294 match &n.value {
295 serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
296 serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
297 _ => serde_json::Value::Null,
298 }
299 }
300 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
301 serde_json::Value::Bool(b.value)
302 }
303 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
304 serde_json::Value::Null
305 }
306 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(
307 _,
308 ) => serde_json::Value::Null,
309 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
310 serde_json::Value::String(b.value.clone())
311 }
312 },
313 RemoteValue::ArrayRemoteValue(a) => {
314 let arr: Vec<serde_json::Value> = a
315 .value
316 .as_ref()
317 .map(|v| v.inner().iter().map(remote_value_to_json).collect())
318 .unwrap_or_default();
319 serde_json::Value::Array(arr)
320 }
321 RemoteValue::ObjectRemoteValue(o) => {
322 let mut map = serde_json::Map::new();
323 if let Some(mapping) = &o.value {
324 for pair in mapping.inner() {
325 if pair.len() >= 2 {
326 let key_opt = match pair.first() {
327 Some(serde_json::Value::String(k)) => Some(k.clone()),
328 Some(v) => match bidi_wire_value_to_json(v) {
329 serde_json::Value::String(s) => Some(s),
330 serde_json::Value::Number(n) => Some(n.to_string()),
331 serde_json::Value::Bool(b) => Some(b.to_string()),
332 _ => None,
333 },
334 None => None,
335 };
336 if let (Some(k), Some(v)) = (key_opt, pair.get(1)) {
337 map.insert(k, bidi_wire_value_to_json(v));
338 }
339 }
340 }
341 }
342 serde_json::Value::Object(map)
343 }
344 RemoteValue::RegExpRemoteValue(r) => serde_json::Value::String(format!(
345 "/{}/{}",
346 r.reg_exp_local_value.value.pattern,
347 r.reg_exp_local_value.value.flags.as_deref().unwrap_or("")
348 )),
349 RemoteValue::DateRemoteValue(d) => {
350 serde_json::Value::String(d.date_local_value.value.clone())
351 }
352 RemoteValue::NodeRemoteValue(_) => serde_json::Value::Null,
353 RemoteValue::WindowProxyRemoteValue(_) => serde_json::Value::Null,
354 _ => serde_json::Value::Null,
355 }
356}
357
358pub struct Element {
360 pub(crate) node: tokio::sync::Mutex<FoxNode>,
361 pub(crate) selector: String,
362}
363
364impl Element {
365 pub async fn click(&self) -> Result<()> {
367 let mut node = self.node.lock().await;
368 node.mouse_click()
369 .await
370 .map_err(|e| anyhow!("element click failed: {e:?}"))?;
371 Ok(())
372 }
373
374 pub fn selector(&self) -> &str {
376 &self.selector
377 }
378
379 pub async fn type_text(&self, text: &str) -> Result<()> {
381 let mut node = self.node.lock().await;
382 node.type_text(text.to_string())
383 .await
384 .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
385 Ok(())
386 }
387
388 pub async fn type_str(&self, text: &str) -> Result<()> {
390 self.type_text(text).await
391 }
392}
393
394type FoxBrowser = FirefoxBrowser;
396type FoxNode =
397 rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum ScrollDirection {
402 Up,
403 Down,
404}
405
406impl Page {
407 pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
409 launch_firefox(config.unwrap_or_default()).await
410 }
411
412 pub async fn goto(&self, url: &str) -> Result<()> {
414 let mut browser = self.browser.lock().await;
415 let browser = match &mut *browser {
416 Some(b) => b,
417 None => return Err(anyhow!("browser closed")),
418 };
419 browser
420 .navigate(url)
421 .await
422 .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
423 Ok(())
424 }
425
426 pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
432 let mut browser = self.browser.lock().await;
433 let browser = match &mut *browser {
434 Some(b) => b,
435 None => return Err(anyhow!("browser closed")),
436 };
437 let result = browser
438 .evaluate_script(expr.into(), false)
439 .await
440 .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
441 Ok(EvaluationResult::new(result.result))
442 }
443
444 pub async fn evaluate_await(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
453 let mut browser = self.browser.lock().await;
454 let browser = match &mut *browser {
455 Some(b) => b,
456 None => return Err(anyhow!("browser closed")),
457 };
458 let result = browser
459 .evaluate_script(expr.into(), true)
460 .await
461 .map_err(|e| anyhow!("evaluate_await failed: {e:?}"))?;
462 Ok(EvaluationResult::new(result.result))
463 }
464
465 pub async fn evaluate_in_context(
467 &self,
468 expr: impl Into<String>,
469 context: &FrameId,
470 ) -> Result<EvaluationResult> {
471 let mut browser = self.browser.lock().await;
472 let browser = match &mut *browser {
473 Some(b) => b,
474 None => return Err(anyhow!("browser closed")),
475 };
476 let options = EvaluateScriptOptionsBuilder::default()
477 .target(Target::ContextTarget(ContextTarget::new(context.clone())))
478 .build();
479 let result = browser
480 .evaluate_script_with_options(expr.into(), false, options)
481 .await
482 .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
483 Ok(EvaluationResult::new(result.result))
484 }
485 pub async fn evaluate_in_context_await(
487 &self,
488 expr: impl Into<String>,
489 context: &FrameId,
490 ) -> Result<EvaluationResult> {
491 let mut browser = self.browser.lock().await;
492 let browser = match &mut *browser {
493 Some(b) => b,
494 None => return Err(anyhow!("browser closed")),
495 };
496 let options = EvaluateScriptOptionsBuilder::default()
497 .target(Target::ContextTarget(ContextTarget::new(context.clone())))
498 .build();
499 let result = browser
500 .evaluate_script_with_options(expr.into(), true, options)
501 .await
502 .map_err(|e| anyhow!("evaluate_in_context_await failed: {e:?}"))?;
503 Ok(EvaluationResult::new(result.result))
504 }
505
506 pub async fn find_element(&self, selector: &str) -> Result<Element> {
508 let mut browser = self.browser.lock().await;
509 let browser = match &mut *browser {
510 Some(b) => b,
511 None => return Err(anyhow!("browser closed")),
512 };
513 let locator =
514 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
515 match browser.find_node(locator).await {
516 Ok(Some(node)) => Ok(Element {
517 node: tokio::sync::Mutex::new(node),
518 selector: selector.to_string(),
519 }),
520 Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
521 Err(e) => Err(anyhow!("find_element failed: {e:?}")),
522 }
523 }
524
525 pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
527 let mut browser = self.browser.lock().await;
528 let browser = match &mut *browser {
529 Some(b) => b,
530 None => return Err(anyhow!("browser closed")),
531 };
532 let locator =
533 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
534 let nodes = browser
535 .find_nodes(locator)
536 .await
537 .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
538 Ok(nodes
539 .into_iter()
540 .map(|n| Element {
541 node: tokio::sync::Mutex::new(n),
542 selector: selector.to_string(),
543 })
544 .collect())
545 }
546
547 pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
555 if files.is_empty() {
556 return Err(anyhow!("set_files: no files provided"));
557 }
558 for f in &files {
559 if !std::path::Path::new(f).exists() {
560 return Err(anyhow!("set_files: file does not exist: '{f}'"));
561 }
562 }
563 let element = self.find_element(selector).await?;
567 let (shared_id, context) = {
568 let node = element.node.lock().await;
569 let id = node.get_shared_id().cloned().ok_or_else(|| {
570 anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
571 })?;
572 (id, node.get_context_id().clone())
573 };
574 let element_ref: SharedReference = SharedReference::builder()
575 .shared_id(shared_id)
576 .build()
577 .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
578 let command = SetFiles::builder()
579 .context(context)
580 .element(element_ref)
581 .files(files)
582 .build()
583 .map_err(|e| anyhow!("set_files: build command: {e}"))?;
584 let mut browser = self.browser.lock().await;
585 let browser = match &mut *browser {
586 Some(b) => b,
587 None => return Err(anyhow!("browser closed")),
588 };
589 let response = browser
590 .driver_mut()
591 .send_command(command)
592 .await
593 .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
594 let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
595 .result
596 .try_into()
597 .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
598 Ok(())
599 }
600
601 pub async fn screenshot(&self) -> Result<Vec<u8>> {
603 let mut browser = self.browser.lock().await;
604 let browser = match &mut *browser {
605 Some(b) => b,
606 None => return Err(anyhow!("browser closed")),
607 };
608 let b64 = browser
609 .screenshot()
610 .await
611 .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
612 base64::engine::general_purpose::STANDARD
613 .decode(b64)
614 .map_err(|e| anyhow!("base64 decode failed: {e}"))
615 }
616
617 pub async fn reload(&self) -> Result<()> {
619 let mut browser = self.browser.lock().await;
620 let browser = match &mut *browser {
621 Some(b) => b,
622 None => return Err(anyhow!("browser closed")),
623 };
624 browser
625 .evaluate_script("location.reload()".to_string(), false)
626 .await
627 .map_err(|e| anyhow!("reload failed: {e:?}"))?;
628 Ok(())
629 }
630
631 pub async fn url(&self) -> Result<String> {
633 let eval = self.evaluate("document.URL").await?;
634 eval.into_value::<String>()
635 .map_err(|e| anyhow!("url deserialize failed: {e}"))
636 }
637
638 pub async fn title(&self) -> Result<String> {
640 let eval = self.evaluate("document.title").await?;
641 eval.into_value::<String>()
642 .map_err(|e| anyhow!("title deserialize failed: {e}"))
643 }
644
645 pub async fn frames(&self) -> Result<Vec<FrameId>> {
647 let browser = self.browser.lock().await;
648 let browser = match &*browser {
649 Some(b) => b,
650 None => return Err(anyhow!("browser closed")),
651 };
652 let contexts = browser
653 .driver()
654 .browsing_contexts
655 .lock()
656 .unwrap_or_else(|e| e.into_inner())
657 .iter()
658 .map(|c| c.id().clone())
659 .collect();
660 Ok(contexts)
661 }
662
663 pub async fn mainframe(&self) -> Result<Option<FrameId>> {
665 let browser = self.browser.lock().await;
666 let browser = match &*browser {
667 Some(b) => b,
668 None => return Err(anyhow!("browser closed")),
669 };
670 match browser.driver().get_active_context_id() {
671 Ok(ctx) => Ok(Some(ctx)),
672 Err(e) => {
673 tracing::debug!("get_active_context_id failed: {e:?}");
674 Ok(None)
675 }
676 }
677 }
678
679 pub async fn frame_execution_context(&self, frame_id: FrameId) -> Result<Option<FrameId>> {
681 let browser = self.browser.lock().await;
682 let browser = match &*browser {
683 Some(b) => b,
684 None => return Err(anyhow!("browser closed")),
685 };
686 let exists = browser
687 .driver()
688 .browsing_contexts
689 .lock()
690 .unwrap_or_else(|e| e.into_inner())
691 .iter()
692 .any(|c| c.id() == &frame_id);
693 Ok(if exists { Some(frame_id) } else { None })
694 }
695
696 pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
706 let frame_ids = self.frames().await?;
707 let main = self.mainframe().await?;
708 let mut out = Vec::with_capacity(frame_ids.len());
709 for fid in frame_ids {
710 let (url, name) = match self
714 .evaluate_in_context("({u: document.URL, n: (window.name || \"\")})", &fid)
715 .await
716 {
717 Ok(eval) => match eval.into_value::<serde_json::Value>() {
718 Ok(v) => (
719 v["u"].as_str().unwrap_or("").to_string(),
720 v["n"].as_str().unwrap_or("").to_string(),
721 ),
722 Err(_) => (String::new(), String::new()),
723 },
724 Err(e) => {
725 tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
726 (String::new(), String::new())
727 }
728 };
729 out.push(FrameInfo {
730 is_main: Some(&fid) == main.as_ref(),
731 id: fid.inner().to_string(),
732 url,
733 name,
734 });
735 }
736 Ok(out)
737 }
738
739 pub async fn frame_tree(&self) -> Result<Vec<FrameTreeNode>> {
755 use rustenium_bidi_definitions::browsing_context::commands::GetTree;
756 use rustenium_bidi_definitions::browsing_context::results::GetTreeResult;
757 use rustenium_bidi_definitions::browsing_context::types::{Info, InfoList};
758
759 let command = GetTree::builder().build();
760 let mut browser = self.browser.lock().await;
761 let browser = match &mut *browser {
762 Some(b) => b,
763 None => return Err(anyhow!("browser closed")),
764 };
765 let response = browser
766 .driver_mut()
767 .send_command(command)
768 .await
769 .map_err(|e| anyhow!("browsingContext.getTree BiDi command failed: {e:?}"))?;
770 let result: GetTreeResult = response
771 .result
772 .try_into()
773 .map_err(|e| anyhow!("browsingContext.getTree result parse failed: {e}"))?;
774
775 fn walk(
779 list: &InfoList,
780 parent: Option<&FrameId>,
781 depth: usize,
782 out: &mut Vec<FrameTreeNode>,
783 ) {
784 for info in list.inner() {
785 let info: &Info = info;
786 out.push(FrameTreeNode {
787 id: info.context.clone(),
788 url: info.url.clone(),
789 parent: parent.cloned(),
790 depth,
791 });
792 if let Some(children) = &info.children {
793 walk(children, Some(&info.context), depth + 1, out);
794 }
795 }
796 }
797 let mut out = Vec::new();
798 walk(&result.contexts, None, 0, &mut out);
799 Ok(out)
800 }
801
802 pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
815 self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
816 .await
817 }
818
819 pub async fn resolve_frame_within(
822 &self,
823 spec: &str,
824 timeout: std::time::Duration,
825 ) -> Result<FrameId> {
826 let parsed = FrameSpec::parse(spec);
827 let deadline = std::time::Instant::now() + timeout;
828 loop {
829 if let Some(fid) = self.try_resolve_frame(&parsed).await? {
830 return Ok(fid);
831 }
832 if std::time::Instant::now() >= deadline {
833 return Err(anyhow!(
834 "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
835 ));
836 }
837 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
838 }
839 }
840
841 async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
844 if matches!(parsed, FrameSpec::Main) {
845 return self.mainframe().await;
846 }
847 let frames = self.frames().await?;
848 match parsed {
849 FrameSpec::Main => unreachable!(),
850 FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
851 FrameSpec::IdOrIndex(id, idx) => {
852 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
854 return Ok(Some(fid.clone()));
855 }
856 Ok(frames.get(*idx).cloned())
857 }
858 FrameSpec::Id(id) => {
859 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
860 return Ok(Some(fid.clone()));
861 }
862 self.frame_by_url_contains(id).await
865 }
866 FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
867 FrameSpec::NameEquals(name) => {
868 for info in self.list_frames().await? {
869 if &info.name == name {
870 return Ok(Some(FrameId::new(info.id)));
871 }
872 }
873 Ok(None)
874 }
875 }
876 }
877
878 async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
881 for info in self.list_frames().await? {
882 if info.url.contains(sub) {
883 return Ok(Some(FrameId::new(info.id)));
884 }
885 }
886 Ok(None)
887 }
888
889 pub async fn eval_in_frame(
894 &self,
895 spec: &str,
896 expr: impl Into<String>,
897 ) -> Result<EvaluationResult> {
898 let fid = self.resolve_frame(spec).await?;
899 self.evaluate_in_context(expr, &fid).await
900 }
901
902 pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
909 let fid = self.resolve_frame(spec).await?;
910 let escaped = crate::frame::escape_js_string(selector);
911 let js = format!(
912 r#"(function() {{
913 const el = document.querySelector('{escaped}');
914 if (!el) return null;
915 const r = el.getBoundingClientRect();
916 if (r.width <= 0 || r.height <= 0) return null;
917 return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
918 }})()"#
919 );
920 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
923 loop {
924 if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
925 if let Ok(val) = eval.into_value::<serde_json::Value>() {
926 if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
927 return self.click_at_in(&fid, x, y).await;
928 }
929 }
930 }
931 if std::time::Instant::now() >= deadline {
932 return Err(anyhow!(
933 "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
934 ));
935 }
936 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
937 }
938 }
939
940 pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
944 let fid = self.resolve_frame(spec).await?;
945 let escaped = crate::frame::escape_js_string(selector);
946 let focus_js = format!(
947 r#"(function() {{
948 const el = document.querySelector('{escaped}');
949 if (!el) return false;
950 el.focus();
951 return document.activeElement === el;
952 }})()"#
953 );
954 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
956 loop {
957 let focused = self
958 .evaluate_in_context(&focus_js, &fid)
959 .await
960 .ok()
961 .and_then(|e| e.into_value::<bool>().ok())
962 .unwrap_or(false);
963 if focused {
964 break;
965 }
966 if std::time::Instant::now() >= deadline {
967 return Err(anyhow!(
968 "type_in_frame: could not focus '{selector}' in frame '{spec}'"
969 ));
970 }
971 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
972 }
973 let browser = self.browser.lock().await;
974 let browser = match &*browser {
975 Some(b) => b,
976 None => return Err(anyhow!("browser closed")),
977 };
978 browser
979 .keyboard()
980 .type_text(text, &fid, None)
981 .await
982 .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
983 Ok(())
984 }
985
986 pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
1000 let mut browser = self.browser.lock().await;
1001 let browser = match &mut *browser {
1002 Some(b) => b,
1003 None => return Err(anyhow!("browser closed")),
1004 };
1005 let log = crate::dialog::DialogLog::new();
1006 let handler = crate::dialog::make_dialog_handler(log.clone());
1007 let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
1008 browser
1009 .subscribe_events(events, handler)
1010 .await
1011 .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
1012 Ok(log)
1013 }
1014
1015 pub async fn start_sensors(&self) -> Result<String> {
1029 let id = self
1030 .add_preload_script(crate::sensors::SENSOR_SCRIPT)
1031 .await?;
1032 let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
1036 Ok(id)
1037 }
1038
1039 pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
1043 let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
1044 eval.into_value::<serde_json::Value>()
1045 .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
1046 }
1047
1048 pub async fn handle_user_prompt(
1054 &self,
1055 context: Option<&FrameId>,
1056 accept: bool,
1057 user_text: Option<&str>,
1058 ) -> Result<()> {
1059 let ctx = match context {
1060 Some(c) => c.clone(),
1061 None => self
1062 .mainframe()
1063 .await?
1064 .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
1065 };
1066 let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
1067 if let Some(text) = user_text {
1068 builder = builder.user_text(text.to_string());
1069 }
1070 let command = builder
1071 .build()
1072 .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
1073 let mut browser = self.browser.lock().await;
1074 let browser = match &mut *browser {
1075 Some(b) => b,
1076 None => return Err(anyhow!("browser closed")),
1077 };
1078 let response = browser
1079 .driver_mut()
1080 .send_command(command)
1081 .await
1082 .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
1083 let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
1084 response
1085 .result
1086 .try_into()
1087 .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
1088 Ok(())
1089 }
1090
1091 pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
1097 let browser = self.browser.lock().await;
1098 let browser = match &*browser {
1099 Some(b) => b,
1100 None => return Err(anyhow!("browser closed")),
1101 };
1102 let context = browser
1103 .driver()
1104 .get_active_context_id()
1105 .map_err(|e| anyhow!("{e:?}"))?;
1106 let hm = browser.human_mouse();
1107 hm.set_last_position(Point { x: x0, y: y0 });
1108 hm.move_to(
1109 Point { x: x1, y: y1 },
1110 &context,
1111 MouseMoveOptions::default(),
1112 )
1113 .await
1114 .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
1115 Ok(())
1116 }
1117
1118 pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
1120 let browser = self.browser.lock().await;
1121 let browser = match &*browser {
1122 Some(b) => b,
1123 None => return Err(anyhow!("browser closed")),
1124 };
1125 let context = browser
1126 .driver()
1127 .get_active_context_id()
1128 .map_err(|e| anyhow!("{e:?}"))?;
1129 let hm = browser.human_mouse();
1130 hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
1131 .await
1132 .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
1133 hm.down(
1134 &context,
1135 MouseOptions {
1136 button: Some(MouseButton::Left),
1137 },
1138 )
1139 .await
1140 .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
1141 Ok(())
1142 }
1143
1144 pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
1146 let browser = self.browser.lock().await;
1147 let browser = match &*browser {
1148 Some(b) => b,
1149 None => return Err(anyhow!("browser closed")),
1150 };
1151 let context = browser
1152 .driver()
1153 .get_active_context_id()
1154 .map_err(|e| anyhow!("{e:?}"))?;
1155 let hm = browser.human_mouse();
1156 hm.up(
1157 &context,
1158 MouseOptions {
1159 button: Some(MouseButton::Left),
1160 },
1161 )
1162 .await
1163 .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
1164 Ok(())
1165 }
1166
1167 pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
1177 let context = self
1178 .mainframe()
1179 .await?
1180 .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
1181 self.click_at_in(&context, x, y).await
1182 }
1183
1184 pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
1195 let browser = self.browser.lock().await;
1196 let browser = match &*browser {
1197 Some(b) => b,
1198 None => return Err(anyhow!("browser closed")),
1199 };
1200 let hm = browser.human_mouse();
1201 hm.set_last_position(Point { x, y });
1209 let options = MouseClickOptions {
1210 button: Some(MouseButton::Left),
1211 count: Some(1),
1212 delay: Some(80),
1213 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1214 };
1215 hm.click(Some(Point { x, y }), context, options)
1216 .await
1217 .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
1218 Ok(())
1219 }
1220
1221 pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
1233 let browser = self.browser.lock().await;
1234 let browser = match &*browser {
1235 Some(b) => b,
1236 None => return Err(anyhow!("browser closed")),
1237 };
1238 let context = browser
1239 .driver()
1240 .get_active_context_id()
1241 .map_err(|e| anyhow!("{e:?}"))?;
1242 browser
1243 .mouse()
1244 .move_to(
1245 Point { x, y },
1246 &context,
1247 MouseMoveOptions {
1248 steps: Some(0),
1249 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1250 },
1251 )
1252 .await
1253 .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
1254 Ok(())
1255 }
1256
1257 pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
1259 let browser = self.browser.lock().await;
1260 let browser = match &*browser {
1261 Some(b) => b,
1262 None => return Err(anyhow!("browser closed")),
1263 };
1264 let context = browser
1265 .driver()
1266 .get_active_context_id()
1267 .map_err(|e| anyhow!("{e:?}"))?;
1268 browser
1269 .mouse()
1270 .wheel(
1271 &context,
1272 MouseWheelOptions {
1273 delta_x: Some(dx),
1274 delta_y: Some(dy),
1275 },
1276 )
1277 .await
1278 .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1279 Ok(())
1280 }
1281
1282 pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1284 let browser = self.browser.lock().await;
1285 let browser = match &*browser {
1286 Some(b) => b,
1287 None => return Err(anyhow!("browser closed")),
1288 };
1289 let context = browser
1290 .driver()
1291 .get_active_context_id()
1292 .map_err(|e| anyhow!("{e:?}"))?;
1293 let y_distance = match direction {
1294 ScrollDirection::Down => amount as i32,
1295 ScrollDirection::Up => -(amount as i32),
1296 };
1297 browser
1298 .human_mouse()
1299 .scroll(y_distance, 0, &context)
1300 .await
1301 .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1302 Ok(())
1303 }
1304
1305 pub async fn type_text(&self, text: &str) -> Result<()> {
1307 let browser = self.browser.lock().await;
1308 let browser = match &*browser {
1309 Some(b) => b,
1310 None => return Err(anyhow!("browser closed")),
1311 };
1312 let context = browser
1313 .driver()
1314 .get_active_context_id()
1315 .map_err(|e| anyhow!("{e:?}"))?;
1316 browser
1317 .keyboard()
1318 .type_text(text, &context, None)
1319 .await
1320 .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1321 Ok(())
1322 }
1323
1324 pub async fn key_down(&self, key: &str) -> Result<()> {
1326 let browser = self.browser.lock().await;
1327 let browser = match &*browser {
1328 Some(b) => b,
1329 None => return Err(anyhow!("browser closed")),
1330 };
1331 let context = browser
1332 .driver()
1333 .get_active_context_id()
1334 .map_err(|e| anyhow!("{e:?}"))?;
1335 browser
1336 .keyboard()
1337 .down(key, &context)
1338 .await
1339 .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1340 Ok(())
1341 }
1342
1343 pub async fn key_up(&self, key: &str) -> Result<()> {
1345 let browser = self.browser.lock().await;
1346 let browser = match &*browser {
1347 Some(b) => b,
1348 None => return Err(anyhow!("browser closed")),
1349 };
1350 let context = browser
1351 .driver()
1352 .get_active_context_id()
1353 .map_err(|e| anyhow!("{e:?}"))?;
1354 browser
1355 .keyboard()
1356 .up(key, &context)
1357 .await
1358 .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1359 Ok(())
1360 }
1361
1362 pub async fn key_press(&self, key: &str) -> Result<()> {
1364 let browser = self.browser.lock().await;
1365 let browser = match &*browser {
1366 Some(b) => b,
1367 None => return Err(anyhow!("browser closed")),
1368 };
1369 let context = browser
1370 .driver()
1371 .get_active_context_id()
1372 .map_err(|e| anyhow!("{e:?}"))?;
1373 browser
1374 .keyboard()
1375 .press(key, &context, None)
1376 .await
1377 .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1378 Ok(())
1379 }
1380
1381 pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1398 let trimmed = source.trim();
1399 let is_fn_decl = trimmed.starts_with("() =>")
1400 || trimmed.starts_with("async () =>")
1401 || trimmed.starts_with("function")
1402 || trimmed.starts_with("async function");
1403 let function_declaration = if is_fn_decl {
1404 source.to_string()
1405 } else {
1406 format!("() => {{\n{source}\n}}")
1407 };
1408 let mut browser = self.browser.lock().await;
1409 let browser = match &mut *browser {
1410 Some(b) => b,
1411 None => return Err(anyhow!("browser closed")),
1412 };
1413 let id = browser
1414 .add_preload_script(function_declaration)
1415 .await
1416 .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1417 Ok(id)
1418 }
1419
1420 pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1422 let mut browser = self.browser.lock().await;
1423 let browser = match &mut *browser {
1424 Some(b) => b,
1425 None => return Err(anyhow!("browser closed")),
1426 };
1427 let response = browser
1428 .driver_mut()
1429 .send_command(GetCookies {
1430 method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1431 params: Default::default(),
1432 })
1433 .await
1434 .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1435 let result: rustenium_bidi_definitions::storage::results::GetCookiesResult = response
1436 .result
1437 .try_into()
1438 .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1439 Ok(result
1440 .cookies
1441 .into_iter()
1442 .map(|c| crate::cookies::CapturedCookie {
1443 name: c.name,
1444 value: match c.value {
1445 BytesValue::StringValue(s) => s.value,
1446 BytesValue::Base64Value(b) => b.value,
1447 },
1448 domain: c.domain,
1449 path: c.path,
1450 expires: c.expiry.map(|e| e as i64),
1451 secure: c.secure,
1452 http_only: c.http_only,
1453 same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1454 })
1455 .collect())
1456 }
1457
1458 #[allow(clippy::too_many_arguments)] pub async fn set_cookie(
1461 &self,
1462 name: &str,
1463 value: &str,
1464 domain: &str,
1465 path: Option<&str>,
1466 expires: Option<u64>,
1467 secure: Option<bool>,
1468 http_only: Option<bool>,
1469 same_site: Option<SameSite>,
1470 ) -> Result<()> {
1471 let normalized_domain = domain.trim_start_matches('.');
1472 let mut browser = self.browser.lock().await;
1473 let browser = match &mut *browser {
1474 Some(b) => b,
1475 None => return Err(anyhow!("browser closed")),
1476 };
1477 let cookie = PartialCookie {
1478 name: name.to_string(),
1479 value: BytesValue::StringValue(StringValue::new(
1480 StringValueType::String,
1481 value.to_string(),
1482 )),
1483 domain: normalized_domain.to_string(),
1484 path: path.map(|p| p.to_string()),
1485 http_only,
1486 secure,
1487 same_site,
1488 expiry: expires,
1489 extensible: Default::default(),
1490 };
1491 let response = browser
1492 .driver_mut()
1493 .send_command(SetCookie {
1494 method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1495 params: SetCookieParams::new(cookie),
1496 })
1497 .await
1498 .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1499 let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1500 .result
1501 .try_into()
1502 .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1503 Ok(())
1504 }
1505
1506 pub fn profile_dir(&self) -> Option<&str> {
1508 self.profile_dir.as_deref()
1509 }
1510
1511 pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1525 let mut browser = self.browser.lock().await;
1526 let browser = match &mut *browser {
1527 Some(b) => b,
1528 None => return Err(anyhow!("browser closed")),
1529 };
1530 let log = crate::network::NetworkLog::new();
1531 let handler = crate::network::make_network_handler(log.clone());
1532 let events: HashSet<&str> = [
1533 "network.beforeRequestSent",
1534 "network.responseCompleted",
1535 "network.fetchError",
1536 ]
1537 .into_iter()
1538 .collect();
1539 browser
1540 .subscribe_events(events, handler)
1541 .await
1542 .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1543 Ok(log)
1544 }
1545
1546 pub async fn close(&self) -> Result<()> {
1554 let child_opt = self.child.lock().ok().and_then(|mut g| g.take());
1557
1558 if let Some(mut c) = child_opt {
1559 {
1578 let mut guard = self.browser.lock().await;
1579 if let Some(browser) = guard.as_mut() {
1580 let cmd = BrowserCommand::Close(BrowserCloseCmd {
1581 method: BrowserCloseMethod::Close,
1582 params: BrowserCloseParams {},
1583 });
1584 let _ = tokio::time::timeout(
1589 std::time::Duration::from_secs(10),
1590 browser.driver_mut().send_command(cmd),
1591 )
1592 .await;
1593 }
1594 }
1595 if !wait_for_exit(&mut c, 100).await {
1598 request_graceful_terminate(c.id());
1602 if !wait_for_exit(&mut c, 30).await {
1603 let _ = c.kill();
1604 let _ = c.wait();
1605 }
1606 }
1607 let _ = self.browser.lock().await.take();
1611 } else if let Some(browser) = self.browser.lock().await.take() {
1612 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1614 }
1615 Ok(())
1616 }
1617}
1618
1619#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1625pub enum ProxyScheme {
1626 #[default]
1628 Http,
1629 Socks5,
1631}
1632
1633#[derive(Debug, Clone, Default)]
1643pub struct ProxyConfig {
1644 pub scheme: ProxyScheme,
1645 pub host: String,
1646 pub port: u16,
1647 pub username: Option<String>,
1648 pub password: Option<String>,
1649}
1650
1651impl ProxyConfig {
1652 pub fn from_url(url: &str) -> Result<Self> {
1655 let (scheme, rest) = match url.split_once("://") {
1656 Some((s, r)) => (s.to_ascii_lowercase(), r),
1657 None => ("http".to_string(), url),
1658 };
1659 let scheme = match scheme.as_str() {
1660 "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1661 "http" | "https" => ProxyScheme::Http,
1662 other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1663 };
1664 let (auth, hostport) = match rest.rsplit_once('@') {
1665 Some((a, hp)) => (Some(a), hp),
1666 None => (None, rest),
1667 };
1668 let (username, password) = match auth {
1669 Some(a) => match a.split_once(':') {
1670 Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1671 None => (Some(a.to_string()), None),
1672 },
1673 None => (None, None),
1674 };
1675 let (host, port) = hostport
1676 .rsplit_once(':')
1677 .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1678 let port: u16 = port
1679 .parse()
1680 .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1681 if host.is_empty() {
1682 return Err(anyhow!("proxy URL missing host: {url}"));
1683 }
1684 Ok(Self {
1685 scheme,
1686 host: host.to_string(),
1687 port,
1688 username,
1689 password,
1690 })
1691 }
1692}
1693
1694pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1696 if proxy.username.is_some() || proxy.password.is_some() {
1697 tracing::warn!(
1698 "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1699 front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1700 foxdriver at that. Emitting host:port prefs only."
1701 );
1702 }
1703 let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1704 match proxy.scheme {
1705 ProxyScheme::Http => {
1706 lines.push(format!(
1707 r#"user_pref("network.proxy.http", "{}");"#,
1708 proxy.host
1709 ));
1710 lines.push(format!(
1711 r#"user_pref("network.proxy.http_port", {});"#,
1712 proxy.port
1713 ));
1714 lines.push(format!(
1715 r#"user_pref("network.proxy.ssl", "{}");"#,
1716 proxy.host
1717 ));
1718 lines.push(format!(
1719 r#"user_pref("network.proxy.ssl_port", {});"#,
1720 proxy.port
1721 ));
1722 lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1723 }
1724 ProxyScheme::Socks5 => {
1725 lines.push(format!(
1726 r#"user_pref("network.proxy.socks", "{}");"#,
1727 proxy.host
1728 ));
1729 lines.push(format!(
1730 r#"user_pref("network.proxy.socks_port", {});"#,
1731 proxy.port
1732 ));
1733 lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1734 lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1735 }
1736 }
1737 lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1740
1741 lines.push(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#.to_string());
1754 lines.push(r#"user_pref("media.peerconnection.ice.no_host", true);"#.to_string());
1755 lines.push(r#"user_pref("media.peerconnection.ice.default_address_only", true);"#.to_string());
1756
1757 lines.push(r#"user_pref("network.dns.disablePrefetch", true);"#.to_string());
1767 lines.push(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#.to_string());
1768 lines.push(r#"user_pref("network.predictor.enabled", false);"#.to_string());
1769 lines.push(r#"user_pref("network.http.speculative-parallel-limit", 0);"#.to_string());
1770 lines.push(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#.to_string());
1771
1772 lines.push('\n'.to_string());
1773 lines.join("\n")
1774}
1775
1776#[derive(Debug, Clone, Default)]
1777pub struct FoxBrowserConfig {
1778 pub executable_path: Option<String>,
1779 pub profile_dir: Option<String>,
1792 pub headless: bool,
1793 pub viewport_width: u32,
1794 pub viewport_height: u32,
1795 pub user_agent: Option<String>,
1796 pub user_js_content: Option<String>,
1800 pub proxy: Option<ProxyConfig>,
1803 pub unhandled_prompt_behavior: Option<String>,
1811 pub env: Vec<(String, String)>,
1821}
1822
1823fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1827 let handler = match s.trim().to_ascii_lowercase().as_str() {
1828 "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1829 "dismiss" => UserPromptHandlerType::Dismiss,
1830 "ignore" => UserPromptHandlerType::Ignore,
1831 "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1832 UserPromptHandlerType::DismissAndNotify
1833 }
1834 _ => return None,
1835 };
1836 Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1837}
1838
1839fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1841 let dir = std::path::Path::new(profile_dir);
1842 std::fs::create_dir_all(dir)
1843 .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1844 let path = dir.join("user.js");
1845 std::fs::write(&path, content)
1846 .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1847 Ok(())
1848}
1849
1850pub async fn launch_firefox(mut config: FoxBrowserConfig) -> Result<Page> {
1852 let mut caps = FirefoxCapabilities::default();
1853 caps.accept_insecure_certs(true);
1854 if let Some(behavior) = config
1855 .unhandled_prompt_behavior
1856 .as_deref()
1857 .and_then(prompt_behavior_capability)
1858 {
1859 caps.unhandled_prompt_behavior(behavior);
1860 }
1861
1862 let mut args = Vec::new();
1863 if config.headless {
1864 args.push("--headless".to_string());
1865 }
1866 if let Some(ref ua) = config.user_agent {
1867 args.push(format!("--user-agent={}", ua));
1868 }
1869 if config.viewport_width > 0 {
1870 args.push(format!("--width={}", config.viewport_width));
1871 }
1872 if config.viewport_height > 0 {
1873 args.push(format!("--height={}", config.viewport_height));
1874 }
1875
1876 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1881 if let Some(ref proxy) = config.proxy {
1882 if !user_js.is_empty() && !user_js.ends_with('\n') {
1883 user_js.push('\n');
1884 }
1885 user_js.push_str(&proxy_prefs(proxy));
1886 }
1887 if !user_js.is_empty() {
1888 if config
1898 .profile_dir
1899 .as_deref()
1900 .filter(|d| !d.is_empty())
1901 .is_none()
1902 {
1903 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1904 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1905 let dir =
1906 std::env::temp_dir().join(format!("foxdriver-profile-{}-{n}", std::process::id()));
1907 config.profile_dir = Some(dir.to_string_lossy().into_owned());
1908 }
1909 let profile_dir = config
1910 .profile_dir
1911 .as_deref()
1912 .ok_or_else(|| anyhow!("profile_dir missing when writing user.js"))?;
1913 write_user_js(profile_dir, &user_js)
1916 .map_err(|e| anyhow!("failed to write user.js to profile {profile_dir:?}: {e}"))?;
1917 }
1918
1919 let profile_dir = config.profile_dir.clone();
1920 let cfg = FirefoxConfig {
1921 capabilities: caps,
1922 firefox_executable_path: config.executable_path,
1923 profile_dir: config.profile_dir,
1924 browser_flags: Some(args),
1925 ..Default::default()
1926 };
1927
1928 let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1929 .await
1930 .map_err(|_| anyhow!("Firefox launch timed out after 30s, check that Firefox is installed and not already running with a locked profile"))?;
1931 Ok(Page {
1932 browser: tokio::sync::Mutex::new(Some(browser)),
1933 profile_dir,
1934 child: std::sync::Mutex::new(None),
1935 })
1936}
1937
1938fn reserve_local_port() -> Result<u16> {
1943 let listener = std::net::TcpListener::bind("127.0.0.1:0")
1944 .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1945 let port = listener
1946 .local_addr()
1947 .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1948 .port();
1949 Ok(port)
1950}
1951
1952fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1962 if let Some(p) = explicit {
1963 return Ok(p);
1964 }
1965 const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1966 if let Ok(path) = std::env::var("PATH") {
1967 let sep = if cfg!(windows) { ';' } else { ':' };
1968 for dir in path.split(sep).filter(|d| !d.is_empty()) {
1969 for name in NAMES {
1970 let cand = std::path::Path::new(dir).join(name);
1971 if cand.is_file() {
1972 return Ok(cand.to_string_lossy().into_owned());
1973 }
1974 }
1975 }
1976 }
1977 const FIXED: &[&str] = &[
1979 "/usr/local/bin/firefox",
1980 "/usr/bin/firefox",
1981 "/opt/firefox/firefox",
1982 "/snap/bin/firefox",
1983 "/Applications/Firefox.app/Contents/MacOS/firefox",
1984 "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1985 "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1986 ];
1987 for p in FIXED {
1988 if std::path::Path::new(p).is_file() {
1989 return Ok((*p).to_string());
1990 }
1991 }
1992 Err(anyhow!(
1993 "could not find a Firefox binary, set FoxBrowserConfig.executable_path or install Firefox on PATH"
1994 ))
1995}
1996
1997pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
2013 let exe = resolve_firefox_binary(config.executable_path.clone())?;
2014
2015 let host = "127.0.0.1".to_string();
2016 let port = reserve_local_port()?;
2017
2018 let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
2022 std::env::temp_dir()
2023 .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
2024 .display()
2025 .to_string()
2026 });
2027 std::fs::create_dir_all(&profile_dir)
2028 .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
2029
2030 let mut user_js = config.user_js_content.clone().unwrap_or_default();
2031 if let Some(ref proxy) = config.proxy {
2032 if !user_js.is_empty() && !user_js.ends_with('\n') {
2033 user_js.push('\n');
2034 }
2035 user_js.push_str(&proxy_prefs(proxy));
2036 }
2037 if !user_js.is_empty() {
2038 write_user_js(&profile_dir, &user_js)?;
2039 }
2040
2041 let mut args = vec![
2045 format!("--remote-debugging-port={port}"),
2046 "--profile".to_string(),
2047 profile_dir.clone(),
2048 "--no-remote".to_string(),
2049 ];
2050 if config.headless {
2051 args.push("--headless".to_string());
2052 }
2053 if let Some(ref ua) = config.user_agent {
2054 args.push(format!("--user-agent={ua}"));
2055 }
2056 if config.viewport_width > 0 {
2057 args.push(format!("--width={}", config.viewport_width));
2058 }
2059 if config.viewport_height > 0 {
2060 args.push(format!("--height={}", config.viewport_height));
2061 }
2062
2063 let mut command = std::process::Command::new(&exe);
2069 command.args(&args).env("MOZ_LAUNCHER_PROCESS", "0");
2070 for (key, value) in &config.env {
2071 command.env(key, value);
2072 }
2073 let child = command
2074 .spawn()
2075 .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
2076
2077 let addr: std::net::SocketAddr = format!("{host}:{port}")
2080 .parse()
2081 .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
2082 let start = std::time::Instant::now();
2083 let ready_timeout = std::time::Duration::from_secs(30);
2084 loop {
2085 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250))
2086 .is_ok()
2087 {
2088 break;
2089 }
2090 if start.elapsed() >= ready_timeout {
2091 terminate_and_reap(child);
2092 return Err(anyhow!(
2093 "browser debug port {port} never came up within {}s, the spawn likely failed (check {exe:?})",
2094 ready_timeout.as_secs()
2095 ));
2096 }
2097 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2098 }
2099
2100 let cfg = FirefoxConfig {
2111 host: Some(host.clone()),
2112 capabilities: {
2113 let mut caps = FirefoxCapabilities::default();
2114 caps.accept_insecure_certs(true);
2115 if let Some(behavior) = config
2116 .unhandled_prompt_behavior
2117 .as_deref()
2118 .and_then(prompt_behavior_capability)
2119 {
2120 caps.unhandled_prompt_behavior(behavior);
2121 }
2122 caps
2123 },
2124 launch_mode: FirefoxLaunchMode::Remote(port),
2125 remote_debugging_port: Some(port),
2126 ..Default::default()
2127 };
2128 let attach = tokio::spawn(async move {
2129 tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
2130 });
2131 let browser = match attach.await {
2132 Ok(Ok(b)) => b,
2133 Ok(Err(_elapsed)) => {
2134 terminate_and_reap(child);
2135 return Err(anyhow!(
2136 "BiDi attach to self-managed browser timed out after 30s"
2137 ));
2138 }
2139 Err(join) => {
2140 terminate_and_reap(child);
2141 return Err(anyhow!(
2142 "BiDi attach to self-managed browser failed: {join}"
2143 ));
2144 }
2145 };
2146
2147 Ok(Page {
2148 browser: tokio::sync::Mutex::new(Some(browser)),
2149 profile_dir: Some(profile_dir),
2150 child: std::sync::Mutex::new(Some(child)),
2151 })
2152}
2153
2154#[cfg(test)]
2155mod tests {
2156 use super::*;
2157
2158 #[cfg(unix)]
2165 #[test]
2166 fn terminate_and_reap_leaves_no_zombie() {
2167 let child = std::process::Command::new("/bin/sh")
2168 .args(["-c", "trap '' TERM; sleep 300"])
2169 .spawn()
2170 .expect("spawn test child");
2171 let pid = child.id();
2172 terminate_and_reap(child);
2173 assert!(
2175 !std::path::Path::new(&format!("/proc/{pid}")).exists(),
2176 "pid {pid} still present after terminate_and_reap (zombie leak)"
2177 );
2178 }
2179
2180 #[test]
2186 fn shared_escaper_covers_quote_and_control_breakout() {
2187 let malicious = "'); alert(1); //";
2188 let escaped = crate::frame::escape_js_string(malicious);
2189 assert_eq!(escaped, "\\'); alert(1); //");
2192 for (index, _) in escaped.match_indices('\'') {
2194 assert_eq!(escaped.as_bytes()[index - 1], b'\\');
2195 }
2196 }
2197
2198 use rustenium_bidi_definitions::script::types::{
2199 ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType, BooleanValue,
2200 BooleanValueType, ListRemoteValue, MappingRemoteValue, NullValue, NullValueType,
2201 NumberValue, NumberValueType, ObjectRemoteValue, ObjectRemoteValueType,
2202 PrimitiveProtocolValue, StringValue, StringValueType, UndefinedValue, UndefinedValueType,
2203 };
2204
2205 #[test]
2208 fn frame_spec_main_aliases() {
2209 assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
2210 assert_eq!(FrameSpec::parse(" "), FrameSpec::Main);
2211 assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
2212 assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
2213 }
2214
2215 #[test]
2216 fn frame_spec_index_forms() {
2217 assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
2219 assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
2220 assert_eq!(
2222 FrameSpec::parse("10737418241"),
2223 FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
2224 );
2225 assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
2227 }
2228
2229 #[test]
2230 fn frame_spec_url_and_name_prefixes() {
2231 assert_eq!(
2232 FrameSpec::parse("url:recaptcha/api2"),
2233 FrameSpec::UrlContains("recaptcha/api2".into())
2234 );
2235 assert_eq!(
2236 FrameSpec::parse("name:checkout-frame"),
2237 FrameSpec::NameEquals("checkout-frame".into())
2238 );
2239 assert_eq!(
2241 FrameSpec::parse("url: https://x.com "),
2242 FrameSpec::UrlContains("https://x.com".into())
2243 );
2244 }
2245
2246 #[test]
2247 fn frame_spec_bare_id_falls_through() {
2248 assert_eq!(
2250 FrameSpec::parse("10737418241-abc"),
2251 FrameSpec::Id("10737418241-abc".into())
2252 );
2253 assert_eq!(
2255 FrameSpec::parse("https://w.com/f"),
2256 FrameSpec::Id("https://w.com/f".into())
2257 );
2258 }
2259
2260 #[test]
2263 fn prompt_behavior_maps_known_values() {
2264 for s in [
2265 "accept",
2266 "ACCEPT",
2267 "dismiss",
2268 "ignore",
2269 "dismiss and notify",
2270 "notify",
2271 ] {
2272 assert!(
2273 prompt_behavior_capability(s).is_some(),
2274 "'{s}' should map to a capability"
2275 );
2276 }
2277 }
2278
2279 #[test]
2280 fn prompt_behavior_rejects_unknown() {
2281 assert!(prompt_behavior_capability("").is_none());
2282 assert!(prompt_behavior_capability("bogus").is_none());
2283 }
2284
2285 #[test]
2286 fn prompt_behavior_ignore_is_user_prompt_handler_type() {
2287 match prompt_behavior_capability("ignore") {
2288 Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {
2289 }
2290 other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
2291 }
2292 }
2293
2294 #[test]
2297 fn wire_string_extracts_value() {
2298 let v = serde_json::json!({"type": "string", "value": "hello"});
2299 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
2300 }
2301
2302 #[test]
2303 fn wire_number_passthrough() {
2304 let v = serde_json::json!({"type": "number", "value": 42.5});
2305 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
2306 }
2307
2308 #[test]
2309 fn wire_boolean_extracts_bool() {
2310 let v = serde_json::json!({"type": "boolean", "value": true});
2311 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
2312 }
2313
2314 #[test]
2315 fn wire_null_returns_null() {
2316 let v = serde_json::json!({"type": "null"});
2317 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2318 }
2319
2320 #[test]
2321 fn wire_undefined_returns_null() {
2322 let v = serde_json::json!({"type": "undefined"});
2323 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2324 }
2325
2326 #[test]
2327 fn wire_bigint_returns_string() {
2328 let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
2329 assert_eq!(
2330 bidi_wire_value_to_json(&v),
2331 serde_json::json!("9007199254740993")
2332 );
2333 }
2334
2335 #[test]
2336 fn wire_object_recurse() {
2337 let v = serde_json::json!({
2338 "type": "object",
2339 "value": [
2340 ["a", {"type": "string", "value": "alpha"}],
2341 ["b", {"type": "number", "value": 2}]
2342 ]
2343 });
2344 let out = bidi_wire_value_to_json(&v);
2345 assert_eq!(out["a"], "alpha");
2346 assert_eq!(out["b"], 2);
2347 }
2348
2349 #[test]
2350 fn wire_array_recurse() {
2351 let v = serde_json::json!({
2352 "type": "array",
2353 "value": [
2354 {"type": "string", "value": "x"},
2355 {"type": "number", "value": 1}
2356 ]
2357 });
2358 let out = bidi_wire_value_to_json(&v);
2359 assert_eq!(out, serde_json::json!(["x", 1]));
2360 }
2361
2362 #[test]
2363 fn wire_unknown_type_clones_raw() {
2364 let v = serde_json::json!({"type": "special", "payload": 99});
2365 assert_eq!(bidi_wire_value_to_json(&v), v);
2366 }
2367
2368 #[test]
2369 fn wire_missing_type_clones_raw() {
2370 let v = serde_json::json!({"payload": 99});
2371 assert_eq!(bidi_wire_value_to_json(&v), v);
2372 }
2373
2374 #[test]
2377 fn rv_string_value() {
2378 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2379 StringValue::new(StringValueType::String, "hi"),
2380 ));
2381 assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
2382 }
2383
2384 #[test]
2385 fn rv_number_value() {
2386 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2387 NumberValue::new(NumberValueType::Number, 2.5),
2388 ));
2389 assert_eq!(remote_value_to_json(&rv), serde_json::json!(2.5));
2390 }
2391
2392 #[test]
2393 fn rv_boolean_value() {
2394 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2395 BooleanValue::new(BooleanValueType::Boolean, true),
2396 ));
2397 assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
2398 }
2399
2400 #[test]
2401 fn rv_null_value() {
2402 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
2403 NullValue::new(NullValueType::Null),
2404 ));
2405 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2406 }
2407
2408 #[test]
2409 fn rv_undefined_value() {
2410 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
2411 UndefinedValue::new(UndefinedValueType::Undefined),
2412 ));
2413 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2414 }
2415
2416 #[test]
2417 fn rv_bigint_value() {
2418 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
2419 BigIntValue::new(BigIntValueType::Bigint, "999n"),
2420 ));
2421 assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
2422 }
2423
2424 #[test]
2425 fn rv_array_value() {
2426 let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2427 StringValue::new(StringValueType::String, "item"),
2428 ));
2429 let arr = ArrayRemoteValue {
2430 r#type: ArrayRemoteValueType::Array,
2431 handle: None,
2432 internal_id: None,
2433 value: Some(ListRemoteValue::new(vec![inner])),
2434 };
2435 let rv = RemoteValue::ArrayRemoteValue(arr);
2436 assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
2437 }
2438
2439 #[test]
2440 fn rv_object_value() {
2441 let obj = ObjectRemoteValue {
2442 r#type: ObjectRemoteValueType::Object,
2443 handle: None,
2444 internal_id: None,
2445 value: Some(MappingRemoteValue::new(vec![vec![
2446 serde_json::json!("key"),
2447 serde_json::json!({"type": "string", "value": "val"}),
2448 ]])),
2449 };
2450 let rv = RemoteValue::ObjectRemoteValue(obj);
2451 let out = remote_value_to_json(&rv);
2452 assert_eq!(out["key"], "val");
2453 }
2454
2455 #[test]
2456 fn rv_object_value_wire_key() {
2457 let obj = ObjectRemoteValue {
2458 r#type: ObjectRemoteValueType::Object,
2459 handle: None,
2460 internal_id: None,
2461 value: Some(MappingRemoteValue::new(vec![vec![
2462 serde_json::json!({"type": "string", "value": "key"}),
2463 serde_json::json!({"type": "string", "value": "val"}),
2464 ]])),
2465 };
2466 let rv = RemoteValue::ObjectRemoteValue(obj);
2467 let out = remote_value_to_json(&rv);
2468 assert_eq!(out["key"], "val");
2469 }
2470
2471 #[test]
2472 fn bidi_wire_value_to_json_object_wire_key() {
2473 let raw = serde_json::json!({
2474 "type": "object",
2475 "value": [
2476 [
2477 {"type": "string", "value": "wire_key"},
2478 {"type": "number", "value": 42}
2479 ]
2480 ]
2481 });
2482 let out = bidi_wire_value_to_json(&raw);
2483 assert_eq!(out["wire_key"], 42);
2484 }
2485
2486 #[test]
2487 fn rv_unsupported_returns_null() {
2488 let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
2489 rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
2490 );
2491 let rv = RemoteValue::SymbolRemoteValue(sym);
2492 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2493 }
2494
2495 #[test]
2498 fn eval_result_into_value_deserializes() {
2499 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2500 StringValue::new(StringValueType::String, "deserialized"),
2501 ));
2502 let er = EvaluationResult::new(rv);
2503 let s: String = er.into_value().unwrap();
2504 assert_eq!(s, "deserialized");
2505 }
2506
2507 #[test]
2508 fn eval_result_into_value_number() {
2509 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2510 NumberValue::new(NumberValueType::Number, 42i32),
2511 ));
2512 let er = EvaluationResult::new(rv);
2513 let n: i32 = er.into_value().unwrap();
2514 assert_eq!(n, 42);
2515 }
2516
2517 #[test]
2518 fn eval_result_remote_value_accessor() {
2519 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2520 BooleanValue::new(BooleanValueType::Boolean, false),
2521 ));
2522 let er = EvaluationResult::new(rv.clone());
2523 assert_eq!(er.remote_value(), &rv);
2524 }
2525
2526 #[test]
2529 fn fox_browser_config_default_is_headless_false() {
2530 let cfg = FoxBrowserConfig::default();
2531 assert!(!cfg.headless);
2532 assert!(cfg.executable_path.is_none());
2533 assert!(cfg.profile_dir.is_none());
2534 assert_eq!(cfg.viewport_width, 0);
2535 assert_eq!(cfg.viewport_height, 0);
2536 assert!(cfg.user_agent.is_none());
2537 assert!(cfg.user_js_content.is_none());
2538 }
2539
2540 #[test]
2543 fn write_user_js_creates_file() {
2544 let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2545 let _ = std::fs::remove_dir_all(&tmp);
2546 let content = "user_pref(\"test\", true);\n";
2547 write_user_js(tmp.to_str().unwrap(), content).unwrap();
2548 let path = tmp.join("user.js");
2549 assert!(path.exists());
2550 let read = std::fs::read_to_string(&path).unwrap();
2551 assert_eq!(read, content);
2552 let _ = std::fs::remove_dir_all(&tmp);
2553 }
2554
2555 #[test]
2556 fn write_user_js_creates_nested_dirs() {
2557 let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2558 let _ = std::fs::remove_dir_all(&tmp);
2559 let nested = tmp.join("a").join("b");
2560 write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2561 assert!(nested.join("user.js").exists());
2562 let _ = std::fs::remove_dir_all(&tmp);
2563 }
2564
2565 #[test]
2568 fn proxy_from_url_http_no_auth() {
2569 let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2570 assert_eq!(p.scheme, ProxyScheme::Http);
2571 assert_eq!(p.host, "1.2.3.4");
2572 assert_eq!(p.port, 8080);
2573 assert!(p.username.is_none() && p.password.is_none());
2574 }
2575
2576 #[test]
2577 fn proxy_from_url_socks5_with_auth() {
2578 let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2579 assert_eq!(p.scheme, ProxyScheme::Socks5);
2580 assert_eq!(p.host, "gw.residential.net");
2581 assert_eq!(p.port, 1080);
2582 assert_eq!(p.username.as_deref(), Some("user"));
2583 assert_eq!(p.password.as_deref(), Some("pass"));
2584 }
2585
2586 #[test]
2587 fn proxy_from_url_bare_defaults_http() {
2588 let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2589 assert_eq!(p.scheme, ProxyScheme::Http);
2590 assert_eq!(p.host, "10.0.0.1");
2591 assert_eq!(p.port, 3128);
2592 }
2593
2594 #[test]
2595 fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2596 assert!(ProxyConfig::from_url("http://nohost").is_err());
2597 assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2598 assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2599 }
2600
2601 #[test]
2602 fn proxy_prefs_http_emits_http_ssl_and_type() {
2603 let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2604 assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2605 assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2606 assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2607 assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2608 assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2609 assert!(!prefs.contains("network.proxy.socks"));
2611 }
2612
2613 #[test]
2614 fn proxy_prefs_socks5_emits_socks_and_version() {
2615 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2616 assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2617 assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2618 assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2619 assert!(!prefs.contains("network.proxy.http_port"));
2621 }
2622
2623 #[test]
2624 fn proxy_prefs_close_the_webrtc_ip_leak_for_both_schemes() {
2625 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2630 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2631 assert!(
2632 prefs.contains(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#),
2633 "{url}: must force ICE through the proxy (no direct-UDP srflx leak)"
2634 );
2635 assert!(
2636 prefs.contains(r#"user_pref("media.peerconnection.ice.no_host", true);"#),
2637 "{url}: must suppress LAN host candidates"
2638 );
2639 assert!(
2640 prefs.contains(
2641 r#"user_pref("media.peerconnection.ice.default_address_only", true);"#
2642 ),
2643 "{url}: must expose only the default route address"
2644 );
2645 assert!(
2647 !prefs.contains("media.peerconnection.enabled"),
2648 "{url}: must NOT disable WebRTC outright (a fingerprint tell); only confine ICE"
2649 );
2650 }
2651 }
2652
2653 #[test]
2654 fn proxy_prefs_close_the_dns_prefetch_leak_for_both_schemes() {
2655 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2659 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2660 assert!(
2661 prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#),
2662 "{url}: must disable DNS prefetch (a `dns-prefetch` link leaks a clear-text query)"
2663 );
2664 assert!(
2665 prefs.contains(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#),
2666 "{url}: must disable DNS prefetch from HTTPS origins too"
2667 );
2668 assert!(
2669 prefs.contains(r#"user_pref("network.predictor.enabled", false);"#),
2670 "{url}: must disable the network predictor (history-driven pre-resolution)"
2671 );
2672 assert!(
2673 prefs.contains(r#"user_pref("network.http.speculative-parallel-limit", 0);"#),
2674 "{url}: must stop speculative parallel connections"
2675 );
2676 assert!(
2677 prefs.contains(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#),
2678 "{url}: must stop urlbar speculative connect"
2679 );
2680 }
2681 }
2682
2683 #[test]
2684 fn proxy_prefs_socks_keeps_remote_dns_alongside_the_leak_guards() {
2685 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2689 assert!(prefs.contains(r#"user_pref("network.proxy.socks_remote_dns", true);"#));
2690 assert!(prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#));
2691 }
2692
2693 #[test]
2696 fn scroll_direction_up_not_eq_down() {
2697 assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2698 }
2699
2700 #[test]
2701 fn scroll_direction_clone_copy() {
2702 let a = ScrollDirection::Up;
2703 let b = a;
2704 assert_eq!(a, b); }
2706
2707 #[test]
2708 fn scroll_direction_debug() {
2709 let s = format!("{:?}", ScrollDirection::Down);
2710 assert!(s.contains("Down"));
2711 }
2712 #[test]
2713 fn remote_value_to_json_handles_string_number_values() {
2714 let rv: RemoteValue = serde_json::from_value(serde_json::json!({
2715 "type": "number",
2716 "value": "NaN"
2717 }))
2718 .unwrap();
2719 let json = remote_value_to_json(&rv);
2720 assert_eq!(json, serde_json::Value::String("NaN".to_string()));
2721 }
2722
2723 #[test]
2724 fn remote_value_to_json_handles_date_and_regexp() {
2725 let regexp_rv: RemoteValue = serde_json::from_value(serde_json::json!({
2726 "type": "regexp",
2727 "value": {
2728 "pattern": "abc",
2729 "flags": "gi"
2730 }
2731 }))
2732 .unwrap();
2733 assert_eq!(
2734 remote_value_to_json(®exp_rv),
2735 serde_json::Value::String("/abc/gi".to_string())
2736 );
2737
2738 let date_rv: RemoteValue = serde_json::from_value(serde_json::json!({
2739 "type": "date",
2740 "value": "2026-08-07T00:00:00.000Z"
2741 }))
2742 .unwrap();
2743 assert_eq!(
2744 remote_value_to_json(&date_rv),
2745 serde_json::Value::String("2026-08-07T00:00:00.000Z".to_string())
2746 );
2747 }
2748 #[test]
2749 fn bidi_wire_value_to_json_handles_bool_keys() {
2750 let raw = serde_json::json!({
2751 "type": "object",
2752 "value": [
2753 [
2754 {"type": "boolean", "value": true},
2755 {"type": "number", "value": 100}
2756 ]
2757 ]
2758 });
2759 let out = bidi_wire_value_to_json(&raw);
2760 assert_eq!(out["true"], 100);
2761 }
2762
2763 #[test]
2764 fn preload_script_detection_identifies_fn_declarations() {
2765 let fn_decl = "() => { window.x = 1; }";
2766 let trimmed = fn_decl.trim();
2767 let is_fn_decl = trimmed.starts_with("() =>")
2768 || trimmed.starts_with("async () =>")
2769 || trimmed.starts_with("function")
2770 || trimmed.starts_with("async function");
2771 assert!(is_fn_decl);
2772
2773 let stmt = "window.x = 1;";
2774 let trimmed_stmt = stmt.trim();
2775 let is_fn_decl_stmt = trimmed_stmt.starts_with("() =>")
2776 || trimmed_stmt.starts_with("async () =>")
2777 || trimmed_stmt.starts_with("function")
2778 || trimmed_stmt.starts_with("async function");
2779 assert!(!is_fn_decl_stmt);
2780 }
2781
2782 #[test]
2783 fn set_cookie_domain_strips_leading_dot() {
2784 let domain = ".example.com";
2785 let normalized = domain.trim_start_matches('.');
2786 assert_eq!(normalized, "example.com");
2787 }
2788}