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 _ => None,
262 }
263 });
264 if let (Some(k), Some(val)) = (key_opt, items.get(1)) {
265 map.insert(k, bidi_wire_value_to_json(val));
266 }
267 }
268 }
269 }
270 }
271 serde_json::Value::Object(map)
272 }
273 Some("array") => {
274 let arr: Vec<serde_json::Value> = v
275 .get("value")
276 .and_then(|v| v.as_array())
277 .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
278 .unwrap_or_default();
279 serde_json::Value::Array(arr)
280 }
281 _ => v.clone(),
282 }
283}
284
285fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
287 match rv {
288 RemoteValue::PrimitiveProtocolValue(p) => match p {
289 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
290 serde_json::Value::String(s.value.clone())
291 }
292 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
293 match &n.value {
294 serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
295 _ => serde_json::Value::Null,
296 }
297 }
298 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
299 serde_json::Value::Bool(b.value)
300 }
301 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
302 serde_json::Value::Null
303 }
304 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(
305 _,
306 ) => serde_json::Value::Null,
307 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
308 serde_json::Value::String(b.value.clone())
309 }
310 },
311 RemoteValue::ArrayRemoteValue(a) => {
312 let arr: Vec<serde_json::Value> = a
313 .value
314 .as_ref()
315 .map(|v| v.inner().iter().map(remote_value_to_json).collect())
316 .unwrap_or_default();
317 serde_json::Value::Array(arr)
318 }
319 RemoteValue::ObjectRemoteValue(o) => {
320 let mut map = serde_json::Map::new();
321 if let Some(mapping) = &o.value {
322 for pair in mapping.inner() {
323 if pair.len() >= 2 {
324 let key_opt = match pair.first() {
325 Some(serde_json::Value::String(k)) => Some(k.clone()),
326 Some(v) => match bidi_wire_value_to_json(v) {
327 serde_json::Value::String(s) => Some(s),
328 serde_json::Value::Number(n) => Some(n.to_string()),
329 _ => None,
330 },
331 None => None,
332 };
333 if let (Some(k), Some(v)) = (key_opt, pair.get(1)) {
334 map.insert(k, bidi_wire_value_to_json(v));
335 }
336 }
337 }
338 }
339 serde_json::Value::Object(map)
340 }
341 _ => serde_json::Value::Null,
342 }
343}
344
345pub struct Element {
347 pub(crate) node: tokio::sync::Mutex<FoxNode>,
348 pub(crate) selector: String,
349}
350
351impl Element {
352 pub async fn click(&self) -> Result<()> {
354 let mut node = self.node.lock().await;
355 node.mouse_click()
356 .await
357 .map_err(|e| anyhow!("element click failed: {e:?}"))?;
358 Ok(())
359 }
360
361 pub fn selector(&self) -> &str {
363 &self.selector
364 }
365
366 pub async fn type_text(&self, text: &str) -> Result<()> {
368 let mut node = self.node.lock().await;
369 node.type_text(text.to_string())
370 .await
371 .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
372 Ok(())
373 }
374
375 pub async fn type_str(&self, text: &str) -> Result<()> {
377 self.type_text(text).await
378 }
379}
380
381type FoxBrowser = FirefoxBrowser;
383type FoxNode =
384 rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum ScrollDirection {
389 Up,
390 Down,
391}
392
393impl Page {
394 pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
396 launch_firefox(config.unwrap_or_default()).await
397 }
398
399 pub async fn goto(&self, url: &str) -> Result<()> {
401 let mut browser = self.browser.lock().await;
402 let browser = match &mut *browser {
403 Some(b) => b,
404 None => return Err(anyhow!("browser closed")),
405 };
406 browser
407 .navigate(url)
408 .await
409 .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
410 Ok(())
411 }
412
413 pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
419 let mut browser = self.browser.lock().await;
420 let browser = match &mut *browser {
421 Some(b) => b,
422 None => return Err(anyhow!("browser closed")),
423 };
424 let result = browser
425 .evaluate_script(expr.into(), false)
426 .await
427 .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
428 Ok(EvaluationResult::new(result.result))
429 }
430
431 pub async fn evaluate_await(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
440 let mut browser = self.browser.lock().await;
441 let browser = match &mut *browser {
442 Some(b) => b,
443 None => return Err(anyhow!("browser closed")),
444 };
445 let result = browser
446 .evaluate_script(expr.into(), true)
447 .await
448 .map_err(|e| anyhow!("evaluate_await failed: {e:?}"))?;
449 Ok(EvaluationResult::new(result.result))
450 }
451
452 pub async fn evaluate_in_context(
454 &self,
455 expr: impl Into<String>,
456 context: &FrameId,
457 ) -> Result<EvaluationResult> {
458 let mut browser = self.browser.lock().await;
459 let browser = match &mut *browser {
460 Some(b) => b,
461 None => return Err(anyhow!("browser closed")),
462 };
463 let options = EvaluateScriptOptionsBuilder::default()
464 .target(Target::ContextTarget(ContextTarget::new(context.clone())))
465 .build();
466 let result = browser
467 .evaluate_script_with_options(expr.into(), false, options)
468 .await
469 .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
470 Ok(EvaluationResult::new(result.result))
471 }
472
473 pub async fn find_element(&self, selector: &str) -> Result<Element> {
475 let mut browser = self.browser.lock().await;
476 let browser = match &mut *browser {
477 Some(b) => b,
478 None => return Err(anyhow!("browser closed")),
479 };
480 let locator =
481 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
482 match browser.find_node(locator).await {
483 Ok(Some(node)) => Ok(Element {
484 node: tokio::sync::Mutex::new(node),
485 selector: selector.to_string(),
486 }),
487 Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
488 Err(e) => Err(anyhow!("find_element failed: {e:?}")),
489 }
490 }
491
492 pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
494 let mut browser = self.browser.lock().await;
495 let browser = match &mut *browser {
496 Some(b) => b,
497 None => return Err(anyhow!("browser closed")),
498 };
499 let locator =
500 Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
501 let nodes = browser
502 .find_nodes(locator)
503 .await
504 .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
505 Ok(nodes
506 .into_iter()
507 .map(|n| Element {
508 node: tokio::sync::Mutex::new(n),
509 selector: selector.to_string(),
510 })
511 .collect())
512 }
513
514 pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
522 if files.is_empty() {
523 return Err(anyhow!("set_files: no files provided"));
524 }
525 let element = self.find_element(selector).await?;
529 let (shared_id, context) = {
530 let node = element.node.lock().await;
531 let id = node.get_shared_id().cloned().ok_or_else(|| {
532 anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
533 })?;
534 (id, node.get_context_id().clone())
535 };
536 let element_ref: SharedReference = SharedReference::builder()
537 .shared_id(shared_id)
538 .build()
539 .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
540 let command = SetFiles::builder()
541 .context(context)
542 .element(element_ref)
543 .files(files)
544 .build()
545 .map_err(|e| anyhow!("set_files: build command: {e}"))?;
546 let mut browser = self.browser.lock().await;
547 let browser = match &mut *browser {
548 Some(b) => b,
549 None => return Err(anyhow!("browser closed")),
550 };
551 let response = browser
552 .driver_mut()
553 .send_command(command)
554 .await
555 .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
556 let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
557 .result
558 .try_into()
559 .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
560 Ok(())
561 }
562
563 pub async fn screenshot(&self) -> Result<Vec<u8>> {
565 let mut browser = self.browser.lock().await;
566 let browser = match &mut *browser {
567 Some(b) => b,
568 None => return Err(anyhow!("browser closed")),
569 };
570 let b64 = browser
571 .screenshot()
572 .await
573 .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
574 base64::engine::general_purpose::STANDARD
575 .decode(b64)
576 .map_err(|e| anyhow!("base64 decode failed: {e}"))
577 }
578
579 pub async fn reload(&self) -> Result<()> {
581 let mut browser = self.browser.lock().await;
582 let browser = match &mut *browser {
583 Some(b) => b,
584 None => return Err(anyhow!("browser closed")),
585 };
586 browser
587 .evaluate_script("location.reload()".to_string(), false)
588 .await
589 .map_err(|e| anyhow!("reload failed: {e:?}"))?;
590 Ok(())
591 }
592
593 pub async fn url(&self) -> Result<String> {
595 let eval = self.evaluate("document.URL").await?;
596 eval.into_value::<String>()
597 .map_err(|e| anyhow!("url deserialize failed: {e}"))
598 }
599
600 pub async fn title(&self) -> Result<String> {
602 let eval = self.evaluate("document.title").await?;
603 eval.into_value::<String>()
604 .map_err(|e| anyhow!("title deserialize failed: {e}"))
605 }
606
607 pub async fn frames(&self) -> Result<Vec<FrameId>> {
609 let browser = self.browser.lock().await;
610 let browser = match &*browser {
611 Some(b) => b,
612 None => return Err(anyhow!("browser closed")),
613 };
614 let contexts = browser
615 .driver()
616 .browsing_contexts
617 .lock()
618 .unwrap_or_else(|e| e.into_inner())
619 .iter()
620 .map(|c| c.id().clone())
621 .collect();
622 Ok(contexts)
623 }
624
625 pub async fn mainframe(&self) -> Result<Option<FrameId>> {
627 let browser = self.browser.lock().await;
628 let browser = match &*browser {
629 Some(b) => b,
630 None => return Err(anyhow!("browser closed")),
631 };
632 match browser.driver().get_active_context_id() {
633 Ok(ctx) => Ok(Some(ctx)),
634 Err(e) => {
635 tracing::debug!("get_active_context_id failed: {e:?}");
636 Ok(None)
637 }
638 }
639 }
640
641 pub async fn frame_execution_context(&self, frame_id: FrameId) -> Result<Option<FrameId>> {
643 let browser = self.browser.lock().await;
644 let browser = match &*browser {
645 Some(b) => b,
646 None => return Err(anyhow!("browser closed")),
647 };
648 let exists = browser
649 .driver()
650 .browsing_contexts
651 .lock()
652 .unwrap_or_else(|e| e.into_inner())
653 .iter()
654 .any(|c| c.id() == &frame_id);
655 Ok(if exists { Some(frame_id) } else { None })
656 }
657
658 pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
668 let frame_ids = self.frames().await?;
669 let main = self.mainframe().await?;
670 let mut out = Vec::with_capacity(frame_ids.len());
671 for fid in frame_ids {
672 let (url, name) = match self
676 .evaluate_in_context("({u: document.URL, n: (window.name || \"\")})", &fid)
677 .await
678 {
679 Ok(eval) => match eval.into_value::<serde_json::Value>() {
680 Ok(v) => (
681 v["u"].as_str().unwrap_or("").to_string(),
682 v["n"].as_str().unwrap_or("").to_string(),
683 ),
684 Err(_) => (String::new(), String::new()),
685 },
686 Err(e) => {
687 tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
688 (String::new(), String::new())
689 }
690 };
691 out.push(FrameInfo {
692 is_main: Some(&fid) == main.as_ref(),
693 id: fid.inner().to_string(),
694 url,
695 name,
696 });
697 }
698 Ok(out)
699 }
700
701 pub async fn frame_tree(&self) -> Result<Vec<FrameTreeNode>> {
717 use rustenium_bidi_definitions::browsing_context::commands::GetTree;
718 use rustenium_bidi_definitions::browsing_context::results::GetTreeResult;
719 use rustenium_bidi_definitions::browsing_context::types::{Info, InfoList};
720
721 let command = GetTree::builder().build();
722 let mut browser = self.browser.lock().await;
723 let browser = match &mut *browser {
724 Some(b) => b,
725 None => return Err(anyhow!("browser closed")),
726 };
727 let response = browser
728 .driver_mut()
729 .send_command(command)
730 .await
731 .map_err(|e| anyhow!("browsingContext.getTree BiDi command failed: {e:?}"))?;
732 let result: GetTreeResult = response
733 .result
734 .try_into()
735 .map_err(|e| anyhow!("browsingContext.getTree result parse failed: {e}"))?;
736
737 fn walk(
741 list: &InfoList,
742 parent: Option<&FrameId>,
743 depth: usize,
744 out: &mut Vec<FrameTreeNode>,
745 ) {
746 for info in list.inner() {
747 let info: &Info = info;
748 out.push(FrameTreeNode {
749 id: info.context.clone(),
750 url: info.url.clone(),
751 parent: parent.cloned(),
752 depth,
753 });
754 if let Some(children) = &info.children {
755 walk(children, Some(&info.context), depth + 1, out);
756 }
757 }
758 }
759 let mut out = Vec::new();
760 walk(&result.contexts, None, 0, &mut out);
761 Ok(out)
762 }
763
764 pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
777 self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
778 .await
779 }
780
781 pub async fn resolve_frame_within(
784 &self,
785 spec: &str,
786 timeout: std::time::Duration,
787 ) -> Result<FrameId> {
788 let parsed = FrameSpec::parse(spec);
789 let deadline = std::time::Instant::now() + timeout;
790 loop {
791 if let Some(fid) = self.try_resolve_frame(&parsed).await? {
792 return Ok(fid);
793 }
794 if std::time::Instant::now() >= deadline {
795 return Err(anyhow!(
796 "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
797 ));
798 }
799 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
800 }
801 }
802
803 async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
806 if matches!(parsed, FrameSpec::Main) {
807 return self.mainframe().await;
808 }
809 let frames = self.frames().await?;
810 match parsed {
811 FrameSpec::Main => unreachable!(),
812 FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
813 FrameSpec::IdOrIndex(id, idx) => {
814 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
816 return Ok(Some(fid.clone()));
817 }
818 Ok(frames.get(*idx).cloned())
819 }
820 FrameSpec::Id(id) => {
821 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
822 return Ok(Some(fid.clone()));
823 }
824 self.frame_by_url_contains(id).await
827 }
828 FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
829 FrameSpec::NameEquals(name) => {
830 for info in self.list_frames().await? {
831 if &info.name == name {
832 return Ok(Some(FrameId::new(info.id)));
833 }
834 }
835 Ok(None)
836 }
837 }
838 }
839
840 async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
843 for info in self.list_frames().await? {
844 if info.url.contains(sub) {
845 return Ok(Some(FrameId::new(info.id)));
846 }
847 }
848 Ok(None)
849 }
850
851 pub async fn eval_in_frame(
856 &self,
857 spec: &str,
858 expr: impl Into<String>,
859 ) -> Result<EvaluationResult> {
860 let fid = self.resolve_frame(spec).await?;
861 self.evaluate_in_context(expr, &fid).await
862 }
863
864 pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
871 let fid = self.resolve_frame(spec).await?;
872 let escaped = crate::frame::escape_js_string(selector);
873 let js = format!(
874 r#"(function() {{
875 const el = document.querySelector('{escaped}');
876 if (!el) return null;
877 const r = el.getBoundingClientRect();
878 if (r.width <= 0 || r.height <= 0) return null;
879 return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
880 }})()"#
881 );
882 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
885 loop {
886 if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
887 if let Ok(val) = eval.into_value::<serde_json::Value>() {
888 if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
889 return self.click_at_in(&fid, x, y).await;
890 }
891 }
892 }
893 if std::time::Instant::now() >= deadline {
894 return Err(anyhow!(
895 "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
896 ));
897 }
898 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
899 }
900 }
901
902 pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
906 let fid = self.resolve_frame(spec).await?;
907 let escaped = crate::frame::escape_js_string(selector);
908 let focus_js = format!(
909 r#"(function() {{
910 const el = document.querySelector('{escaped}');
911 if (!el) return false;
912 el.focus();
913 return document.activeElement === el;
914 }})()"#
915 );
916 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
918 loop {
919 let focused = self
920 .evaluate_in_context(&focus_js, &fid)
921 .await
922 .ok()
923 .and_then(|e| e.into_value::<bool>().ok())
924 .unwrap_or(false);
925 if focused {
926 break;
927 }
928 if std::time::Instant::now() >= deadline {
929 return Err(anyhow!(
930 "type_in_frame: could not focus '{selector}' in frame '{spec}'"
931 ));
932 }
933 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
934 }
935 let browser = self.browser.lock().await;
936 let browser = match &*browser {
937 Some(b) => b,
938 None => return Err(anyhow!("browser closed")),
939 };
940 browser
941 .keyboard()
942 .type_text(text, &fid, None)
943 .await
944 .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
945 Ok(())
946 }
947
948 pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
962 let mut browser = self.browser.lock().await;
963 let browser = match &mut *browser {
964 Some(b) => b,
965 None => return Err(anyhow!("browser closed")),
966 };
967 let log = crate::dialog::DialogLog::new();
968 let handler = crate::dialog::make_dialog_handler(log.clone());
969 let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
970 browser
971 .subscribe_events(events, handler)
972 .await
973 .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
974 Ok(log)
975 }
976
977 pub async fn start_sensors(&self) -> Result<String> {
991 let id = self
992 .add_preload_script(crate::sensors::SENSOR_SCRIPT)
993 .await?;
994 let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
998 Ok(id)
999 }
1000
1001 pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
1005 let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
1006 eval.into_value::<serde_json::Value>()
1007 .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
1008 }
1009
1010 pub async fn handle_user_prompt(
1016 &self,
1017 context: Option<&FrameId>,
1018 accept: bool,
1019 user_text: Option<&str>,
1020 ) -> Result<()> {
1021 let ctx = match context {
1022 Some(c) => c.clone(),
1023 None => self
1024 .mainframe()
1025 .await?
1026 .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
1027 };
1028 let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
1029 if let Some(text) = user_text {
1030 builder = builder.user_text(text.to_string());
1031 }
1032 let command = builder
1033 .build()
1034 .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
1035 let mut browser = self.browser.lock().await;
1036 let browser = match &mut *browser {
1037 Some(b) => b,
1038 None => return Err(anyhow!("browser closed")),
1039 };
1040 let response = browser
1041 .driver_mut()
1042 .send_command(command)
1043 .await
1044 .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
1045 let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
1046 response
1047 .result
1048 .try_into()
1049 .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
1050 Ok(())
1051 }
1052
1053 pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
1059 let browser = self.browser.lock().await;
1060 let browser = match &*browser {
1061 Some(b) => b,
1062 None => return Err(anyhow!("browser closed")),
1063 };
1064 let context = browser
1065 .driver()
1066 .get_active_context_id()
1067 .map_err(|e| anyhow!("{e:?}"))?;
1068 let hm = browser.human_mouse();
1069 hm.set_last_position(Point { x: x0, y: y0 });
1070 hm.move_to(
1071 Point { x: x1, y: y1 },
1072 &context,
1073 MouseMoveOptions::default(),
1074 )
1075 .await
1076 .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
1077 Ok(())
1078 }
1079
1080 pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
1082 let browser = self.browser.lock().await;
1083 let browser = match &*browser {
1084 Some(b) => b,
1085 None => return Err(anyhow!("browser closed")),
1086 };
1087 let context = browser
1088 .driver()
1089 .get_active_context_id()
1090 .map_err(|e| anyhow!("{e:?}"))?;
1091 let hm = browser.human_mouse();
1092 hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
1093 .await
1094 .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
1095 hm.down(
1096 &context,
1097 MouseOptions {
1098 button: Some(MouseButton::Left),
1099 },
1100 )
1101 .await
1102 .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
1103 Ok(())
1104 }
1105
1106 pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
1108 let browser = self.browser.lock().await;
1109 let browser = match &*browser {
1110 Some(b) => b,
1111 None => return Err(anyhow!("browser closed")),
1112 };
1113 let context = browser
1114 .driver()
1115 .get_active_context_id()
1116 .map_err(|e| anyhow!("{e:?}"))?;
1117 let hm = browser.human_mouse();
1118 hm.up(
1119 &context,
1120 MouseOptions {
1121 button: Some(MouseButton::Left),
1122 },
1123 )
1124 .await
1125 .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
1126 Ok(())
1127 }
1128
1129 pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
1139 let context = self
1140 .mainframe()
1141 .await?
1142 .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
1143 self.click_at_in(&context, x, y).await
1144 }
1145
1146 pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
1157 let browser = self.browser.lock().await;
1158 let browser = match &*browser {
1159 Some(b) => b,
1160 None => return Err(anyhow!("browser closed")),
1161 };
1162 let hm = browser.human_mouse();
1163 hm.set_last_position(Point { x, y });
1171 let options = MouseClickOptions {
1172 button: Some(MouseButton::Left),
1173 count: Some(1),
1174 delay: Some(80),
1175 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1176 };
1177 hm.click(Some(Point { x, y }), context, options)
1178 .await
1179 .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
1180 Ok(())
1181 }
1182
1183 pub async fn move_mouse_to(&self, 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 context = browser
1201 .driver()
1202 .get_active_context_id()
1203 .map_err(|e| anyhow!("{e:?}"))?;
1204 browser
1205 .mouse()
1206 .move_to(
1207 Point { x, y },
1208 &context,
1209 MouseMoveOptions {
1210 steps: Some(0),
1211 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1212 },
1213 )
1214 .await
1215 .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
1216 Ok(())
1217 }
1218
1219 pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
1221 let browser = self.browser.lock().await;
1222 let browser = match &*browser {
1223 Some(b) => b,
1224 None => return Err(anyhow!("browser closed")),
1225 };
1226 let context = browser
1227 .driver()
1228 .get_active_context_id()
1229 .map_err(|e| anyhow!("{e:?}"))?;
1230 browser
1231 .mouse()
1232 .wheel(
1233 &context,
1234 MouseWheelOptions {
1235 delta_x: Some(dx),
1236 delta_y: Some(dy),
1237 },
1238 )
1239 .await
1240 .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1241 Ok(())
1242 }
1243
1244 pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1246 let browser = self.browser.lock().await;
1247 let browser = match &*browser {
1248 Some(b) => b,
1249 None => return Err(anyhow!("browser closed")),
1250 };
1251 let context = browser
1252 .driver()
1253 .get_active_context_id()
1254 .map_err(|e| anyhow!("{e:?}"))?;
1255 let y_distance = match direction {
1256 ScrollDirection::Down => amount as i32,
1257 ScrollDirection::Up => -(amount as i32),
1258 };
1259 browser
1260 .human_mouse()
1261 .scroll(y_distance, 0, &context)
1262 .await
1263 .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1264 Ok(())
1265 }
1266
1267 pub async fn type_text(&self, text: &str) -> Result<()> {
1269 let browser = self.browser.lock().await;
1270 let browser = match &*browser {
1271 Some(b) => b,
1272 None => return Err(anyhow!("browser closed")),
1273 };
1274 let context = browser
1275 .driver()
1276 .get_active_context_id()
1277 .map_err(|e| anyhow!("{e:?}"))?;
1278 browser
1279 .keyboard()
1280 .type_text(text, &context, None)
1281 .await
1282 .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1283 Ok(())
1284 }
1285
1286 pub async fn key_down(&self, key: &str) -> Result<()> {
1288 let browser = self.browser.lock().await;
1289 let browser = match &*browser {
1290 Some(b) => b,
1291 None => return Err(anyhow!("browser closed")),
1292 };
1293 let context = browser
1294 .driver()
1295 .get_active_context_id()
1296 .map_err(|e| anyhow!("{e:?}"))?;
1297 browser
1298 .keyboard()
1299 .down(key, &context)
1300 .await
1301 .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1302 Ok(())
1303 }
1304
1305 pub async fn key_up(&self, key: &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 .up(key, &context)
1319 .await
1320 .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1321 Ok(())
1322 }
1323
1324 pub async fn key_press(&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 .press(key, &context, None)
1338 .await
1339 .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1340 Ok(())
1341 }
1342
1343 pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1360 let function_declaration = format!("() => {{\n{source}\n}}");
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 id = browser
1367 .add_preload_script(function_declaration)
1368 .await
1369 .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1370 Ok(id)
1371 }
1372
1373 pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1375 let mut browser = self.browser.lock().await;
1376 let browser = match &mut *browser {
1377 Some(b) => b,
1378 None => return Err(anyhow!("browser closed")),
1379 };
1380 let response = browser
1381 .driver_mut()
1382 .send_command(GetCookies {
1383 method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1384 params: Default::default(),
1385 })
1386 .await
1387 .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1388 let result: rustenium_bidi_definitions::storage::results::GetCookiesResult = response
1389 .result
1390 .try_into()
1391 .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1392 Ok(result
1393 .cookies
1394 .into_iter()
1395 .map(|c| crate::cookies::CapturedCookie {
1396 name: c.name,
1397 value: match c.value {
1398 BytesValue::StringValue(s) => s.value,
1399 BytesValue::Base64Value(b) => b.value,
1400 },
1401 domain: c.domain,
1402 path: c.path,
1403 expires: c.expiry.map(|e| e as i64),
1404 secure: c.secure,
1405 http_only: c.http_only,
1406 same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1407 })
1408 .collect())
1409 }
1410
1411 #[allow(clippy::too_many_arguments)] pub async fn set_cookie(
1414 &self,
1415 name: &str,
1416 value: &str,
1417 domain: &str,
1418 path: Option<&str>,
1419 expires: Option<u64>,
1420 secure: Option<bool>,
1421 http_only: Option<bool>,
1422 same_site: Option<SameSite>,
1423 ) -> Result<()> {
1424 let mut browser = self.browser.lock().await;
1425 let browser = match &mut *browser {
1426 Some(b) => b,
1427 None => return Err(anyhow!("browser closed")),
1428 };
1429 let cookie = PartialCookie {
1430 name: name.to_string(),
1431 value: BytesValue::StringValue(StringValue::new(
1432 StringValueType::String,
1433 value.to_string(),
1434 )),
1435 domain: domain.to_string(),
1436 path: path.map(|p| p.to_string()),
1437 http_only,
1438 secure,
1439 same_site,
1440 expiry: expires,
1441 extensible: Default::default(),
1442 };
1443 let response = browser
1444 .driver_mut()
1445 .send_command(SetCookie {
1446 method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1447 params: SetCookieParams::new(cookie),
1448 })
1449 .await
1450 .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1451 let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1452 .result
1453 .try_into()
1454 .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1455 Ok(())
1456 }
1457
1458 pub fn profile_dir(&self) -> Option<&str> {
1460 self.profile_dir.as_deref()
1461 }
1462
1463 pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1477 let mut browser = self.browser.lock().await;
1478 let browser = match &mut *browser {
1479 Some(b) => b,
1480 None => return Err(anyhow!("browser closed")),
1481 };
1482 let log = crate::network::NetworkLog::new();
1483 let handler = crate::network::make_network_handler(log.clone());
1484 let events: HashSet<&str> = [
1485 "network.beforeRequestSent",
1486 "network.responseCompleted",
1487 "network.fetchError",
1488 ]
1489 .into_iter()
1490 .collect();
1491 browser
1492 .subscribe_events(events, handler)
1493 .await
1494 .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1495 Ok(log)
1496 }
1497
1498 pub async fn close(&self) -> Result<()> {
1506 let child_opt = self.child.lock().ok().and_then(|mut g| g.take());
1509
1510 if let Some(mut c) = child_opt {
1511 {
1530 let mut guard = self.browser.lock().await;
1531 if let Some(browser) = guard.as_mut() {
1532 let cmd = BrowserCommand::Close(BrowserCloseCmd {
1533 method: BrowserCloseMethod::Close,
1534 params: BrowserCloseParams {},
1535 });
1536 let _ = tokio::time::timeout(
1541 std::time::Duration::from_secs(10),
1542 browser.driver_mut().send_command(cmd),
1543 )
1544 .await;
1545 }
1546 }
1547 if !wait_for_exit(&mut c, 100).await {
1550 request_graceful_terminate(c.id());
1554 if !wait_for_exit(&mut c, 30).await {
1555 let _ = c.kill();
1556 let _ = c.wait();
1557 }
1558 }
1559 let _ = self.browser.lock().await.take();
1563 } else if let Some(browser) = self.browser.lock().await.take() {
1564 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1566 }
1567 Ok(())
1568 }
1569}
1570
1571#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1577pub enum ProxyScheme {
1578 #[default]
1580 Http,
1581 Socks5,
1583}
1584
1585#[derive(Debug, Clone, Default)]
1595pub struct ProxyConfig {
1596 pub scheme: ProxyScheme,
1597 pub host: String,
1598 pub port: u16,
1599 pub username: Option<String>,
1600 pub password: Option<String>,
1601}
1602
1603impl ProxyConfig {
1604 pub fn from_url(url: &str) -> Result<Self> {
1607 let (scheme, rest) = match url.split_once("://") {
1608 Some((s, r)) => (s.to_ascii_lowercase(), r),
1609 None => ("http".to_string(), url),
1610 };
1611 let scheme = match scheme.as_str() {
1612 "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1613 "http" | "https" => ProxyScheme::Http,
1614 other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1615 };
1616 let (auth, hostport) = match rest.rsplit_once('@') {
1617 Some((a, hp)) => (Some(a), hp),
1618 None => (None, rest),
1619 };
1620 let (username, password) = match auth {
1621 Some(a) => match a.split_once(':') {
1622 Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1623 None => (Some(a.to_string()), None),
1624 },
1625 None => (None, None),
1626 };
1627 let (host, port) = hostport
1628 .rsplit_once(':')
1629 .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1630 let port: u16 = port
1631 .parse()
1632 .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1633 if host.is_empty() {
1634 return Err(anyhow!("proxy URL missing host: {url}"));
1635 }
1636 Ok(Self {
1637 scheme,
1638 host: host.to_string(),
1639 port,
1640 username,
1641 password,
1642 })
1643 }
1644}
1645
1646pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1648 if proxy.username.is_some() || proxy.password.is_some() {
1649 tracing::warn!(
1650 "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1651 front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1652 foxdriver at that. Emitting host:port prefs only."
1653 );
1654 }
1655 let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1656 match proxy.scheme {
1657 ProxyScheme::Http => {
1658 lines.push(format!(
1659 r#"user_pref("network.proxy.http", "{}");"#,
1660 proxy.host
1661 ));
1662 lines.push(format!(
1663 r#"user_pref("network.proxy.http_port", {});"#,
1664 proxy.port
1665 ));
1666 lines.push(format!(
1667 r#"user_pref("network.proxy.ssl", "{}");"#,
1668 proxy.host
1669 ));
1670 lines.push(format!(
1671 r#"user_pref("network.proxy.ssl_port", {});"#,
1672 proxy.port
1673 ));
1674 lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1675 }
1676 ProxyScheme::Socks5 => {
1677 lines.push(format!(
1678 r#"user_pref("network.proxy.socks", "{}");"#,
1679 proxy.host
1680 ));
1681 lines.push(format!(
1682 r#"user_pref("network.proxy.socks_port", {});"#,
1683 proxy.port
1684 ));
1685 lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1686 lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1687 }
1688 }
1689 lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1692
1693 lines.push(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#.to_string());
1706 lines.push(r#"user_pref("media.peerconnection.ice.no_host", true);"#.to_string());
1707 lines.push(r#"user_pref("media.peerconnection.ice.default_address_only", true);"#.to_string());
1708
1709 lines.push(r#"user_pref("network.dns.disablePrefetch", true);"#.to_string());
1719 lines.push(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#.to_string());
1720 lines.push(r#"user_pref("network.predictor.enabled", false);"#.to_string());
1721 lines.push(r#"user_pref("network.http.speculative-parallel-limit", 0);"#.to_string());
1722 lines.push(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#.to_string());
1723
1724 lines.push('\n'.to_string());
1725 lines.join("\n")
1726}
1727
1728#[derive(Debug, Clone, Default)]
1729pub struct FoxBrowserConfig {
1730 pub executable_path: Option<String>,
1731 pub profile_dir: Option<String>,
1744 pub headless: bool,
1745 pub viewport_width: u32,
1746 pub viewport_height: u32,
1747 pub user_agent: Option<String>,
1748 pub user_js_content: Option<String>,
1752 pub proxy: Option<ProxyConfig>,
1755 pub unhandled_prompt_behavior: Option<String>,
1763 pub env: Vec<(String, String)>,
1773}
1774
1775fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1779 let handler = match s.trim().to_ascii_lowercase().as_str() {
1780 "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1781 "dismiss" => UserPromptHandlerType::Dismiss,
1782 "ignore" => UserPromptHandlerType::Ignore,
1783 "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1784 UserPromptHandlerType::DismissAndNotify
1785 }
1786 _ => return None,
1787 };
1788 Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1789}
1790
1791fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1793 let dir = std::path::Path::new(profile_dir);
1794 std::fs::create_dir_all(dir)
1795 .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1796 let path = dir.join("user.js");
1797 std::fs::write(&path, content)
1798 .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1799 Ok(())
1800}
1801
1802pub async fn launch_firefox(mut config: FoxBrowserConfig) -> Result<Page> {
1804 let mut caps = FirefoxCapabilities::default();
1805 caps.accept_insecure_certs(true);
1806 if let Some(behavior) = config
1807 .unhandled_prompt_behavior
1808 .as_deref()
1809 .and_then(prompt_behavior_capability)
1810 {
1811 caps.unhandled_prompt_behavior(behavior);
1812 }
1813
1814 let mut args = Vec::new();
1815 if config.headless {
1816 args.push("--headless".to_string());
1817 }
1818 if let Some(ref ua) = config.user_agent {
1819 args.push(format!("--user-agent={}", ua));
1820 }
1821 if config.viewport_width > 0 {
1822 args.push(format!("--width={}", config.viewport_width));
1823 }
1824 if config.viewport_height > 0 {
1825 args.push(format!("--height={}", config.viewport_height));
1826 }
1827
1828 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1833 if let Some(ref proxy) = config.proxy {
1834 if !user_js.is_empty() && !user_js.ends_with('\n') {
1835 user_js.push('\n');
1836 }
1837 user_js.push_str(&proxy_prefs(proxy));
1838 }
1839 if !user_js.is_empty() {
1840 if config
1850 .profile_dir
1851 .as_deref()
1852 .filter(|d| !d.is_empty())
1853 .is_none()
1854 {
1855 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1856 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1857 let dir =
1858 std::env::temp_dir().join(format!("foxdriver-profile-{}-{n}", std::process::id()));
1859 config.profile_dir = Some(dir.to_string_lossy().into_owned());
1860 }
1861 let profile_dir = config
1862 .profile_dir
1863 .as_deref()
1864 .ok_or_else(|| anyhow!("profile_dir missing when writing user.js"))?;
1865 write_user_js(profile_dir, &user_js)
1868 .map_err(|e| anyhow!("failed to write user.js to profile {profile_dir:?}: {e}"))?;
1869 }
1870
1871 let profile_dir = config.profile_dir.clone();
1872 let cfg = FirefoxConfig {
1873 capabilities: caps,
1874 firefox_executable_path: config.executable_path,
1875 profile_dir: config.profile_dir,
1876 browser_flags: Some(args),
1877 ..Default::default()
1878 };
1879
1880 let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1881 .await
1882 .map_err(|_| anyhow!("Firefox launch timed out after 30s, check that Firefox is installed and not already running with a locked profile"))?;
1883 Ok(Page {
1884 browser: tokio::sync::Mutex::new(Some(browser)),
1885 profile_dir,
1886 child: std::sync::Mutex::new(None),
1887 })
1888}
1889
1890fn reserve_local_port() -> Result<u16> {
1895 let listener = std::net::TcpListener::bind("127.0.0.1:0")
1896 .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1897 let port = listener
1898 .local_addr()
1899 .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1900 .port();
1901 Ok(port)
1902}
1903
1904fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1914 if let Some(p) = explicit {
1915 return Ok(p);
1916 }
1917 const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1918 if let Ok(path) = std::env::var("PATH") {
1919 let sep = if cfg!(windows) { ';' } else { ':' };
1920 for dir in path.split(sep).filter(|d| !d.is_empty()) {
1921 for name in NAMES {
1922 let cand = std::path::Path::new(dir).join(name);
1923 if cand.is_file() {
1924 return Ok(cand.to_string_lossy().into_owned());
1925 }
1926 }
1927 }
1928 }
1929 const FIXED: &[&str] = &[
1931 "/usr/local/bin/firefox",
1932 "/usr/bin/firefox",
1933 "/opt/firefox/firefox",
1934 "/snap/bin/firefox",
1935 "/Applications/Firefox.app/Contents/MacOS/firefox",
1936 "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1937 "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1938 ];
1939 for p in FIXED {
1940 if std::path::Path::new(p).is_file() {
1941 return Ok((*p).to_string());
1942 }
1943 }
1944 Err(anyhow!(
1945 "could not find a Firefox binary, set FoxBrowserConfig.executable_path or install Firefox on PATH"
1946 ))
1947}
1948
1949pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
1965 let exe = resolve_firefox_binary(config.executable_path.clone())?;
1966
1967 let host = "127.0.0.1".to_string();
1968 let port = reserve_local_port()?;
1969
1970 let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
1974 std::env::temp_dir()
1975 .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
1976 .display()
1977 .to_string()
1978 });
1979 std::fs::create_dir_all(&profile_dir)
1980 .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
1981
1982 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1983 if let Some(ref proxy) = config.proxy {
1984 if !user_js.is_empty() && !user_js.ends_with('\n') {
1985 user_js.push('\n');
1986 }
1987 user_js.push_str(&proxy_prefs(proxy));
1988 }
1989 if !user_js.is_empty() {
1990 write_user_js(&profile_dir, &user_js)?;
1991 }
1992
1993 let mut args = vec![
1997 format!("--remote-debugging-port={port}"),
1998 "--profile".to_string(),
1999 profile_dir.clone(),
2000 "--no-remote".to_string(),
2001 ];
2002 if config.headless {
2003 args.push("--headless".to_string());
2004 }
2005 if let Some(ref ua) = config.user_agent {
2006 args.push(format!("--user-agent={ua}"));
2007 }
2008 if config.viewport_width > 0 {
2009 args.push(format!("--width={}", config.viewport_width));
2010 }
2011 if config.viewport_height > 0 {
2012 args.push(format!("--height={}", config.viewport_height));
2013 }
2014
2015 let mut command = std::process::Command::new(&exe);
2021 command.args(&args).env("MOZ_LAUNCHER_PROCESS", "0");
2022 for (key, value) in &config.env {
2023 command.env(key, value);
2024 }
2025 let child = command
2026 .spawn()
2027 .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
2028
2029 let addr: std::net::SocketAddr = format!("{host}:{port}")
2032 .parse()
2033 .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
2034 let start = std::time::Instant::now();
2035 let ready_timeout = std::time::Duration::from_secs(30);
2036 loop {
2037 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250))
2038 .is_ok()
2039 {
2040 break;
2041 }
2042 if start.elapsed() >= ready_timeout {
2043 return Err(anyhow!(
2044 "browser debug port {port} never came up within {}s, the spawn likely failed (check {exe:?})",
2045 ready_timeout.as_secs()
2046 ));
2047 }
2048 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2049 }
2050
2051 let cfg = FirefoxConfig {
2062 host: Some(host.clone()),
2063 capabilities: {
2064 let mut caps = FirefoxCapabilities::default();
2065 caps.accept_insecure_certs(true);
2066 if let Some(behavior) = config
2067 .unhandled_prompt_behavior
2068 .as_deref()
2069 .and_then(prompt_behavior_capability)
2070 {
2071 caps.unhandled_prompt_behavior(behavior);
2072 }
2073 caps
2074 },
2075 launch_mode: FirefoxLaunchMode::Remote(port),
2076 remote_debugging_port: Some(port),
2077 ..Default::default()
2078 };
2079 let attach = tokio::spawn(async move {
2080 tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
2081 });
2082 let browser = match attach.await {
2083 Ok(Ok(b)) => b,
2084 Ok(Err(_elapsed)) => {
2085 return Err(anyhow!(
2086 "BiDi attach to self-managed browser timed out after 30s"
2087 ))
2088 }
2089 Err(join) => {
2090 return Err(anyhow!(
2091 "BiDi attach to self-managed browser failed: {join}"
2092 ))
2093 }
2094 };
2095
2096 Ok(Page {
2097 browser: tokio::sync::Mutex::new(Some(browser)),
2098 profile_dir: Some(profile_dir),
2099 child: std::sync::Mutex::new(Some(child)),
2100 })
2101}
2102
2103#[cfg(test)]
2104mod tests {
2105 use super::*;
2106
2107 #[cfg(unix)]
2114 #[test]
2115 fn terminate_and_reap_leaves_no_zombie() {
2116 let child = std::process::Command::new("/bin/sh")
2117 .args(["-c", "trap '' TERM; sleep 300"])
2118 .spawn()
2119 .expect("spawn test child");
2120 let pid = child.id();
2121 terminate_and_reap(child);
2122 assert!(
2124 !std::path::Path::new(&format!("/proc/{pid}")).exists(),
2125 "pid {pid} still present after terminate_and_reap (zombie leak)"
2126 );
2127 }
2128
2129 #[test]
2135 fn shared_escaper_covers_quote_and_control_breakout() {
2136 let malicious = "'); alert(1); //";
2137 let escaped = crate::frame::escape_js_string(malicious);
2138 assert_eq!(escaped, "\\'); alert(1); //");
2141 for (index, _) in escaped.match_indices('\'') {
2143 assert_eq!(escaped.as_bytes()[index - 1], b'\\');
2144 }
2145 }
2146
2147 use rustenium_bidi_definitions::script::types::{
2148 ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType, BooleanValue,
2149 BooleanValueType, ListRemoteValue, MappingRemoteValue, NullValue, NullValueType,
2150 NumberValue, NumberValueType, ObjectRemoteValue, ObjectRemoteValueType,
2151 PrimitiveProtocolValue, StringValue, StringValueType, UndefinedValue, UndefinedValueType,
2152 };
2153
2154 #[test]
2157 fn frame_spec_main_aliases() {
2158 assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
2159 assert_eq!(FrameSpec::parse(" "), FrameSpec::Main);
2160 assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
2161 assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
2162 }
2163
2164 #[test]
2165 fn frame_spec_index_forms() {
2166 assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
2168 assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
2169 assert_eq!(
2171 FrameSpec::parse("10737418241"),
2172 FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
2173 );
2174 assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
2176 }
2177
2178 #[test]
2179 fn frame_spec_url_and_name_prefixes() {
2180 assert_eq!(
2181 FrameSpec::parse("url:recaptcha/api2"),
2182 FrameSpec::UrlContains("recaptcha/api2".into())
2183 );
2184 assert_eq!(
2185 FrameSpec::parse("name:checkout-frame"),
2186 FrameSpec::NameEquals("checkout-frame".into())
2187 );
2188 assert_eq!(
2190 FrameSpec::parse("url: https://x.com "),
2191 FrameSpec::UrlContains("https://x.com".into())
2192 );
2193 }
2194
2195 #[test]
2196 fn frame_spec_bare_id_falls_through() {
2197 assert_eq!(
2199 FrameSpec::parse("10737418241-abc"),
2200 FrameSpec::Id("10737418241-abc".into())
2201 );
2202 assert_eq!(
2204 FrameSpec::parse("https://w.com/f"),
2205 FrameSpec::Id("https://w.com/f".into())
2206 );
2207 }
2208
2209 #[test]
2212 fn prompt_behavior_maps_known_values() {
2213 for s in [
2214 "accept",
2215 "ACCEPT",
2216 "dismiss",
2217 "ignore",
2218 "dismiss and notify",
2219 "notify",
2220 ] {
2221 assert!(
2222 prompt_behavior_capability(s).is_some(),
2223 "'{s}' should map to a capability"
2224 );
2225 }
2226 }
2227
2228 #[test]
2229 fn prompt_behavior_rejects_unknown() {
2230 assert!(prompt_behavior_capability("").is_none());
2231 assert!(prompt_behavior_capability("bogus").is_none());
2232 }
2233
2234 #[test]
2235 fn prompt_behavior_ignore_is_user_prompt_handler_type() {
2236 match prompt_behavior_capability("ignore") {
2237 Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {
2238 }
2239 other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
2240 }
2241 }
2242
2243 #[test]
2246 fn wire_string_extracts_value() {
2247 let v = serde_json::json!({"type": "string", "value": "hello"});
2248 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
2249 }
2250
2251 #[test]
2252 fn wire_number_passthrough() {
2253 let v = serde_json::json!({"type": "number", "value": 42.5});
2254 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
2255 }
2256
2257 #[test]
2258 fn wire_boolean_extracts_bool() {
2259 let v = serde_json::json!({"type": "boolean", "value": true});
2260 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
2261 }
2262
2263 #[test]
2264 fn wire_null_returns_null() {
2265 let v = serde_json::json!({"type": "null"});
2266 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2267 }
2268
2269 #[test]
2270 fn wire_undefined_returns_null() {
2271 let v = serde_json::json!({"type": "undefined"});
2272 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2273 }
2274
2275 #[test]
2276 fn wire_bigint_returns_string() {
2277 let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
2278 assert_eq!(
2279 bidi_wire_value_to_json(&v),
2280 serde_json::json!("9007199254740993")
2281 );
2282 }
2283
2284 #[test]
2285 fn wire_object_recurse() {
2286 let v = serde_json::json!({
2287 "type": "object",
2288 "value": [
2289 ["a", {"type": "string", "value": "alpha"}],
2290 ["b", {"type": "number", "value": 2}]
2291 ]
2292 });
2293 let out = bidi_wire_value_to_json(&v);
2294 assert_eq!(out["a"], "alpha");
2295 assert_eq!(out["b"], 2);
2296 }
2297
2298 #[test]
2299 fn wire_array_recurse() {
2300 let v = serde_json::json!({
2301 "type": "array",
2302 "value": [
2303 {"type": "string", "value": "x"},
2304 {"type": "number", "value": 1}
2305 ]
2306 });
2307 let out = bidi_wire_value_to_json(&v);
2308 assert_eq!(out, serde_json::json!(["x", 1]));
2309 }
2310
2311 #[test]
2312 fn wire_unknown_type_clones_raw() {
2313 let v = serde_json::json!({"type": "special", "payload": 99});
2314 assert_eq!(bidi_wire_value_to_json(&v), v);
2315 }
2316
2317 #[test]
2318 fn wire_missing_type_clones_raw() {
2319 let v = serde_json::json!({"payload": 99});
2320 assert_eq!(bidi_wire_value_to_json(&v), v);
2321 }
2322
2323 #[test]
2326 fn rv_string_value() {
2327 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2328 StringValue::new(StringValueType::String, "hi"),
2329 ));
2330 assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
2331 }
2332
2333 #[test]
2334 fn rv_number_value() {
2335 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2336 NumberValue::new(NumberValueType::Number, 2.5),
2337 ));
2338 assert_eq!(remote_value_to_json(&rv), serde_json::json!(2.5));
2339 }
2340
2341 #[test]
2342 fn rv_boolean_value() {
2343 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2344 BooleanValue::new(BooleanValueType::Boolean, true),
2345 ));
2346 assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
2347 }
2348
2349 #[test]
2350 fn rv_null_value() {
2351 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
2352 NullValue::new(NullValueType::Null),
2353 ));
2354 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2355 }
2356
2357 #[test]
2358 fn rv_undefined_value() {
2359 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
2360 UndefinedValue::new(UndefinedValueType::Undefined),
2361 ));
2362 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2363 }
2364
2365 #[test]
2366 fn rv_bigint_value() {
2367 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
2368 BigIntValue::new(BigIntValueType::Bigint, "999n"),
2369 ));
2370 assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
2371 }
2372
2373 #[test]
2374 fn rv_array_value() {
2375 let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2376 StringValue::new(StringValueType::String, "item"),
2377 ));
2378 let arr = ArrayRemoteValue {
2379 r#type: ArrayRemoteValueType::Array,
2380 handle: None,
2381 internal_id: None,
2382 value: Some(ListRemoteValue::new(vec![inner])),
2383 };
2384 let rv = RemoteValue::ArrayRemoteValue(arr);
2385 assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
2386 }
2387
2388 #[test]
2389 fn rv_object_value() {
2390 let obj = ObjectRemoteValue {
2391 r#type: ObjectRemoteValueType::Object,
2392 handle: None,
2393 internal_id: None,
2394 value: Some(MappingRemoteValue::new(vec![vec![
2395 serde_json::json!("key"),
2396 serde_json::json!({"type": "string", "value": "val"}),
2397 ]])),
2398 };
2399 let rv = RemoteValue::ObjectRemoteValue(obj);
2400 let out = remote_value_to_json(&rv);
2401 assert_eq!(out["key"], "val");
2402 }
2403
2404 #[test]
2405 fn rv_object_value_wire_key() {
2406 let obj = ObjectRemoteValue {
2407 r#type: ObjectRemoteValueType::Object,
2408 handle: None,
2409 internal_id: None,
2410 value: Some(MappingRemoteValue::new(vec![vec![
2411 serde_json::json!({"type": "string", "value": "key"}),
2412 serde_json::json!({"type": "string", "value": "val"}),
2413 ]])),
2414 };
2415 let rv = RemoteValue::ObjectRemoteValue(obj);
2416 let out = remote_value_to_json(&rv);
2417 assert_eq!(out["key"], "val");
2418 }
2419
2420 #[test]
2421 fn bidi_wire_value_to_json_object_wire_key() {
2422 let raw = serde_json::json!({
2423 "type": "object",
2424 "value": [
2425 [
2426 {"type": "string", "value": "wire_key"},
2427 {"type": "number", "value": 42}
2428 ]
2429 ]
2430 });
2431 let out = bidi_wire_value_to_json(&raw);
2432 assert_eq!(out["wire_key"], 42);
2433 }
2434
2435 #[test]
2436 fn rv_unsupported_returns_null() {
2437 let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
2438 rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
2439 );
2440 let rv = RemoteValue::SymbolRemoteValue(sym);
2441 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2442 }
2443
2444 #[test]
2447 fn eval_result_into_value_deserializes() {
2448 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2449 StringValue::new(StringValueType::String, "deserialized"),
2450 ));
2451 let er = EvaluationResult::new(rv);
2452 let s: String = er.into_value().unwrap();
2453 assert_eq!(s, "deserialized");
2454 }
2455
2456 #[test]
2457 fn eval_result_into_value_number() {
2458 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2459 NumberValue::new(NumberValueType::Number, 42i32),
2460 ));
2461 let er = EvaluationResult::new(rv);
2462 let n: i32 = er.into_value().unwrap();
2463 assert_eq!(n, 42);
2464 }
2465
2466 #[test]
2467 fn eval_result_remote_value_accessor() {
2468 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2469 BooleanValue::new(BooleanValueType::Boolean, false),
2470 ));
2471 let er = EvaluationResult::new(rv.clone());
2472 assert_eq!(er.remote_value(), &rv);
2473 }
2474
2475 #[test]
2478 fn fox_browser_config_default_is_headless_false() {
2479 let cfg = FoxBrowserConfig::default();
2480 assert!(!cfg.headless);
2481 assert!(cfg.executable_path.is_none());
2482 assert!(cfg.profile_dir.is_none());
2483 assert_eq!(cfg.viewport_width, 0);
2484 assert_eq!(cfg.viewport_height, 0);
2485 assert!(cfg.user_agent.is_none());
2486 assert!(cfg.user_js_content.is_none());
2487 }
2488
2489 #[test]
2492 fn write_user_js_creates_file() {
2493 let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2494 let _ = std::fs::remove_dir_all(&tmp);
2495 let content = "user_pref(\"test\", true);\n";
2496 write_user_js(tmp.to_str().unwrap(), content).unwrap();
2497 let path = tmp.join("user.js");
2498 assert!(path.exists());
2499 let read = std::fs::read_to_string(&path).unwrap();
2500 assert_eq!(read, content);
2501 let _ = std::fs::remove_dir_all(&tmp);
2502 }
2503
2504 #[test]
2505 fn write_user_js_creates_nested_dirs() {
2506 let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2507 let _ = std::fs::remove_dir_all(&tmp);
2508 let nested = tmp.join("a").join("b");
2509 write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2510 assert!(nested.join("user.js").exists());
2511 let _ = std::fs::remove_dir_all(&tmp);
2512 }
2513
2514 #[test]
2517 fn proxy_from_url_http_no_auth() {
2518 let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2519 assert_eq!(p.scheme, ProxyScheme::Http);
2520 assert_eq!(p.host, "1.2.3.4");
2521 assert_eq!(p.port, 8080);
2522 assert!(p.username.is_none() && p.password.is_none());
2523 }
2524
2525 #[test]
2526 fn proxy_from_url_socks5_with_auth() {
2527 let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2528 assert_eq!(p.scheme, ProxyScheme::Socks5);
2529 assert_eq!(p.host, "gw.residential.net");
2530 assert_eq!(p.port, 1080);
2531 assert_eq!(p.username.as_deref(), Some("user"));
2532 assert_eq!(p.password.as_deref(), Some("pass"));
2533 }
2534
2535 #[test]
2536 fn proxy_from_url_bare_defaults_http() {
2537 let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2538 assert_eq!(p.scheme, ProxyScheme::Http);
2539 assert_eq!(p.host, "10.0.0.1");
2540 assert_eq!(p.port, 3128);
2541 }
2542
2543 #[test]
2544 fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2545 assert!(ProxyConfig::from_url("http://nohost").is_err());
2546 assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2547 assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2548 }
2549
2550 #[test]
2551 fn proxy_prefs_http_emits_http_ssl_and_type() {
2552 let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2553 assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2554 assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2555 assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2556 assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2557 assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2558 assert!(!prefs.contains("network.proxy.socks"));
2560 }
2561
2562 #[test]
2563 fn proxy_prefs_socks5_emits_socks_and_version() {
2564 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2565 assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2566 assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2567 assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2568 assert!(!prefs.contains("network.proxy.http_port"));
2570 }
2571
2572 #[test]
2573 fn proxy_prefs_close_the_webrtc_ip_leak_for_both_schemes() {
2574 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2579 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2580 assert!(
2581 prefs.contains(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#),
2582 "{url}: must force ICE through the proxy (no direct-UDP srflx leak)"
2583 );
2584 assert!(
2585 prefs.contains(r#"user_pref("media.peerconnection.ice.no_host", true);"#),
2586 "{url}: must suppress LAN host candidates"
2587 );
2588 assert!(
2589 prefs.contains(
2590 r#"user_pref("media.peerconnection.ice.default_address_only", true);"#
2591 ),
2592 "{url}: must expose only the default route address"
2593 );
2594 assert!(
2596 !prefs.contains("media.peerconnection.enabled"),
2597 "{url}: must NOT disable WebRTC outright (a fingerprint tell); only confine ICE"
2598 );
2599 }
2600 }
2601
2602 #[test]
2603 fn proxy_prefs_close_the_dns_prefetch_leak_for_both_schemes() {
2604 for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2608 let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2609 assert!(
2610 prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#),
2611 "{url}: must disable DNS prefetch (a `dns-prefetch` link leaks a clear-text query)"
2612 );
2613 assert!(
2614 prefs.contains(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#),
2615 "{url}: must disable DNS prefetch from HTTPS origins too"
2616 );
2617 assert!(
2618 prefs.contains(r#"user_pref("network.predictor.enabled", false);"#),
2619 "{url}: must disable the network predictor (history-driven pre-resolution)"
2620 );
2621 assert!(
2622 prefs.contains(r#"user_pref("network.http.speculative-parallel-limit", 0);"#),
2623 "{url}: must stop speculative parallel connections"
2624 );
2625 assert!(
2626 prefs.contains(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#),
2627 "{url}: must stop urlbar speculative connect"
2628 );
2629 }
2630 }
2631
2632 #[test]
2633 fn proxy_prefs_socks_keeps_remote_dns_alongside_the_leak_guards() {
2634 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2638 assert!(prefs.contains(r#"user_pref("network.proxy.socks_remote_dns", true);"#));
2639 assert!(prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#));
2640 }
2641
2642 #[test]
2645 fn scroll_direction_up_not_eq_down() {
2646 assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2647 }
2648
2649 #[test]
2650 fn scroll_direction_clone_copy() {
2651 let a = ScrollDirection::Up;
2652 let b = a;
2653 assert_eq!(a, b); }
2655
2656 #[test]
2657 fn scroll_direction_debug() {
2658 let s = format!("{:?}", ScrollDirection::Down);
2659 assert!(s.contains("Down"));
2660 }
2661}