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 Some(serde_json::Value::Array(items)) = Some(pair) {
256 if items.len() >= 2 {
257 if let (Some(k), Some(val)) = (items[0].as_str(), items.get(1)) {
258 map.insert(k.to_string(), bidi_wire_value_to_json(val));
259 }
260 }
261 }
262 }
263 }
264 serde_json::Value::Object(map)
265 }
266 Some("array") => {
267 let arr: Vec<serde_json::Value> = v
268 .get("value")
269 .and_then(|v| v.as_array())
270 .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
271 .unwrap_or_default();
272 serde_json::Value::Array(arr)
273 }
274 _ => v.clone(),
275 }
276}
277
278fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
280 match rv {
281 RemoteValue::PrimitiveProtocolValue(p) => match p {
282 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
283 serde_json::Value::String(s.value.clone())
284 }
285 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
286 match &n.value {
287 serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
288 _ => serde_json::Value::Null,
289 }
290 }
291 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
292 serde_json::Value::Bool(b.value)
293 }
294 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
295 serde_json::Value::Null
296 }
297 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(
298 _,
299 ) => serde_json::Value::Null,
300 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
301 serde_json::Value::String(b.value.clone())
302 }
303 },
304 RemoteValue::ArrayRemoteValue(a) => {
305 let arr: Vec<serde_json::Value> = a
306 .value
307 .as_ref()
308 .map(|v| v.inner().iter().map(remote_value_to_json).collect())
309 .unwrap_or_default();
310 serde_json::Value::Array(arr)
311 }
312 RemoteValue::ObjectRemoteValue(o) => {
313 let mut map = serde_json::Map::new();
314 if let Some(ref mapping) = o.value {
315 for pair in mapping.inner() {
316 if pair.len() >= 2 {
317 if let (Some(serde_json::Value::String(k)), Some(v)) =
318 (pair.first(), pair.get(1))
319 {
320 map.insert(k.clone(), bidi_wire_value_to_json(v));
321 }
322 }
323 }
324 }
325 serde_json::Value::Object(map)
326 }
327 _ => serde_json::Value::Null,
328 }
329}
330
331pub struct Element {
333 pub(crate) node: tokio::sync::Mutex<FoxNode>,
334 pub(crate) selector: String,
335}
336
337impl Element {
338 pub async fn click(&self) -> Result<()> {
340 let mut node = self.node.lock().await;
341 node.mouse_click()
342 .await
343 .map_err(|e| anyhow!("element click failed: {e:?}"))?;
344 Ok(())
345 }
346
347 pub fn selector(&self) -> &str {
349 &self.selector
350 }
351
352 pub async fn type_text(&self, text: &str) -> Result<()> {
354 let mut node = self.node.lock().await;
355 node.type_text(text.to_string())
356 .await
357 .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
358 Ok(())
359 }
360
361 pub async fn type_str(&self, text: &str) -> Result<()> {
363 self.type_text(text).await
364 }
365}
366
367type FoxBrowser = FirefoxBrowser;
369type FoxNode =
370 rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum ScrollDirection {
375 Up,
376 Down,
377}
378
379impl Page {
380 pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
382 launch_firefox(config.unwrap_or_default()).await
383 }
384
385 pub async fn goto(&self, url: &str) -> Result<()> {
387 let mut browser = self.browser.lock().await;
388 let browser = match &mut *browser {
389 Some(b) => b,
390 None => return Err(anyhow!("browser closed")),
391 };
392 browser
393 .navigate(url)
394 .await
395 .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
396 Ok(())
397 }
398
399 pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
405 let mut browser = self.browser.lock().await;
406 let browser = match &mut *browser {
407 Some(b) => b,
408 None => return Err(anyhow!("browser closed")),
409 };
410 let result = browser
411 .evaluate_script(expr.into(), false)
412 .await
413 .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
414 Ok(EvaluationResult::new(result.result))
415 }
416
417 pub async fn evaluate_await(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
426 let mut browser = self.browser.lock().await;
427 let browser = match &mut *browser {
428 Some(b) => b,
429 None => return Err(anyhow!("browser closed")),
430 };
431 let result = browser
432 .evaluate_script(expr.into(), true)
433 .await
434 .map_err(|e| anyhow!("evaluate_await failed: {e:?}"))?;
435 Ok(EvaluationResult::new(result.result))
436 }
437
438 pub async fn evaluate_in_context(
440 &self,
441 expr: impl Into<String>,
442 context: &FrameId,
443 ) -> Result<EvaluationResult> {
444 let mut browser = self.browser.lock().await;
445 let browser = match &mut *browser {
446 Some(b) => b,
447 None => return Err(anyhow!("browser closed")),
448 };
449 let options = EvaluateScriptOptionsBuilder::default()
450 .target(Target::ContextTarget(ContextTarget::new(context.clone())))
451 .build();
452 let result = browser
453 .evaluate_script_with_options(expr.into(), false, options)
454 .await
455 .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
456 Ok(EvaluationResult::new(result.result))
457 }
458
459 pub async fn find_element(&self, selector: &str) -> Result<Element> {
461 let mut browser = self.browser.lock().await;
462 let browser = match &mut *browser {
463 Some(b) => b,
464 None => return Err(anyhow!("browser closed")),
465 };
466 let locator =
467 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
468 match browser.find_node(locator).await {
469 Ok(Some(node)) => Ok(Element {
470 node: tokio::sync::Mutex::new(node),
471 selector: selector.to_string(),
472 }),
473 Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
474 Err(e) => Err(anyhow!("find_element failed: {e:?}")),
475 }
476 }
477
478 pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
480 let mut browser = self.browser.lock().await;
481 let browser = match &mut *browser {
482 Some(b) => b,
483 None => return Err(anyhow!("browser closed")),
484 };
485 let locator =
486 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
487 let nodes = browser
488 .find_nodes(locator)
489 .await
490 .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
491 Ok(nodes
492 .into_iter()
493 .map(|n| Element {
494 node: tokio::sync::Mutex::new(n),
495 selector: selector.to_string(),
496 })
497 .collect())
498 }
499
500 pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
508 if files.is_empty() {
509 return Err(anyhow!("set_files: no files provided"));
510 }
511 let element = self.find_element(selector).await?;
515 let (shared_id, context) = {
516 let node = element.node.lock().await;
517 let id = node.get_shared_id().cloned().ok_or_else(|| {
518 anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
519 })?;
520 (id, node.get_context_id().clone())
521 };
522 let element_ref: SharedReference = SharedReference::builder()
523 .shared_id(shared_id)
524 .build()
525 .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
526 let command = SetFiles::builder()
527 .context(context)
528 .element(element_ref)
529 .files(files)
530 .build()
531 .map_err(|e| anyhow!("set_files: build command: {e}"))?;
532 let mut browser = self.browser.lock().await;
533 let browser = match &mut *browser {
534 Some(b) => b,
535 None => return Err(anyhow!("browser closed")),
536 };
537 let response = browser
538 .driver_mut()
539 .send_command(command)
540 .await
541 .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
542 let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
543 .result
544 .try_into()
545 .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
546 Ok(())
547 }
548
549 pub async fn screenshot(&self) -> Result<Vec<u8>> {
551 let mut browser = self.browser.lock().await;
552 let browser = match &mut *browser {
553 Some(b) => b,
554 None => return Err(anyhow!("browser closed")),
555 };
556 let b64 = browser
557 .screenshot()
558 .await
559 .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
560 base64::engine::general_purpose::STANDARD
561 .decode(b64)
562 .map_err(|e| anyhow!("base64 decode failed: {e}"))
563 }
564
565 pub async fn reload(&self) -> Result<()> {
567 let mut browser = self.browser.lock().await;
568 let browser = match &mut *browser {
569 Some(b) => b,
570 None => return Err(anyhow!("browser closed")),
571 };
572 browser
573 .evaluate_script("location.reload()".to_string(), false)
574 .await
575 .map_err(|e| anyhow!("reload failed: {e:?}"))?;
576 Ok(())
577 }
578
579 pub async fn url(&self) -> Result<String> {
581 let eval = self.evaluate("document.URL").await?;
582 eval.into_value::<String>()
583 .map_err(|e| anyhow!("url deserialize failed: {e}"))
584 }
585
586 pub async fn title(&self) -> Result<String> {
588 let eval = self.evaluate("document.title").await?;
589 eval.into_value::<String>()
590 .map_err(|e| anyhow!("title deserialize failed: {e}"))
591 }
592
593 pub async fn frames(&self) -> Result<Vec<FrameId>> {
595 let browser = self.browser.lock().await;
596 let browser = match &*browser {
597 Some(b) => b,
598 None => return Err(anyhow!("browser closed")),
599 };
600 let contexts = browser
601 .driver()
602 .browsing_contexts
603 .lock()
604 .unwrap_or_else(|e| e.into_inner())
605 .iter()
606 .map(|c| c.id().clone())
607 .collect();
608 Ok(contexts)
609 }
610
611 pub async fn mainframe(&self) -> Result<Option<FrameId>> {
613 let browser = self.browser.lock().await;
614 let browser = match &*browser {
615 Some(b) => b,
616 None => return Err(anyhow!("browser closed")),
617 };
618 match browser.driver().get_active_context_id() {
619 Ok(ctx) => Ok(Some(ctx)),
620 Err(e) => {
621 tracing::debug!("get_active_context_id failed: {e:?}");
622 Ok(None)
623 }
624 }
625 }
626
627 pub async fn frame_execution_context(&self, frame_id: FrameId) -> Result<Option<FrameId>> {
629 let browser = self.browser.lock().await;
630 let browser = match &*browser {
631 Some(b) => b,
632 None => return Err(anyhow!("browser closed")),
633 };
634 let exists = browser
635 .driver()
636 .browsing_contexts
637 .lock()
638 .unwrap_or_else(|e| e.into_inner())
639 .iter()
640 .any(|c| c.id() == &frame_id);
641 Ok(if exists { Some(frame_id) } else { None })
642 }
643
644 pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
654 let frame_ids = self.frames().await?;
655 let main = self.mainframe().await?;
656 let mut out = Vec::with_capacity(frame_ids.len());
657 for fid in frame_ids {
658 let (url, name) = match self
662 .evaluate_in_context("({u: document.URL, n: (window.name || \"\")})", &fid)
663 .await
664 {
665 Ok(eval) => match eval.into_value::<serde_json::Value>() {
666 Ok(v) => (
667 v["u"].as_str().unwrap_or("").to_string(),
668 v["n"].as_str().unwrap_or("").to_string(),
669 ),
670 Err(_) => (String::new(), String::new()),
671 },
672 Err(e) => {
673 tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
674 (String::new(), String::new())
675 }
676 };
677 out.push(FrameInfo {
678 is_main: Some(&fid) == main.as_ref(),
679 id: fid.inner().to_string(),
680 url,
681 name,
682 });
683 }
684 Ok(out)
685 }
686
687 pub async fn frame_tree(&self) -> Result<Vec<FrameTreeNode>> {
703 use rustenium_bidi_definitions::browsing_context::commands::GetTree;
704 use rustenium_bidi_definitions::browsing_context::results::GetTreeResult;
705 use rustenium_bidi_definitions::browsing_context::types::{Info, InfoList};
706
707 let command = GetTree::builder().build();
708 let mut browser = self.browser.lock().await;
709 let browser = match &mut *browser {
710 Some(b) => b,
711 None => return Err(anyhow!("browser closed")),
712 };
713 let response = browser
714 .driver_mut()
715 .send_command(command)
716 .await
717 .map_err(|e| anyhow!("browsingContext.getTree BiDi command failed: {e:?}"))?;
718 let result: GetTreeResult = response
719 .result
720 .try_into()
721 .map_err(|e| anyhow!("browsingContext.getTree result parse failed: {e}"))?;
722
723 fn walk(
727 list: &InfoList,
728 parent: Option<&FrameId>,
729 depth: usize,
730 out: &mut Vec<FrameTreeNode>,
731 ) {
732 for info in list.inner() {
733 let info: &Info = info;
734 out.push(FrameTreeNode {
735 id: info.context.clone(),
736 url: info.url.clone(),
737 parent: parent.cloned(),
738 depth,
739 });
740 if let Some(children) = &info.children {
741 walk(children, Some(&info.context), depth + 1, out);
742 }
743 }
744 }
745 let mut out = Vec::new();
746 walk(&result.contexts, None, 0, &mut out);
747 Ok(out)
748 }
749
750 pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
763 self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
764 .await
765 }
766
767 pub async fn resolve_frame_within(
770 &self,
771 spec: &str,
772 timeout: std::time::Duration,
773 ) -> Result<FrameId> {
774 let parsed = FrameSpec::parse(spec);
775 let deadline = std::time::Instant::now() + timeout;
776 loop {
777 if let Some(fid) = self.try_resolve_frame(&parsed).await? {
778 return Ok(fid);
779 }
780 if std::time::Instant::now() >= deadline {
781 return Err(anyhow!(
782 "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
783 ));
784 }
785 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
786 }
787 }
788
789 async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
792 if matches!(parsed, FrameSpec::Main) {
793 return self.mainframe().await;
794 }
795 let frames = self.frames().await?;
796 match parsed {
797 FrameSpec::Main => unreachable!(),
798 FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
799 FrameSpec::IdOrIndex(id, idx) => {
800 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
802 return Ok(Some(fid.clone()));
803 }
804 Ok(frames.get(*idx).cloned())
805 }
806 FrameSpec::Id(id) => {
807 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
808 return Ok(Some(fid.clone()));
809 }
810 self.frame_by_url_contains(id).await
813 }
814 FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
815 FrameSpec::NameEquals(name) => {
816 for info in self.list_frames().await? {
817 if &info.name == name {
818 return Ok(Some(FrameId::new(info.id)));
819 }
820 }
821 Ok(None)
822 }
823 }
824 }
825
826 async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
829 for info in self.list_frames().await? {
830 if info.url.contains(sub) {
831 return Ok(Some(FrameId::new(info.id)));
832 }
833 }
834 Ok(None)
835 }
836
837 pub async fn eval_in_frame(
842 &self,
843 spec: &str,
844 expr: impl Into<String>,
845 ) -> Result<EvaluationResult> {
846 let fid = self.resolve_frame(spec).await?;
847 self.evaluate_in_context(expr, &fid).await
848 }
849
850 pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
857 let fid = self.resolve_frame(spec).await?;
858 let escaped = crate::frame::escape_js_string(selector);
859 let js = format!(
860 r#"(function() {{
861 const el = document.querySelector('{escaped}');
862 if (!el) return null;
863 const r = el.getBoundingClientRect();
864 if (r.width <= 0 || r.height <= 0) return null;
865 return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
866 }})()"#
867 );
868 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
871 loop {
872 if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
873 if let Ok(val) = eval.into_value::<serde_json::Value>() {
874 if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
875 return self.click_at_in(&fid, x, y).await;
876 }
877 }
878 }
879 if std::time::Instant::now() >= deadline {
880 return Err(anyhow!(
881 "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
882 ));
883 }
884 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
885 }
886 }
887
888 pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
892 let fid = self.resolve_frame(spec).await?;
893 let escaped = crate::frame::escape_js_string(selector);
894 let focus_js = format!(
895 r#"(function() {{
896 const el = document.querySelector('{escaped}');
897 if (!el) return false;
898 el.focus();
899 return document.activeElement === el;
900 }})()"#
901 );
902 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
904 loop {
905 let focused = self
906 .evaluate_in_context(&focus_js, &fid)
907 .await
908 .ok()
909 .and_then(|e| e.into_value::<bool>().ok())
910 .unwrap_or(false);
911 if focused {
912 break;
913 }
914 if std::time::Instant::now() >= deadline {
915 return Err(anyhow!(
916 "type_in_frame: could not focus '{selector}' in frame '{spec}'"
917 ));
918 }
919 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
920 }
921 let browser = self.browser.lock().await;
922 let browser = match &*browser {
923 Some(b) => b,
924 None => return Err(anyhow!("browser closed")),
925 };
926 browser
927 .keyboard()
928 .type_text(text, &fid, None)
929 .await
930 .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
931 Ok(())
932 }
933
934 pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
948 let mut browser = self.browser.lock().await;
949 let browser = match &mut *browser {
950 Some(b) => b,
951 None => return Err(anyhow!("browser closed")),
952 };
953 let log = crate::dialog::DialogLog::new();
954 let handler = crate::dialog::make_dialog_handler(log.clone());
955 let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
956 browser
957 .subscribe_events(events, handler)
958 .await
959 .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
960 Ok(log)
961 }
962
963 pub async fn start_sensors(&self) -> Result<String> {
977 let id = self
978 .add_preload_script(crate::sensors::SENSOR_SCRIPT)
979 .await?;
980 let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
984 Ok(id)
985 }
986
987 pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
991 let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
992 eval.into_value::<serde_json::Value>()
993 .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
994 }
995
996 pub async fn handle_user_prompt(
1002 &self,
1003 context: Option<&FrameId>,
1004 accept: bool,
1005 user_text: Option<&str>,
1006 ) -> Result<()> {
1007 let ctx = match context {
1008 Some(c) => c.clone(),
1009 None => self
1010 .mainframe()
1011 .await?
1012 .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
1013 };
1014 let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
1015 if let Some(text) = user_text {
1016 builder = builder.user_text(text.to_string());
1017 }
1018 let command = builder
1019 .build()
1020 .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
1021 let mut browser = self.browser.lock().await;
1022 let browser = match &mut *browser {
1023 Some(b) => b,
1024 None => return Err(anyhow!("browser closed")),
1025 };
1026 let response = browser
1027 .driver_mut()
1028 .send_command(command)
1029 .await
1030 .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
1031 let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
1032 response
1033 .result
1034 .try_into()
1035 .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
1036 Ok(())
1037 }
1038
1039 pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
1045 let browser = self.browser.lock().await;
1046 let browser = match &*browser {
1047 Some(b) => b,
1048 None => return Err(anyhow!("browser closed")),
1049 };
1050 let context = browser
1051 .driver()
1052 .get_active_context_id()
1053 .map_err(|e| anyhow!("{e:?}"))?;
1054 let hm = browser.human_mouse();
1055 hm.set_last_position(Point { x: x0, y: y0 });
1056 hm.move_to(
1057 Point { x: x1, y: y1 },
1058 &context,
1059 MouseMoveOptions::default(),
1060 )
1061 .await
1062 .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
1063 Ok(())
1064 }
1065
1066 pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
1068 let browser = self.browser.lock().await;
1069 let browser = match &*browser {
1070 Some(b) => b,
1071 None => return Err(anyhow!("browser closed")),
1072 };
1073 let context = browser
1074 .driver()
1075 .get_active_context_id()
1076 .map_err(|e| anyhow!("{e:?}"))?;
1077 let hm = browser.human_mouse();
1078 hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
1079 .await
1080 .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
1081 hm.down(
1082 &context,
1083 MouseOptions {
1084 button: Some(MouseButton::Left),
1085 },
1086 )
1087 .await
1088 .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
1089 Ok(())
1090 }
1091
1092 pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
1094 let browser = self.browser.lock().await;
1095 let browser = match &*browser {
1096 Some(b) => b,
1097 None => return Err(anyhow!("browser closed")),
1098 };
1099 let context = browser
1100 .driver()
1101 .get_active_context_id()
1102 .map_err(|e| anyhow!("{e:?}"))?;
1103 let hm = browser.human_mouse();
1104 hm.up(
1105 &context,
1106 MouseOptions {
1107 button: Some(MouseButton::Left),
1108 },
1109 )
1110 .await
1111 .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
1112 Ok(())
1113 }
1114
1115 pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
1125 let context = self
1126 .mainframe()
1127 .await?
1128 .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
1129 self.click_at_in(&context, x, y).await
1130 }
1131
1132 pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
1143 let browser = self.browser.lock().await;
1144 let browser = match &*browser {
1145 Some(b) => b,
1146 None => return Err(anyhow!("browser closed")),
1147 };
1148 let hm = browser.human_mouse();
1149 hm.set_last_position(Point { x, y });
1157 let options = MouseClickOptions {
1158 button: Some(MouseButton::Left),
1159 count: Some(1),
1160 delay: Some(80),
1161 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1162 };
1163 hm.click(Some(Point { x, y }), context, options)
1164 .await
1165 .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
1166 Ok(())
1167 }
1168
1169 pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
1181 let browser = self.browser.lock().await;
1182 let browser = match &*browser {
1183 Some(b) => b,
1184 None => return Err(anyhow!("browser closed")),
1185 };
1186 let context = browser
1187 .driver()
1188 .get_active_context_id()
1189 .map_err(|e| anyhow!("{e:?}"))?;
1190 browser
1191 .mouse()
1192 .move_to(
1193 Point { x, y },
1194 &context,
1195 MouseMoveOptions {
1196 steps: Some(0),
1197 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1198 },
1199 )
1200 .await
1201 .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
1202 Ok(())
1203 }
1204
1205 pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
1207 let browser = self.browser.lock().await;
1208 let browser = match &*browser {
1209 Some(b) => b,
1210 None => return Err(anyhow!("browser closed")),
1211 };
1212 let context = browser
1213 .driver()
1214 .get_active_context_id()
1215 .map_err(|e| anyhow!("{e:?}"))?;
1216 browser
1217 .mouse()
1218 .wheel(
1219 &context,
1220 MouseWheelOptions {
1221 delta_x: Some(dx),
1222 delta_y: Some(dy),
1223 },
1224 )
1225 .await
1226 .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1227 Ok(())
1228 }
1229
1230 pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1232 let browser = self.browser.lock().await;
1233 let browser = match &*browser {
1234 Some(b) => b,
1235 None => return Err(anyhow!("browser closed")),
1236 };
1237 let context = browser
1238 .driver()
1239 .get_active_context_id()
1240 .map_err(|e| anyhow!("{e:?}"))?;
1241 let y_distance = match direction {
1242 ScrollDirection::Down => amount as i32,
1243 ScrollDirection::Up => -(amount as i32),
1244 };
1245 browser
1246 .human_mouse()
1247 .scroll(y_distance, 0, &context)
1248 .await
1249 .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1250 Ok(())
1251 }
1252
1253 pub async fn type_text(&self, text: &str) -> Result<()> {
1255 let browser = self.browser.lock().await;
1256 let browser = match &*browser {
1257 Some(b) => b,
1258 None => return Err(anyhow!("browser closed")),
1259 };
1260 let context = browser
1261 .driver()
1262 .get_active_context_id()
1263 .map_err(|e| anyhow!("{e:?}"))?;
1264 browser
1265 .keyboard()
1266 .type_text(text, &context, None)
1267 .await
1268 .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1269 Ok(())
1270 }
1271
1272 pub async fn key_down(&self, key: &str) -> Result<()> {
1274 let browser = self.browser.lock().await;
1275 let browser = match &*browser {
1276 Some(b) => b,
1277 None => return Err(anyhow!("browser closed")),
1278 };
1279 let context = browser
1280 .driver()
1281 .get_active_context_id()
1282 .map_err(|e| anyhow!("{e:?}"))?;
1283 browser
1284 .keyboard()
1285 .down(key, &context)
1286 .await
1287 .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1288 Ok(())
1289 }
1290
1291 pub async fn key_up(&self, key: &str) -> Result<()> {
1293 let browser = self.browser.lock().await;
1294 let browser = match &*browser {
1295 Some(b) => b,
1296 None => return Err(anyhow!("browser closed")),
1297 };
1298 let context = browser
1299 .driver()
1300 .get_active_context_id()
1301 .map_err(|e| anyhow!("{e:?}"))?;
1302 browser
1303 .keyboard()
1304 .up(key, &context)
1305 .await
1306 .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1307 Ok(())
1308 }
1309
1310 pub async fn key_press(&self, key: &str) -> Result<()> {
1312 let browser = self.browser.lock().await;
1313 let browser = match &*browser {
1314 Some(b) => b,
1315 None => return Err(anyhow!("browser closed")),
1316 };
1317 let context = browser
1318 .driver()
1319 .get_active_context_id()
1320 .map_err(|e| anyhow!("{e:?}"))?;
1321 browser
1322 .keyboard()
1323 .press(key, &context, None)
1324 .await
1325 .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1326 Ok(())
1327 }
1328
1329 pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1346 let function_declaration = format!("() => {{\n{source}\n}}");
1347 let mut browser = self.browser.lock().await;
1348 let browser = match &mut *browser {
1349 Some(b) => b,
1350 None => return Err(anyhow!("browser closed")),
1351 };
1352 let id = browser
1353 .add_preload_script(function_declaration)
1354 .await
1355 .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1356 Ok(id)
1357 }
1358
1359 pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1361 let mut browser = self.browser.lock().await;
1362 let browser = match &mut *browser {
1363 Some(b) => b,
1364 None => return Err(anyhow!("browser closed")),
1365 };
1366 let response = browser
1367 .driver_mut()
1368 .send_command(GetCookies {
1369 method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1370 params: Default::default(),
1371 })
1372 .await
1373 .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1374 let result: rustenium_bidi_definitions::storage::results::GetCookiesResult = response
1375 .result
1376 .try_into()
1377 .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1378 Ok(result
1379 .cookies
1380 .into_iter()
1381 .map(|c| crate::cookies::CapturedCookie {
1382 name: c.name,
1383 value: match c.value {
1384 BytesValue::StringValue(s) => s.value,
1385 BytesValue::Base64Value(b) => b.value,
1386 },
1387 domain: c.domain,
1388 path: c.path,
1389 expires: c.expiry.map(|e| e as i64),
1390 secure: c.secure,
1391 http_only: c.http_only,
1392 same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1393 })
1394 .collect())
1395 }
1396
1397 #[allow(clippy::too_many_arguments)] pub async fn set_cookie(
1400 &self,
1401 name: &str,
1402 value: &str,
1403 domain: &str,
1404 path: Option<&str>,
1405 expires: Option<u64>,
1406 secure: Option<bool>,
1407 http_only: Option<bool>,
1408 same_site: Option<SameSite>,
1409 ) -> Result<()> {
1410 let mut browser = self.browser.lock().await;
1411 let browser = match &mut *browser {
1412 Some(b) => b,
1413 None => return Err(anyhow!("browser closed")),
1414 };
1415 let cookie = PartialCookie {
1416 name: name.to_string(),
1417 value: BytesValue::StringValue(StringValue::new(
1418 StringValueType::String,
1419 value.to_string(),
1420 )),
1421 domain: domain.to_string(),
1422 path: path.map(|p| p.to_string()),
1423 http_only,
1424 secure,
1425 same_site,
1426 expiry: expires,
1427 extensible: Default::default(),
1428 };
1429 let response = browser
1430 .driver_mut()
1431 .send_command(SetCookie {
1432 method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1433 params: SetCookieParams::new(cookie),
1434 })
1435 .await
1436 .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1437 let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1438 .result
1439 .try_into()
1440 .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1441 Ok(())
1442 }
1443
1444 pub fn profile_dir(&self) -> Option<&str> {
1446 self.profile_dir.as_deref()
1447 }
1448
1449 pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1463 let mut browser = self.browser.lock().await;
1464 let browser = match &mut *browser {
1465 Some(b) => b,
1466 None => return Err(anyhow!("browser closed")),
1467 };
1468 let log = crate::network::NetworkLog::new();
1469 let handler = crate::network::make_network_handler(log.clone());
1470 let events: HashSet<&str> = [
1471 "network.beforeRequestSent",
1472 "network.responseCompleted",
1473 "network.fetchError",
1474 ]
1475 .into_iter()
1476 .collect();
1477 browser
1478 .subscribe_events(events, handler)
1479 .await
1480 .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1481 Ok(log)
1482 }
1483
1484 pub async fn close(&self) -> Result<()> {
1492 let child_opt = self.child.lock().ok().and_then(|mut g| g.take());
1495
1496 if let Some(mut c) = child_opt {
1497 {
1516 let mut guard = self.browser.lock().await;
1517 if let Some(browser) = guard.as_mut() {
1518 let cmd = BrowserCommand::Close(BrowserCloseCmd {
1519 method: BrowserCloseMethod::Close,
1520 params: BrowserCloseParams {},
1521 });
1522 let _ = tokio::time::timeout(
1527 std::time::Duration::from_secs(10),
1528 browser.driver_mut().send_command(cmd),
1529 )
1530 .await;
1531 }
1532 }
1533 if !wait_for_exit(&mut c, 100).await {
1536 request_graceful_terminate(c.id());
1540 if !wait_for_exit(&mut c, 30).await {
1541 let _ = c.kill();
1542 let _ = c.wait();
1543 }
1544 }
1545 let _ = self.browser.lock().await.take();
1549 } else if let Some(browser) = self.browser.lock().await.take() {
1550 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1552 }
1553 Ok(())
1554 }
1555}
1556
1557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1563pub enum ProxyScheme {
1564 #[default]
1566 Http,
1567 Socks5,
1569}
1570
1571#[derive(Debug, Clone, Default)]
1581pub struct ProxyConfig {
1582 pub scheme: ProxyScheme,
1583 pub host: String,
1584 pub port: u16,
1585 pub username: Option<String>,
1586 pub password: Option<String>,
1587}
1588
1589impl ProxyConfig {
1590 pub fn from_url(url: &str) -> Result<Self> {
1593 let (scheme, rest) = match url.split_once("://") {
1594 Some((s, r)) => (s.to_ascii_lowercase(), r),
1595 None => ("http".to_string(), url),
1596 };
1597 let scheme = match scheme.as_str() {
1598 "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1599 "http" | "https" => ProxyScheme::Http,
1600 other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1601 };
1602 let (auth, hostport) = match rest.rsplit_once('@') {
1603 Some((a, hp)) => (Some(a), hp),
1604 None => (None, rest),
1605 };
1606 let (username, password) = match auth {
1607 Some(a) => match a.split_once(':') {
1608 Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1609 None => (Some(a.to_string()), None),
1610 },
1611 None => (None, None),
1612 };
1613 let (host, port) = hostport
1614 .rsplit_once(':')
1615 .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1616 let port: u16 = port
1617 .parse()
1618 .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1619 if host.is_empty() {
1620 return Err(anyhow!("proxy URL missing host: {url}"));
1621 }
1622 Ok(Self {
1623 scheme,
1624 host: host.to_string(),
1625 port,
1626 username,
1627 password,
1628 })
1629 }
1630}
1631
1632pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1634 if proxy.username.is_some() || proxy.password.is_some() {
1635 tracing::warn!(
1636 "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1637 front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1638 foxdriver at that. Emitting host:port prefs only."
1639 );
1640 }
1641 let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1642 match proxy.scheme {
1643 ProxyScheme::Http => {
1644 lines.push(format!(
1645 r#"user_pref("network.proxy.http", "{}");"#,
1646 proxy.host
1647 ));
1648 lines.push(format!(
1649 r#"user_pref("network.proxy.http_port", {});"#,
1650 proxy.port
1651 ));
1652 lines.push(format!(
1653 r#"user_pref("network.proxy.ssl", "{}");"#,
1654 proxy.host
1655 ));
1656 lines.push(format!(
1657 r#"user_pref("network.proxy.ssl_port", {});"#,
1658 proxy.port
1659 ));
1660 lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1661 }
1662 ProxyScheme::Socks5 => {
1663 lines.push(format!(
1664 r#"user_pref("network.proxy.socks", "{}");"#,
1665 proxy.host
1666 ));
1667 lines.push(format!(
1668 r#"user_pref("network.proxy.socks_port", {});"#,
1669 proxy.port
1670 ));
1671 lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1672 lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1673 }
1674 }
1675 lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1678
1679 lines.push(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#.to_string());
1692 lines.push(r#"user_pref("media.peerconnection.ice.no_host", true);"#.to_string());
1693 lines.push(r#"user_pref("media.peerconnection.ice.default_address_only", true);"#.to_string());
1694
1695 lines.push(r#"user_pref("network.dns.disablePrefetch", true);"#.to_string());
1705 lines.push(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#.to_string());
1706 lines.push(r#"user_pref("network.predictor.enabled", false);"#.to_string());
1707 lines.push(r#"user_pref("network.http.speculative-parallel-limit", 0);"#.to_string());
1708 lines.push(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#.to_string());
1709
1710 lines.push('\n'.to_string());
1711 lines.join("\n")
1712}
1713
1714#[derive(Debug, Clone, Default)]
1715pub struct FoxBrowserConfig {
1716 pub executable_path: Option<String>,
1717 pub profile_dir: Option<String>,
1730 pub headless: bool,
1731 pub viewport_width: u32,
1732 pub viewport_height: u32,
1733 pub user_agent: Option<String>,
1734 pub user_js_content: Option<String>,
1738 pub proxy: Option<ProxyConfig>,
1741 pub unhandled_prompt_behavior: Option<String>,
1749 pub env: Vec<(String, String)>,
1759}
1760
1761fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1765 let handler = match s.trim().to_ascii_lowercase().as_str() {
1766 "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1767 "dismiss" => UserPromptHandlerType::Dismiss,
1768 "ignore" => UserPromptHandlerType::Ignore,
1769 "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1770 UserPromptHandlerType::DismissAndNotify
1771 }
1772 _ => return None,
1773 };
1774 Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1775}
1776
1777fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1779 let dir = std::path::Path::new(profile_dir);
1780 std::fs::create_dir_all(dir)
1781 .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1782 let path = dir.join("user.js");
1783 std::fs::write(&path, content)
1784 .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1785 Ok(())
1786}
1787
1788pub async fn launch_firefox(mut config: FoxBrowserConfig) -> Result<Page> {
1790 let mut caps = FirefoxCapabilities::default();
1791 caps.accept_insecure_certs(true);
1792 if let Some(behavior) = config
1793 .unhandled_prompt_behavior
1794 .as_deref()
1795 .and_then(prompt_behavior_capability)
1796 {
1797 caps.unhandled_prompt_behavior(behavior);
1798 }
1799
1800 let mut args = Vec::new();
1801 if config.headless {
1802 args.push("--headless".to_string());
1803 }
1804 if let Some(ref ua) = config.user_agent {
1805 args.push(format!("--user-agent={}", ua));
1806 }
1807 if config.viewport_width > 0 {
1808 args.push(format!("--width={}", config.viewport_width));
1809 }
1810 if config.viewport_height > 0 {
1811 args.push(format!("--height={}", config.viewport_height));
1812 }
1813
1814 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1819 if let Some(ref proxy) = config.proxy {
1820 if !user_js.is_empty() && !user_js.ends_with('\n') {
1821 user_js.push('\n');
1822 }
1823 user_js.push_str(&proxy_prefs(proxy));
1824 }
1825 if !user_js.is_empty() {
1826 if config
1836 .profile_dir
1837 .as_deref()
1838 .filter(|d| !d.is_empty())
1839 .is_none()
1840 {
1841 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1842 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1843 let dir =
1844 std::env::temp_dir().join(format!("foxdriver-profile-{}-{n}", std::process::id()));
1845 config.profile_dir = Some(dir.to_string_lossy().into_owned());
1846 }
1847 let profile_dir = config
1848 .profile_dir
1849 .as_deref()
1850 .expect("profile_dir synthesized above when missing");
1851 write_user_js(profile_dir, &user_js)
1854 .map_err(|e| anyhow!("failed to write user.js to profile {profile_dir:?}: {e}"))?;
1855 }
1856
1857 let profile_dir = config.profile_dir.clone();
1858 let cfg = FirefoxConfig {
1859 capabilities: caps,
1860 firefox_executable_path: config.executable_path,
1861 profile_dir: config.profile_dir,
1862 browser_flags: Some(args),
1863 ..Default::default()
1864 };
1865
1866 let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1867 .await
1868 .map_err(|_| anyhow!("Firefox launch timed out after 30s, check that Firefox is installed and not already running with a locked profile"))?;
1869 Ok(Page {
1870 browser: tokio::sync::Mutex::new(Some(browser)),
1871 profile_dir,
1872 child: std::sync::Mutex::new(None),
1873 })
1874}
1875
1876fn reserve_local_port() -> Result<u16> {
1881 let listener = std::net::TcpListener::bind("127.0.0.1:0")
1882 .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1883 let port = listener
1884 .local_addr()
1885 .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1886 .port();
1887 Ok(port)
1888}
1889
1890fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1900 if let Some(p) = explicit {
1901 return Ok(p);
1902 }
1903 const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1904 if let Ok(path) = std::env::var("PATH") {
1905 let sep = if cfg!(windows) { ';' } else { ':' };
1906 for dir in path.split(sep).filter(|d| !d.is_empty()) {
1907 for name in NAMES {
1908 let cand = std::path::Path::new(dir).join(name);
1909 if cand.is_file() {
1910 return Ok(cand.to_string_lossy().into_owned());
1911 }
1912 }
1913 }
1914 }
1915 const FIXED: &[&str] = &[
1917 "/usr/local/bin/firefox",
1918 "/usr/bin/firefox",
1919 "/opt/firefox/firefox",
1920 "/snap/bin/firefox",
1921 "/Applications/Firefox.app/Contents/MacOS/firefox",
1922 "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1923 "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1924 ];
1925 for p in FIXED {
1926 if std::path::Path::new(p).is_file() {
1927 return Ok((*p).to_string());
1928 }
1929 }
1930 Err(anyhow!(
1931 "could not find a Firefox binary, set FoxBrowserConfig.executable_path or install Firefox on PATH"
1932 ))
1933}
1934
1935pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
1951 let exe = resolve_firefox_binary(config.executable_path.clone())?;
1952
1953 let host = "127.0.0.1".to_string();
1954 let port = reserve_local_port()?;
1955
1956 let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
1960 std::env::temp_dir()
1961 .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
1962 .display()
1963 .to_string()
1964 });
1965 std::fs::create_dir_all(&profile_dir)
1966 .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
1967
1968 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1969 if let Some(ref proxy) = config.proxy {
1970 if !user_js.is_empty() && !user_js.ends_with('\n') {
1971 user_js.push('\n');
1972 }
1973 user_js.push_str(&proxy_prefs(proxy));
1974 }
1975 if !user_js.is_empty() {
1976 write_user_js(&profile_dir, &user_js)?;
1977 }
1978
1979 let mut args = vec![
1983 format!("--remote-debugging-port={port}"),
1984 "--profile".to_string(),
1985 profile_dir.clone(),
1986 "--no-remote".to_string(),
1987 ];
1988 if config.headless {
1989 args.push("--headless".to_string());
1990 }
1991 if let Some(ref ua) = config.user_agent {
1992 args.push(format!("--user-agent={ua}"));
1993 }
1994 if config.viewport_width > 0 {
1995 args.push(format!("--width={}", config.viewport_width));
1996 }
1997 if config.viewport_height > 0 {
1998 args.push(format!("--height={}", config.viewport_height));
1999 }
2000
2001 let mut command = std::process::Command::new(&exe);
2007 command.args(&args).env("MOZ_LAUNCHER_PROCESS", "0");
2008 for (key, value) in &config.env {
2009 command.env(key, value);
2010 }
2011 let child = command
2012 .spawn()
2013 .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
2014
2015 let addr: std::net::SocketAddr = format!("{host}:{port}")
2018 .parse()
2019 .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
2020 let start = std::time::Instant::now();
2021 let ready_timeout = std::time::Duration::from_secs(30);
2022 loop {
2023 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250))
2024 .is_ok()
2025 {
2026 break;
2027 }
2028 if start.elapsed() >= ready_timeout {
2029 return Err(anyhow!(
2030 "browser debug port {port} never came up within {}s, the spawn likely failed (check {exe:?})",
2031 ready_timeout.as_secs()
2032 ));
2033 }
2034 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2035 }
2036
2037 let cfg = FirefoxConfig {
2048 host: Some(host.clone()),
2049 capabilities: {
2050 let mut caps = FirefoxCapabilities::default();
2051 caps.accept_insecure_certs(true);
2052 if let Some(behavior) = config
2053 .unhandled_prompt_behavior
2054 .as_deref()
2055 .and_then(prompt_behavior_capability)
2056 {
2057 caps.unhandled_prompt_behavior(behavior);
2058 }
2059 caps
2060 },
2061 launch_mode: FirefoxLaunchMode::Remote(port),
2062 remote_debugging_port: Some(port),
2063 ..Default::default()
2064 };
2065 let attach = tokio::spawn(async move {
2066 tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
2067 });
2068 let browser = match attach.await {
2069 Ok(Ok(b)) => b,
2070 Ok(Err(_elapsed)) => {
2071 return Err(anyhow!(
2072 "BiDi attach to self-managed browser timed out after 30s"
2073 ))
2074 }
2075 Err(join) => {
2076 return Err(anyhow!(
2077 "BiDi attach to self-managed browser failed: {join}"
2078 ))
2079 }
2080 };
2081
2082 Ok(Page {
2083 browser: tokio::sync::Mutex::new(Some(browser)),
2084 profile_dir: Some(profile_dir),
2085 child: std::sync::Mutex::new(Some(child)),
2086 })
2087}
2088
2089#[cfg(test)]
2090mod tests {
2091 use super::*;
2092
2093 #[cfg(unix)]
2100 #[test]
2101 fn terminate_and_reap_leaves_no_zombie() {
2102 let child = std::process::Command::new("/bin/sh")
2103 .args(["-c", "trap '' TERM; sleep 300"])
2104 .spawn()
2105 .expect("spawn test child");
2106 let pid = child.id();
2107 terminate_and_reap(child);
2108 assert!(
2110 !std::path::Path::new(&format!("/proc/{pid}")).exists(),
2111 "pid {pid} still present after terminate_and_reap (zombie leak)"
2112 );
2113 }
2114
2115 #[test]
2121 fn shared_escaper_covers_quote_and_control_breakout() {
2122 let malicious = "'); alert(1); //";
2123 let escaped = crate::frame::escape_js_string(malicious);
2124 assert_eq!(escaped, "\\'); alert(1); //");
2127 for (index, _) in escaped.match_indices('\'') {
2129 assert_eq!(escaped.as_bytes()[index - 1], b'\\');
2130 }
2131 }
2132
2133 use rustenium_bidi_definitions::script::types::{
2134 ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType, BooleanValue,
2135 BooleanValueType, ListRemoteValue, MappingRemoteValue, NullValue, NullValueType,
2136 NumberValue, NumberValueType, ObjectRemoteValue, ObjectRemoteValueType,
2137 PrimitiveProtocolValue, StringValue, StringValueType, UndefinedValue, UndefinedValueType,
2138 };
2139
2140 #[test]
2143 fn frame_spec_main_aliases() {
2144 assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
2145 assert_eq!(FrameSpec::parse(" "), FrameSpec::Main);
2146 assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
2147 assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
2148 }
2149
2150 #[test]
2151 fn frame_spec_index_forms() {
2152 assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
2154 assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
2155 assert_eq!(
2157 FrameSpec::parse("10737418241"),
2158 FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
2159 );
2160 assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
2162 }
2163
2164 #[test]
2165 fn frame_spec_url_and_name_prefixes() {
2166 assert_eq!(
2167 FrameSpec::parse("url:recaptcha/api2"),
2168 FrameSpec::UrlContains("recaptcha/api2".into())
2169 );
2170 assert_eq!(
2171 FrameSpec::parse("name:checkout-frame"),
2172 FrameSpec::NameEquals("checkout-frame".into())
2173 );
2174 assert_eq!(
2176 FrameSpec::parse("url: https://x.com "),
2177 FrameSpec::UrlContains("https://x.com".into())
2178 );
2179 }
2180
2181 #[test]
2182 fn frame_spec_bare_id_falls_through() {
2183 assert_eq!(
2185 FrameSpec::parse("10737418241-abc"),
2186 FrameSpec::Id("10737418241-abc".into())
2187 );
2188 assert_eq!(
2190 FrameSpec::parse("https://w.com/f"),
2191 FrameSpec::Id("https://w.com/f".into())
2192 );
2193 }
2194
2195 #[test]
2198 fn prompt_behavior_maps_known_values() {
2199 for s in [
2200 "accept",
2201 "ACCEPT",
2202 "dismiss",
2203 "ignore",
2204 "dismiss and notify",
2205 "notify",
2206 ] {
2207 assert!(
2208 prompt_behavior_capability(s).is_some(),
2209 "'{s}' should map to a capability"
2210 );
2211 }
2212 }
2213
2214 #[test]
2215 fn prompt_behavior_rejects_unknown() {
2216 assert!(prompt_behavior_capability("").is_none());
2217 assert!(prompt_behavior_capability("bogus").is_none());
2218 }
2219
2220 #[test]
2221 fn prompt_behavior_ignore_is_user_prompt_handler_type() {
2222 match prompt_behavior_capability("ignore") {
2223 Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {
2224 }
2225 other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
2226 }
2227 }
2228
2229 #[test]
2232 fn wire_string_extracts_value() {
2233 let v = serde_json::json!({"type": "string", "value": "hello"});
2234 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
2235 }
2236
2237 #[test]
2238 fn wire_number_passthrough() {
2239 let v = serde_json::json!({"type": "number", "value": 42.5});
2240 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
2241 }
2242
2243 #[test]
2244 fn wire_boolean_extracts_bool() {
2245 let v = serde_json::json!({"type": "boolean", "value": true});
2246 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
2247 }
2248
2249 #[test]
2250 fn wire_null_returns_null() {
2251 let v = serde_json::json!({"type": "null"});
2252 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2253 }
2254
2255 #[test]
2256 fn wire_undefined_returns_null() {
2257 let v = serde_json::json!({"type": "undefined"});
2258 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2259 }
2260
2261 #[test]
2262 fn wire_bigint_returns_string() {
2263 let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
2264 assert_eq!(
2265 bidi_wire_value_to_json(&v),
2266 serde_json::json!("9007199254740993")
2267 );
2268 }
2269
2270 #[test]
2271 fn wire_object_recurse() {
2272 let v = serde_json::json!({
2273 "type": "object",
2274 "value": [
2275 ["a", {"type": "string", "value": "alpha"}],
2276 ["b", {"type": "number", "value": 2}]
2277 ]
2278 });
2279 let out = bidi_wire_value_to_json(&v);
2280 assert_eq!(out["a"], "alpha");
2281 assert_eq!(out["b"], 2);
2282 }
2283
2284 #[test]
2285 fn wire_array_recurse() {
2286 let v = serde_json::json!({
2287 "type": "array",
2288 "value": [
2289 {"type": "string", "value": "x"},
2290 {"type": "number", "value": 1}
2291 ]
2292 });
2293 let out = bidi_wire_value_to_json(&v);
2294 assert_eq!(out, serde_json::json!(["x", 1]));
2295 }
2296
2297 #[test]
2298 fn wire_unknown_type_clones_raw() {
2299 let v = serde_json::json!({"type": "special", "payload": 99});
2300 assert_eq!(bidi_wire_value_to_json(&v), v);
2301 }
2302
2303 #[test]
2304 fn wire_missing_type_clones_raw() {
2305 let v = serde_json::json!({"payload": 99});
2306 assert_eq!(bidi_wire_value_to_json(&v), v);
2307 }
2308
2309 #[test]
2312 fn rv_string_value() {
2313 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2314 StringValue::new(StringValueType::String, "hi"),
2315 ));
2316 assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
2317 }
2318
2319 #[test]
2320 fn rv_number_value() {
2321 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2322 NumberValue::new(NumberValueType::Number, 2.5),
2323 ));
2324 assert_eq!(remote_value_to_json(&rv), serde_json::json!(2.5));
2325 }
2326
2327 #[test]
2328 fn rv_boolean_value() {
2329 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2330 BooleanValue::new(BooleanValueType::Boolean, true),
2331 ));
2332 assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
2333 }
2334
2335 #[test]
2336 fn rv_null_value() {
2337 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
2338 NullValue::new(NullValueType::Null),
2339 ));
2340 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2341 }
2342
2343 #[test]
2344 fn rv_undefined_value() {
2345 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
2346 UndefinedValue::new(UndefinedValueType::Undefined),
2347 ));
2348 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2349 }
2350
2351 #[test]
2352 fn rv_bigint_value() {
2353 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
2354 BigIntValue::new(BigIntValueType::Bigint, "999n"),
2355 ));
2356 assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
2357 }
2358
2359 #[test]
2360 fn rv_array_value() {
2361 let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2362 StringValue::new(StringValueType::String, "item"),
2363 ));
2364 let arr = ArrayRemoteValue {
2365 r#type: ArrayRemoteValueType::Array,
2366 handle: None,
2367 internal_id: None,
2368 value: Some(ListRemoteValue::new(vec![inner])),
2369 };
2370 let rv = RemoteValue::ArrayRemoteValue(arr);
2371 assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
2372 }
2373
2374 #[test]
2375 fn rv_object_value() {
2376 let obj = ObjectRemoteValue {
2377 r#type: ObjectRemoteValueType::Object,
2378 handle: None,
2379 internal_id: None,
2380 value: Some(MappingRemoteValue::new(vec![vec![
2381 serde_json::json!("key"),
2382 serde_json::json!({"type": "string", "value": "val"}),
2383 ]])),
2384 };
2385 let rv = RemoteValue::ObjectRemoteValue(obj);
2386 let out = remote_value_to_json(&rv);
2387 assert_eq!(out["key"], "val");
2388 }
2389
2390 #[test]
2391 fn rv_unsupported_returns_null() {
2392 let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
2393 rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
2394 );
2395 let rv = RemoteValue::SymbolRemoteValue(sym);
2396 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2397 }
2398
2399 #[test]
2402 fn eval_result_into_value_deserializes() {
2403 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2404 StringValue::new(StringValueType::String, "deserialized"),
2405 ));
2406 let er = EvaluationResult::new(rv);
2407 let s: String = er.into_value().unwrap();
2408 assert_eq!(s, "deserialized");
2409 }
2410
2411 #[test]
2412 fn eval_result_into_value_number() {
2413 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2414 NumberValue::new(NumberValueType::Number, 42i32),
2415 ));
2416 let er = EvaluationResult::new(rv);
2417 let n: i32 = er.into_value().unwrap();
2418 assert_eq!(n, 42);
2419 }
2420
2421 #[test]
2422 fn eval_result_remote_value_accessor() {
2423 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2424 BooleanValue::new(BooleanValueType::Boolean, false),
2425 ));
2426 let er = EvaluationResult::new(rv.clone());
2427 assert_eq!(er.remote_value(), &rv);
2428 }
2429
2430 #[test]
2433 fn fox_browser_config_default_is_headless_false() {
2434 let cfg = FoxBrowserConfig::default();
2435 assert!(!cfg.headless);
2436 assert!(cfg.executable_path.is_none());
2437 assert!(cfg.profile_dir.is_none());
2438 assert_eq!(cfg.viewport_width, 0);
2439 assert_eq!(cfg.viewport_height, 0);
2440 assert!(cfg.user_agent.is_none());
2441 assert!(cfg.user_js_content.is_none());
2442 }
2443
2444 #[test]
2447 fn write_user_js_creates_file() {
2448 let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2449 let _ = std::fs::remove_dir_all(&tmp);
2450 let content = "user_pref(\"test\", true);\n";
2451 write_user_js(tmp.to_str().unwrap(), content).unwrap();
2452 let path = tmp.join("user.js");
2453 assert!(path.exists());
2454 let read = std::fs::read_to_string(&path).unwrap();
2455 assert_eq!(read, content);
2456 let _ = std::fs::remove_dir_all(&tmp);
2457 }
2458
2459 #[test]
2460 fn write_user_js_creates_nested_dirs() {
2461 let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2462 let _ = std::fs::remove_dir_all(&tmp);
2463 let nested = tmp.join("a").join("b");
2464 write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2465 assert!(nested.join("user.js").exists());
2466 let _ = std::fs::remove_dir_all(&tmp);
2467 }
2468
2469 #[test]
2472 fn proxy_from_url_http_no_auth() {
2473 let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2474 assert_eq!(p.scheme, ProxyScheme::Http);
2475 assert_eq!(p.host, "1.2.3.4");
2476 assert_eq!(p.port, 8080);
2477 assert!(p.username.is_none() && p.password.is_none());
2478 }
2479
2480 #[test]
2481 fn proxy_from_url_socks5_with_auth() {
2482 let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2483 assert_eq!(p.scheme, ProxyScheme::Socks5);
2484 assert_eq!(p.host, "gw.residential.net");
2485 assert_eq!(p.port, 1080);
2486 assert_eq!(p.username.as_deref(), Some("user"));
2487 assert_eq!(p.password.as_deref(), Some("pass"));
2488 }
2489
2490 #[test]
2491 fn proxy_from_url_bare_defaults_http() {
2492 let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2493 assert_eq!(p.scheme, ProxyScheme::Http);
2494 assert_eq!(p.host, "10.0.0.1");
2495 assert_eq!(p.port, 3128);
2496 }
2497
2498 #[test]
2499 fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2500 assert!(ProxyConfig::from_url("http://nohost").is_err());
2501 assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2502 assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2503 }
2504
2505 #[test]
2506 fn proxy_prefs_http_emits_http_ssl_and_type() {
2507 let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2508 assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2509 assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2510 assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2511 assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2512 assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2513 assert!(!prefs.contains("network.proxy.socks"));
2515 }
2516
2517 #[test]
2518 fn proxy_prefs_socks5_emits_socks_and_version() {
2519 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2520 assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2521 assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2522 assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2523 assert!(!prefs.contains("network.proxy.http_port"));
2525 }
2526
2527 #[test]
2528 fn proxy_prefs_close_the_webrtc_ip_leak_for_both_schemes() {
2529 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2534 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2535 assert!(
2536 prefs.contains(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#),
2537 "{url}: must force ICE through the proxy (no direct-UDP srflx leak)"
2538 );
2539 assert!(
2540 prefs.contains(r#"user_pref("media.peerconnection.ice.no_host", true);"#),
2541 "{url}: must suppress LAN host candidates"
2542 );
2543 assert!(
2544 prefs.contains(
2545 r#"user_pref("media.peerconnection.ice.default_address_only", true);"#
2546 ),
2547 "{url}: must expose only the default route address"
2548 );
2549 assert!(
2551 !prefs.contains("media.peerconnection.enabled"),
2552 "{url}: must NOT disable WebRTC outright (a fingerprint tell); only confine ICE"
2553 );
2554 }
2555 }
2556
2557 #[test]
2558 fn proxy_prefs_close_the_dns_prefetch_leak_for_both_schemes() {
2559 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2563 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2564 assert!(
2565 prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#),
2566 "{url}: must disable DNS prefetch (a `dns-prefetch` link leaks a clear-text query)"
2567 );
2568 assert!(
2569 prefs.contains(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#),
2570 "{url}: must disable DNS prefetch from HTTPS origins too"
2571 );
2572 assert!(
2573 prefs.contains(r#"user_pref("network.predictor.enabled", false);"#),
2574 "{url}: must disable the network predictor (history-driven pre-resolution)"
2575 );
2576 assert!(
2577 prefs.contains(r#"user_pref("network.http.speculative-parallel-limit", 0);"#),
2578 "{url}: must stop speculative parallel connections"
2579 );
2580 assert!(
2581 prefs.contains(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#),
2582 "{url}: must stop urlbar speculative connect"
2583 );
2584 }
2585 }
2586
2587 #[test]
2588 fn proxy_prefs_socks_keeps_remote_dns_alongside_the_leak_guards() {
2589 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2593 assert!(prefs.contains(r#"user_pref("network.proxy.socks_remote_dns", true);"#));
2594 assert!(prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#));
2595 }
2596
2597 #[test]
2600 fn scroll_direction_up_not_eq_down() {
2601 assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2602 }
2603
2604 #[test]
2605 fn scroll_direction_clone_copy() {
2606 let a = ScrollDirection::Up;
2607 let b = a;
2608 assert_eq!(a, b); }
2610
2611 #[test]
2612 fn scroll_direction_debug() {
2613 let s = format!("{:?}", ScrollDirection::Down);
2614 assert!(s.contains("Down"));
2615 }
2616}