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::browsing_context::commands::HandleUserPrompt;
14use rustenium_bidi_definitions::browsing_context::types::{CssLocator, CssLocatorType, Locator};
15use rustenium_bidi_definitions::network::types::{BytesValue, SameSite, StringValue, StringValueType};
16use rustenium_bidi_definitions::input::commands::SetFiles;
17use rustenium_bidi_definitions::script::types::{ContextTarget, RemoteValue, SharedReference, Target};
18use rustenium_bidi_definitions::session::types::{UnhandledPromptBehavior, UserPromptHandlerType};
19use rustenium_bidi_definitions::storage::commands::{GetCookies, SetCookie, SetCookieParams};
20use rustenium_bidi_definitions::storage::types::PartialCookie;
21use serde::de::DeserializeOwned;
22use std::collections::HashSet;
23
24pub struct Page {
26 browser: tokio::sync::Mutex<Option<FoxBrowser>>,
27 profile_dir: Option<String>,
28 child: std::sync::Mutex<Option<std::process::Child>>,
33}
34
35impl Drop for Page {
36 fn drop(&mut self) {
37 if let Ok(mut guard) = self.browser.try_lock() {
41 let _ = guard.take();
42 }
43 if let Ok(mut child) = self.child.try_lock() {
46 if let Some(mut c) = child.take() {
47 let _ = c.kill();
48 }
49 }
50 }
51}
52
53pub type FrameId = rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
55
56#[derive(Debug, Clone, PartialEq, serde::Serialize)]
60pub struct FrameInfo {
61 pub id: String,
63 pub url: String,
65 pub name: String,
67 pub is_main: bool,
69}
70
71#[derive(Debug, Clone, PartialEq)]
75enum FrameSpec {
76 Main,
78 Index(usize),
80 IdOrIndex(String, usize),
84 Id(String),
86 UrlContains(String),
88 NameEquals(String),
90}
91
92impl FrameSpec {
93 fn parse(spec: &str) -> Self {
94 let s = spec.trim();
95 if s.is_empty() || s.eq_ignore_ascii_case("main") || s.eq_ignore_ascii_case("top") {
96 return FrameSpec::Main;
97 }
98 if let Some(rest) = s.strip_prefix("url:") {
99 return FrameSpec::UrlContains(rest.trim().to_string());
100 }
101 if let Some(rest) = s.strip_prefix("name:") {
102 return FrameSpec::NameEquals(rest.trim().to_string());
103 }
104 if let Some(rest) = s.strip_prefix("index:") {
105 if let Ok(n) = rest.trim().parse::<usize>() {
106 return FrameSpec::Index(n);
107 }
108 }
109 if let Ok(n) = s.parse::<usize>() {
114 return FrameSpec::IdOrIndex(s.to_string(), n);
115 }
116 FrameSpec::Id(s.to_string())
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct EvaluationResult {
123 inner: RemoteValue,
124}
125
126impl EvaluationResult {
127 pub fn new(inner: RemoteValue) -> Self {
128 Self { inner }
129 }
130
131 pub fn into_value<T: DeserializeOwned>(self) -> serde_json::Result<T> {
133 let json = remote_value_to_json(&self.inner);
134 serde_json::from_value(json)
135 }
136
137 pub fn remote_value(&self) -> &RemoteValue {
139 &self.inner
140 }
141}
142
143fn bidi_wire_value_to_json(v: &serde_json::Value) -> serde_json::Value {
145 match v.get("type").and_then(|t| t.as_str()) {
146 Some("string") => v
147 .get("value")
148 .and_then(|v| v.as_str())
149 .map(|s| serde_json::Value::String(s.to_string()))
150 .unwrap_or(serde_json::Value::Null),
151 Some("number") => v.get("value").cloned().unwrap_or(serde_json::Value::Null),
152 Some("boolean") => v
153 .get("value")
154 .and_then(|v| v.as_bool())
155 .map(serde_json::Value::Bool)
156 .unwrap_or(serde_json::Value::Null),
157 Some("null") | Some("undefined") => serde_json::Value::Null,
158 Some("bigint") => v
159 .get("value")
160 .and_then(|v| v.as_str())
161 .map(|s| serde_json::Value::String(s.to_string()))
162 .unwrap_or(serde_json::Value::Null),
163 Some("object") => {
164 let mut map = serde_json::Map::new();
165 if let Some(serde_json::Value::Array(pairs)) = v.get("value") {
166 for pair in pairs {
167 if let Some(serde_json::Value::Array(items)) = Some(pair) {
168 if items.len() >= 2 {
169 if let (Some(k), Some(val)) =
170 (items[0].as_str(), items.get(1))
171 {
172 map.insert(k.to_string(), bidi_wire_value_to_json(val));
173 }
174 }
175 }
176 }
177 }
178 serde_json::Value::Object(map)
179 }
180 Some("array") => {
181 let arr: Vec<serde_json::Value> = v
182 .get("value")
183 .and_then(|v| v.as_array())
184 .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
185 .unwrap_or_default();
186 serde_json::Value::Array(arr)
187 }
188 _ => v.clone(),
189 }
190}
191
192fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
194 match rv {
195 RemoteValue::PrimitiveProtocolValue(p) => match p {
196 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
197 serde_json::Value::String(s.value.clone())
198 }
199 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
200 match &n.value {
201 serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
202 _ => serde_json::Value::Null,
203 }
204 }
205 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
206 serde_json::Value::Bool(b.value)
207 }
208 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
209 serde_json::Value::Null
210 }
211 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(_) => {
212 serde_json::Value::Null
213 }
214 rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
215 serde_json::Value::String(b.value.clone())
216 }
217 },
218 RemoteValue::ArrayRemoteValue(a) => {
219 let arr: Vec<serde_json::Value> = a
220 .value
221 .as_ref()
222 .map(|v| v.inner().iter().map(remote_value_to_json).collect())
223 .unwrap_or_default();
224 serde_json::Value::Array(arr)
225 }
226 RemoteValue::ObjectRemoteValue(o) => {
227 let mut map = serde_json::Map::new();
228 if let Some(ref mapping) = o.value {
229 for pair in mapping.inner() {
230 if pair.len() >= 2 {
231 if let (Some(serde_json::Value::String(k)), Some(v)) =
232 (pair.first(), pair.get(1))
233 {
234 map.insert(k.clone(), bidi_wire_value_to_json(v));
235 }
236 }
237 }
238 }
239 serde_json::Value::Object(map)
240 }
241 _ => serde_json::Value::Null,
242 }
243}
244
245pub struct Element {
247 pub(crate) node: tokio::sync::Mutex<FoxNode>,
248 pub(crate) selector: String,
249}
250
251impl Element {
252 pub async fn click(&self) -> Result<()> {
254 let mut node = self.node.lock().await;
255 node.mouse_click()
256 .await
257 .map_err(|e| anyhow!("element click failed: {e:?}"))?;
258 Ok(())
259 }
260
261 pub fn selector(&self) -> &str {
263 &self.selector
264 }
265
266 pub async fn type_text(&self, text: &str) -> Result<()> {
268 let mut node = self.node.lock().await;
269 node.type_text(text.to_string())
270 .await
271 .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
272 Ok(())
273 }
274
275 pub async fn type_str(&self, text: &str) -> Result<()> {
277 self.type_text(text).await
278 }
279}
280
281type FoxBrowser = FirefoxBrowser;
283type FoxNode = rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum ScrollDirection {
288 Up,
289 Down,
290}
291
292impl Page {
293 pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
295 launch_firefox(config.unwrap_or_default()).await
296 }
297
298 pub async fn goto(&self, url: &str) -> Result<()> {
300 let mut browser = self.browser.lock().await;
301 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
302 browser
303 .navigate(url)
304 .await
305 .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
306 Ok(())
307 }
308
309 pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
311 let mut browser = self.browser.lock().await;
312 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
313 let result = browser
314 .evaluate_script(expr.into(), false)
315 .await
316 .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
317 Ok(EvaluationResult::new(result.result))
318 }
319
320 pub async fn evaluate_in_context(
322 &self,
323 expr: impl Into<String>,
324 context: &FrameId,
325 ) -> Result<EvaluationResult> {
326 let mut browser = self.browser.lock().await;
327 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
328 let options = EvaluateScriptOptionsBuilder::default()
329 .target(Target::ContextTarget(ContextTarget::new(context.clone())))
330 .build();
331 let result = browser
332 .evaluate_script_with_options(expr.into(), false, options)
333 .await
334 .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
335 Ok(EvaluationResult::new(result.result))
336 }
337
338 pub async fn find_element(&self, selector: &str) -> Result<Element> {
340 let mut browser = self.browser.lock().await;
341 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
342 let locator = Locator::CssLocator(CssLocator::new(
343 CssLocatorType::Css,
344 selector.to_string(),
345 ));
346 match browser.find_node(locator).await {
347 Ok(Some(node)) => {
348 Ok(Element {
349 node: tokio::sync::Mutex::new(node),
350 selector: selector.to_string(),
351 })
352 }
353 Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
354 Err(e) => Err(anyhow!("find_element failed: {e:?}")),
355 }
356 }
357
358 pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
360 let mut browser = self.browser.lock().await;
361 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
362 let locator = Locator::CssLocator(CssLocator::new(
363 CssLocatorType::Css,
364 selector.to_string(),
365 ));
366 let nodes = browser
367 .find_nodes(locator)
368 .await
369 .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
370 Ok(nodes
371 .into_iter()
372 .map(|n| Element {
373 node: tokio::sync::Mutex::new(n),
374 selector: selector.to_string(),
375 })
376 .collect())
377 }
378
379 pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
387 if files.is_empty() {
388 return Err(anyhow!("set_files: no files provided"));
389 }
390 let element = self.find_element(selector).await?;
394 let (shared_id, context) = {
395 let node = element.node.lock().await;
396 let id = node.get_shared_id().cloned().ok_or_else(|| {
397 anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
398 })?;
399 (id, node.get_context_id().clone())
400 };
401 let element_ref: SharedReference = SharedReference::builder()
402 .shared_id(shared_id)
403 .build()
404 .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
405 let command = SetFiles::builder()
406 .context(context)
407 .element(element_ref)
408 .files(files)
409 .build()
410 .map_err(|e| anyhow!("set_files: build command: {e}"))?;
411 let mut browser = self.browser.lock().await;
412 let browser = match &mut *browser {
413 Some(b) => b,
414 None => return Err(anyhow!("browser closed")),
415 };
416 let response = browser
417 .driver_mut()
418 .send_command(command)
419 .await
420 .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
421 let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
422 .result
423 .try_into()
424 .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
425 Ok(())
426 }
427
428 pub async fn screenshot(&self) -> Result<Vec<u8>> {
430 let mut browser = self.browser.lock().await;
431 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
432 let b64 = browser
433 .screenshot()
434 .await
435 .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
436 base64::engine::general_purpose::STANDARD
437 .decode(b64)
438 .map_err(|e| anyhow!("base64 decode failed: {e}"))
439 }
440
441 pub async fn reload(&self) -> Result<()> {
443 let mut browser = self.browser.lock().await;
444 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
445 browser
446 .evaluate_script("location.reload()".to_string(), false)
447 .await
448 .map_err(|e| anyhow!("reload failed: {e:?}"))?;
449 Ok(())
450 }
451
452 pub async fn url(&self) -> Result<String> {
454 let eval = self.evaluate("document.URL").await?;
455 eval.into_value::<String>()
456 .map_err(|e| anyhow!("url deserialize failed: {e}"))
457 }
458
459 pub async fn title(&self) -> Result<String> {
461 let eval = self.evaluate("document.title").await?;
462 eval.into_value::<String>()
463 .map_err(|e| anyhow!("title deserialize failed: {e}"))
464 }
465
466 pub async fn frames(&self) -> Result<Vec<FrameId>> {
468 let browser = self.browser.lock().await;
469 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
470 let contexts = browser
471 .driver()
472 .browsing_contexts
473 .lock()
474 .unwrap_or_else(|e| e.into_inner())
475 .iter()
476 .map(|c| c.id().clone())
477 .collect();
478 Ok(contexts)
479 }
480
481 pub async fn mainframe(&self) -> Result<Option<FrameId>> {
483 let browser = self.browser.lock().await;
484 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
485 match browser.driver().get_active_context_id() {
486 Ok(ctx) => Ok(Some(ctx)),
487 Err(e) => {
488 tracing::debug!("get_active_context_id failed: {e:?}");
489 Ok(None)
490 }
491 }
492 }
493
494 pub async fn frame_execution_context(
496 &self,
497 frame_id: FrameId,
498 ) -> Result<Option<FrameId>> {
499 let browser = self.browser.lock().await;
500 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
501 let exists = browser
502 .driver()
503 .browsing_contexts
504 .lock()
505 .unwrap_or_else(|e| e.into_inner())
506 .iter()
507 .any(|c| c.id() == &frame_id);
508 Ok(if exists { Some(frame_id) } else { None })
509 }
510
511 pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
521 let frame_ids = self.frames().await?;
522 let main = self.mainframe().await?;
523 let mut out = Vec::with_capacity(frame_ids.len());
524 for fid in frame_ids {
525 let (url, name) = match self
529 .evaluate_in_context(
530 "({u: document.URL, n: (window.name || \"\")})",
531 &fid,
532 )
533 .await
534 {
535 Ok(eval) => match eval.into_value::<serde_json::Value>() {
536 Ok(v) => (
537 v["u"].as_str().unwrap_or("").to_string(),
538 v["n"].as_str().unwrap_or("").to_string(),
539 ),
540 Err(_) => (String::new(), String::new()),
541 },
542 Err(e) => {
543 tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
544 (String::new(), String::new())
545 }
546 };
547 out.push(FrameInfo {
548 is_main: Some(&fid) == main.as_ref(),
549 id: fid.inner().to_string(),
550 url,
551 name,
552 });
553 }
554 Ok(out)
555 }
556
557 pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
570 self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
571 .await
572 }
573
574 pub async fn resolve_frame_within(
577 &self,
578 spec: &str,
579 timeout: std::time::Duration,
580 ) -> Result<FrameId> {
581 let parsed = FrameSpec::parse(spec);
582 let deadline = std::time::Instant::now() + timeout;
583 loop {
584 if let Some(fid) = self.try_resolve_frame(&parsed).await? {
585 return Ok(fid);
586 }
587 if std::time::Instant::now() >= deadline {
588 return Err(anyhow!(
589 "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
590 ));
591 }
592 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
593 }
594 }
595
596 async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
599 if matches!(parsed, FrameSpec::Main) {
600 return Ok(self.mainframe().await?);
601 }
602 let frames = self.frames().await?;
603 match parsed {
604 FrameSpec::Main => unreachable!(),
605 FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
606 FrameSpec::IdOrIndex(id, idx) => {
607 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
609 return Ok(Some(fid.clone()));
610 }
611 Ok(frames.get(*idx).cloned())
612 }
613 FrameSpec::Id(id) => {
614 if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
615 return Ok(Some(fid.clone()));
616 }
617 self.frame_by_url_contains(id).await
620 }
621 FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
622 FrameSpec::NameEquals(name) => {
623 for info in self.list_frames().await? {
624 if &info.name == name {
625 return Ok(Some(FrameId::new(info.id)));
626 }
627 }
628 Ok(None)
629 }
630 }
631 }
632
633 async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
636 for info in self.list_frames().await? {
637 if info.url.contains(sub) {
638 return Ok(Some(FrameId::new(info.id)));
639 }
640 }
641 Ok(None)
642 }
643
644 pub async fn eval_in_frame(
649 &self,
650 spec: &str,
651 expr: impl Into<String>,
652 ) -> Result<EvaluationResult> {
653 let fid = self.resolve_frame(spec).await?;
654 self.evaluate_in_context(expr, &fid).await
655 }
656
657 pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
664 let fid = self.resolve_frame(spec).await?;
665 let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
666 let js = format!(
667 r#"(function() {{
668 const el = document.querySelector('{escaped}');
669 if (!el) return null;
670 const r = el.getBoundingClientRect();
671 if (r.width <= 0 || r.height <= 0) return null;
672 return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
673 }})()"#
674 );
675 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
678 loop {
679 if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
680 if let Ok(val) = eval.into_value::<serde_json::Value>() {
681 if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
682 return self.click_at_in(&fid, x, y).await;
683 }
684 }
685 }
686 if std::time::Instant::now() >= deadline {
687 return Err(anyhow!(
688 "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
689 ));
690 }
691 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
692 }
693 }
694
695 pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
699 let fid = self.resolve_frame(spec).await?;
700 let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
701 let focus_js = format!(
702 r#"(function() {{
703 const el = document.querySelector('{escaped}');
704 if (!el) return false;
705 el.focus();
706 return document.activeElement === el;
707 }})()"#
708 );
709 let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
711 loop {
712 let focused = self
713 .evaluate_in_context(&focus_js, &fid)
714 .await
715 .ok()
716 .and_then(|e| e.into_value::<bool>().ok())
717 .unwrap_or(false);
718 if focused {
719 break;
720 }
721 if std::time::Instant::now() >= deadline {
722 return Err(anyhow!(
723 "type_in_frame: could not focus '{selector}' in frame '{spec}'"
724 ));
725 }
726 tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
727 }
728 let browser = self.browser.lock().await;
729 let browser = match &*browser {
730 Some(b) => b,
731 None => return Err(anyhow!("browser closed")),
732 };
733 browser
734 .keyboard()
735 .type_text(text, &fid, None)
736 .await
737 .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
738 Ok(())
739 }
740
741 pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
755 let mut browser = self.browser.lock().await;
756 let browser = match &mut *browser {
757 Some(b) => b,
758 None => return Err(anyhow!("browser closed")),
759 };
760 let log = crate::dialog::DialogLog::new();
761 let handler = crate::dialog::make_dialog_handler(log.clone());
762 let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
763 browser
764 .subscribe_events(events, handler)
765 .await
766 .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
767 Ok(log)
768 }
769
770 pub async fn start_sensors(&self) -> Result<String> {
784 let id = self.add_preload_script(crate::sensors::SENSOR_SCRIPT).await?;
785 let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
789 Ok(id)
790 }
791
792 pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
796 let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
797 eval.into_value::<serde_json::Value>()
798 .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
799 }
800
801 pub async fn handle_user_prompt(
807 &self,
808 context: Option<&FrameId>,
809 accept: bool,
810 user_text: Option<&str>,
811 ) -> Result<()> {
812 let ctx = match context {
813 Some(c) => c.clone(),
814 None => self
815 .mainframe()
816 .await?
817 .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
818 };
819 let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
820 if let Some(text) = user_text {
821 builder = builder.user_text(text.to_string());
822 }
823 let command = builder
824 .build()
825 .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
826 let mut browser = self.browser.lock().await;
827 let browser = match &mut *browser {
828 Some(b) => b,
829 None => return Err(anyhow!("browser closed")),
830 };
831 let response = browser
832 .driver_mut()
833 .send_command(command)
834 .await
835 .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
836 let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
837 response
838 .result
839 .try_into()
840 .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
841 Ok(())
842 }
843
844 pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
850 let browser = self.browser.lock().await;
851 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
852 let context = browser
853 .driver()
854 .get_active_context_id()
855 .map_err(|e| anyhow!("{e:?}"))?;
856 let hm = browser.human_mouse();
857 hm.set_last_position(Point { x: x0, y: y0 });
858 hm.move_to(Point { x: x1, y: y1 }, &context, MouseMoveOptions::default())
859 .await
860 .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
861 Ok(())
862 }
863
864 pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
866 let browser = self.browser.lock().await;
867 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
868 let context = browser
869 .driver()
870 .get_active_context_id()
871 .map_err(|e| anyhow!("{e:?}"))?;
872 let hm = browser.human_mouse();
873 hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
874 .await
875 .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
876 hm.down(&context, MouseOptions {
877 button: Some(MouseButton::Left),
878 })
879 .await
880 .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
881 Ok(())
882 }
883
884 pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
886 let browser = self.browser.lock().await;
887 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
888 let context = browser
889 .driver()
890 .get_active_context_id()
891 .map_err(|e| anyhow!("{e:?}"))?;
892 let hm = browser.human_mouse();
893 hm.up(&context, MouseOptions {
894 button: Some(MouseButton::Left),
895 })
896 .await
897 .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
898 Ok(())
899 }
900
901 pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
911 let context = self
912 .mainframe()
913 .await?
914 .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
915 self.click_at_in(&context, x, y).await
916 }
917
918 pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
929 let browser = self.browser.lock().await;
930 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
931 let hm = browser.human_mouse();
932 hm.set_last_position(Point { x, y });
940 let options = MouseClickOptions {
941 button: Some(MouseButton::Left),
942 count: Some(1),
943 delay: Some(80),
944 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
945 };
946 hm.click(Some(Point { x, y }), context, options)
947 .await
948 .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
949 Ok(())
950 }
951
952 pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
964 let browser = self.browser.lock().await;
965 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
966 let context = browser
967 .driver()
968 .get_active_context_id()
969 .map_err(|e| anyhow!("{e:?}"))?;
970 browser
971 .mouse()
972 .move_to(
973 Point { x, y },
974 &context,
975 MouseMoveOptions {
976 steps: Some(0),
977 origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
978 },
979 )
980 .await
981 .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
982 Ok(())
983 }
984
985 pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
987 let browser = self.browser.lock().await;
988 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
989 let context = browser
990 .driver()
991 .get_active_context_id()
992 .map_err(|e| anyhow!("{e:?}"))?;
993 browser
994 .mouse()
995 .wheel(
996 &context,
997 MouseWheelOptions {
998 delta_x: Some(dx),
999 delta_y: Some(dy),
1000 },
1001 )
1002 .await
1003 .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1004 Ok(())
1005 }
1006
1007 pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1009 let browser = self.browser.lock().await;
1010 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1011 let context = browser
1012 .driver()
1013 .get_active_context_id()
1014 .map_err(|e| anyhow!("{e:?}"))?;
1015 let y_distance = match direction {
1016 ScrollDirection::Down => amount as i32,
1017 ScrollDirection::Up => -(amount as i32),
1018 };
1019 browser
1020 .human_mouse()
1021 .scroll(y_distance, 0, &context)
1022 .await
1023 .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1024 Ok(())
1025 }
1026
1027 pub async fn type_text(&self, text: &str) -> Result<()> {
1029 let browser = self.browser.lock().await;
1030 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1031 let context = browser
1032 .driver()
1033 .get_active_context_id()
1034 .map_err(|e| anyhow!("{e:?}"))?;
1035 browser
1036 .keyboard()
1037 .type_text(text, &context, None)
1038 .await
1039 .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1040 Ok(())
1041 }
1042
1043 pub async fn key_down(&self, key: &str) -> Result<()> {
1045 let browser = self.browser.lock().await;
1046 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1047 let context = browser
1048 .driver()
1049 .get_active_context_id()
1050 .map_err(|e| anyhow!("{e:?}"))?;
1051 browser
1052 .keyboard()
1053 .down(key, &context)
1054 .await
1055 .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1056 Ok(())
1057 }
1058
1059 pub async fn key_up(&self, key: &str) -> Result<()> {
1061 let browser = self.browser.lock().await;
1062 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1063 let context = browser
1064 .driver()
1065 .get_active_context_id()
1066 .map_err(|e| anyhow!("{e:?}"))?;
1067 browser
1068 .keyboard()
1069 .up(key, &context)
1070 .await
1071 .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1072 Ok(())
1073 }
1074
1075 pub async fn key_press(&self, key: &str) -> Result<()> {
1077 let browser = self.browser.lock().await;
1078 let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1079 let context = browser
1080 .driver()
1081 .get_active_context_id()
1082 .map_err(|e| anyhow!("{e:?}"))?;
1083 browser
1084 .keyboard()
1085 .press(key, &context, None)
1086 .await
1087 .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1088 Ok(())
1089 }
1090
1091 pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1108 let function_declaration = format!("() => {{\n{source}\n}}");
1109 let mut browser = self.browser.lock().await;
1110 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1111 let id = browser
1112 .add_preload_script(function_declaration)
1113 .await
1114 .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1115 Ok(id)
1116 }
1117
1118 pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1120 let mut browser = self.browser.lock().await;
1121 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1122 let response = browser
1123 .driver_mut()
1124 .send_command(GetCookies {
1125 method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1126 params: Default::default(),
1127 })
1128 .await
1129 .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1130 let result: rustenium_bidi_definitions::storage::results::GetCookiesResult =
1131 response
1132 .result
1133 .try_into()
1134 .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1135 Ok(result
1136 .cookies
1137 .into_iter()
1138 .map(|c| crate::cookies::CapturedCookie {
1139 name: c.name,
1140 value: match c.value {
1141 BytesValue::StringValue(s) => s.value,
1142 BytesValue::Base64Value(b) => b.value,
1143 },
1144 domain: c.domain,
1145 path: c.path,
1146 expires: c.expiry.map(|e| e as i64),
1147 secure: c.secure,
1148 http_only: c.http_only,
1149 same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1150 })
1151 .collect())
1152 }
1153
1154 pub async fn set_cookie(
1156 &self,
1157 name: &str,
1158 value: &str,
1159 domain: &str,
1160 path: Option<&str>,
1161 expires: Option<u64>,
1162 secure: Option<bool>,
1163 http_only: Option<bool>,
1164 same_site: Option<SameSite>,
1165 ) -> Result<()> {
1166 let mut browser = self.browser.lock().await;
1167 let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1168 let cookie = PartialCookie {
1169 name: name.to_string(),
1170 value: BytesValue::StringValue(StringValue::new(
1171 StringValueType::String,
1172 value.to_string(),
1173 )),
1174 domain: domain.to_string(),
1175 path: path.map(|p| p.to_string()),
1176 http_only,
1177 secure,
1178 same_site,
1179 expiry: expires,
1180 extensible: Default::default(),
1181 };
1182 let response = browser
1183 .driver_mut()
1184 .send_command(SetCookie {
1185 method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1186 params: SetCookieParams::new(cookie),
1187 })
1188 .await
1189 .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1190 let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1191 .result
1192 .try_into()
1193 .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1194 Ok(())
1195 }
1196
1197 pub fn profile_dir(&self) -> Option<&str> {
1199 self.profile_dir.as_deref()
1200 }
1201
1202 pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1216 let mut browser = self.browser.lock().await;
1217 let browser = match &mut *browser {
1218 Some(b) => b,
1219 None => return Err(anyhow!("browser closed")),
1220 };
1221 let log = crate::network::NetworkLog::new();
1222 let handler = crate::network::make_network_handler(log.clone());
1223 let events: HashSet<&str> = [
1224 "network.beforeRequestSent",
1225 "network.responseCompleted",
1226 "network.fetchError",
1227 ]
1228 .into_iter()
1229 .collect();
1230 browser
1231 .subscribe_events(events, handler)
1232 .await
1233 .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1234 Ok(log)
1235 }
1236
1237 pub async fn close(&self) -> Result<()> {
1239 if let Some(browser) = self.browser.lock().await.take() {
1240 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1241 }
1242 if let Ok(mut child) = self.child.lock() {
1245 if let Some(mut c) = child.take() {
1246 let _ = c.kill();
1247 let _ = c.wait();
1248 }
1249 }
1250 Ok(())
1251 }
1252}
1253
1254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1260pub enum ProxyScheme {
1261 #[default]
1263 Http,
1264 Socks5,
1266}
1267
1268#[derive(Debug, Clone, Default)]
1278pub struct ProxyConfig {
1279 pub scheme: ProxyScheme,
1280 pub host: String,
1281 pub port: u16,
1282 pub username: Option<String>,
1283 pub password: Option<String>,
1284}
1285
1286impl ProxyConfig {
1287 pub fn from_url(url: &str) -> Result<Self> {
1290 let (scheme, rest) = match url.split_once("://") {
1291 Some((s, r)) => (s.to_ascii_lowercase(), r),
1292 None => ("http".to_string(), url),
1293 };
1294 let scheme = match scheme.as_str() {
1295 "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1296 "http" | "https" => ProxyScheme::Http,
1297 other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1298 };
1299 let (auth, hostport) = match rest.rsplit_once('@') {
1300 Some((a, hp)) => (Some(a), hp),
1301 None => (None, rest),
1302 };
1303 let (username, password) = match auth {
1304 Some(a) => match a.split_once(':') {
1305 Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1306 None => (Some(a.to_string()), None),
1307 },
1308 None => (None, None),
1309 };
1310 let (host, port) = hostport
1311 .rsplit_once(':')
1312 .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1313 let port: u16 = port
1314 .parse()
1315 .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1316 if host.is_empty() {
1317 return Err(anyhow!("proxy URL missing host: {url}"));
1318 }
1319 Ok(Self {
1320 scheme,
1321 host: host.to_string(),
1322 port,
1323 username,
1324 password,
1325 })
1326 }
1327}
1328
1329pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1331 if proxy.username.is_some() || proxy.password.is_some() {
1332 tracing::warn!(
1333 "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1334 front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1335 foxdriver at that. Emitting host:port prefs only."
1336 );
1337 }
1338 let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1339 match proxy.scheme {
1340 ProxyScheme::Http => {
1341 lines.push(format!(
1342 r#"user_pref("network.proxy.http", "{}");"#,
1343 proxy.host
1344 ));
1345 lines.push(format!(
1346 r#"user_pref("network.proxy.http_port", {});"#,
1347 proxy.port
1348 ));
1349 lines.push(format!(r#"user_pref("network.proxy.ssl", "{}");"#, proxy.host));
1350 lines.push(format!(
1351 r#"user_pref("network.proxy.ssl_port", {});"#,
1352 proxy.port
1353 ));
1354 lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1355 }
1356 ProxyScheme::Socks5 => {
1357 lines.push(format!(
1358 r#"user_pref("network.proxy.socks", "{}");"#,
1359 proxy.host
1360 ));
1361 lines.push(format!(
1362 r#"user_pref("network.proxy.socks_port", {});"#,
1363 proxy.port
1364 ));
1365 lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1366 lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1367 }
1368 }
1369 lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1372 lines.push('\n'.to_string());
1373 lines.join("\n")
1374}
1375
1376#[derive(Debug, Clone, Default)]
1377pub struct FoxBrowserConfig {
1378 pub executable_path: Option<String>,
1379 pub profile_dir: Option<String>,
1380 pub headless: bool,
1381 pub viewport_width: u32,
1382 pub viewport_height: u32,
1383 pub user_agent: Option<String>,
1384 pub user_js_content: Option<String>,
1388 pub proxy: Option<ProxyConfig>,
1391 pub unhandled_prompt_behavior: Option<String>,
1399}
1400
1401fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1405 let handler = match s.trim().to_ascii_lowercase().as_str() {
1406 "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1407 "dismiss" => UserPromptHandlerType::Dismiss,
1408 "ignore" => UserPromptHandlerType::Ignore,
1409 "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1410 UserPromptHandlerType::DismissAndNotify
1411 }
1412 _ => return None,
1413 };
1414 Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1415}
1416
1417fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1419 let dir = std::path::Path::new(profile_dir);
1420 std::fs::create_dir_all(dir)
1421 .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1422 let path = dir.join("user.js");
1423 std::fs::write(&path, content)
1424 .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1425 Ok(())
1426}
1427
1428pub async fn launch_firefox(config: FoxBrowserConfig) -> Result<Page> {
1430 let mut caps = FirefoxCapabilities::default();
1431 caps.accept_insecure_certs(true);
1432 if let Some(behavior) = config
1433 .unhandled_prompt_behavior
1434 .as_deref()
1435 .and_then(prompt_behavior_capability)
1436 {
1437 caps.unhandled_prompt_behavior(behavior);
1438 }
1439
1440 let mut args = Vec::new();
1441 if config.headless {
1442 args.push("--headless".to_string());
1443 }
1444 if let Some(ref ua) = config.user_agent {
1445 args.push(format!("--user-agent={}", ua));
1446 }
1447 if config.viewport_width > 0 {
1448 args.push(format!("--width={}", config.viewport_width));
1449 }
1450 if config.viewport_height > 0 {
1451 args.push(format!("--height={}", config.viewport_height));
1452 }
1453
1454 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1459 if let Some(ref proxy) = config.proxy {
1460 if !user_js.is_empty() && !user_js.ends_with('\n') {
1461 user_js.push('\n');
1462 }
1463 user_js.push_str(&proxy_prefs(proxy));
1464 }
1465 if !user_js.is_empty() {
1466 if let Some(ref profile_dir) = &config.profile_dir {
1467 if let Err(e) = write_user_js(profile_dir, &user_js) {
1468 tracing::warn!("failed to write user.js for profile: {e}");
1469 }
1470 } else {
1471 tracing::warn!("user.js prefs (incl. proxy) ignored because profile_dir is not set");
1472 }
1473 }
1474
1475 let profile_dir = config.profile_dir.clone();
1476 let cfg = FirefoxConfig {
1477 capabilities: caps,
1478 firefox_executable_path: config.executable_path,
1479 profile_dir: config.profile_dir,
1480 browser_flags: Some(args),
1481 ..Default::default()
1482 };
1483
1484 let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1485 .await
1486 .map_err(|_| anyhow!("Firefox launch timed out after 30s — check that Firefox is installed and not already running with a locked profile"))?;
1487 Ok(Page {
1488 browser: tokio::sync::Mutex::new(Some(browser)),
1489 profile_dir,
1490 child: std::sync::Mutex::new(None),
1491 })
1492}
1493
1494fn reserve_local_port() -> Result<u16> {
1499 let listener = std::net::TcpListener::bind("127.0.0.1:0")
1500 .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1501 let port = listener
1502 .local_addr()
1503 .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1504 .port();
1505 Ok(port)
1506}
1507
1508fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1518 if let Some(p) = explicit {
1519 return Ok(p);
1520 }
1521 const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1522 if let Ok(path) = std::env::var("PATH") {
1523 let sep = if cfg!(windows) { ';' } else { ':' };
1524 for dir in path.split(sep).filter(|d| !d.is_empty()) {
1525 for name in NAMES {
1526 let cand = std::path::Path::new(dir).join(name);
1527 if cand.is_file() {
1528 return Ok(cand.to_string_lossy().into_owned());
1529 }
1530 }
1531 }
1532 }
1533 const FIXED: &[&str] = &[
1535 "/usr/local/bin/firefox",
1536 "/usr/bin/firefox",
1537 "/opt/firefox/firefox",
1538 "/snap/bin/firefox",
1539 "/Applications/Firefox.app/Contents/MacOS/firefox",
1540 "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1541 "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1542 ];
1543 for p in FIXED {
1544 if std::path::Path::new(p).is_file() {
1545 return Ok((*p).to_string());
1546 }
1547 }
1548 Err(anyhow!(
1549 "could not find a Firefox binary — set FoxBrowserConfig.executable_path or install Firefox on PATH"
1550 ))
1551}
1552
1553pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
1569 let exe = resolve_firefox_binary(config.executable_path.clone())?;
1570
1571 let host = "127.0.0.1".to_string();
1572 let port = reserve_local_port()?;
1573
1574 let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
1578 std::env::temp_dir()
1579 .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
1580 .display()
1581 .to_string()
1582 });
1583 std::fs::create_dir_all(&profile_dir)
1584 .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
1585
1586 let mut user_js = config.user_js_content.clone().unwrap_or_default();
1587 if let Some(ref proxy) = config.proxy {
1588 if !user_js.is_empty() && !user_js.ends_with('\n') {
1589 user_js.push('\n');
1590 }
1591 user_js.push_str(&proxy_prefs(proxy));
1592 }
1593 if !user_js.is_empty() {
1594 write_user_js(&profile_dir, &user_js)?;
1595 }
1596
1597 let mut args = vec![
1601 format!("--remote-debugging-port={port}"),
1602 "--profile".to_string(),
1603 profile_dir.clone(),
1604 "--no-remote".to_string(),
1605 ];
1606 if config.headless {
1607 args.push("--headless".to_string());
1608 }
1609 if let Some(ref ua) = config.user_agent {
1610 args.push(format!("--user-agent={ua}"));
1611 }
1612 if config.viewport_width > 0 {
1613 args.push(format!("--width={}", config.viewport_width));
1614 }
1615 if config.viewport_height > 0 {
1616 args.push(format!("--height={}", config.viewport_height));
1617 }
1618
1619 let child = std::process::Command::new(&exe)
1623 .args(&args)
1624 .env("MOZ_LAUNCHER_PROCESS", "0")
1625 .spawn()
1626 .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
1627
1628 let addr: std::net::SocketAddr = format!("{host}:{port}")
1631 .parse()
1632 .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
1633 let start = std::time::Instant::now();
1634 let ready_timeout = std::time::Duration::from_secs(30);
1635 loop {
1636 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250)).is_ok()
1637 {
1638 break;
1639 }
1640 if start.elapsed() >= ready_timeout {
1641 return Err(anyhow!(
1642 "browser debug port {port} never came up within {}s — the spawn likely failed (check {exe:?})",
1643 ready_timeout.as_secs()
1644 ));
1645 }
1646 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1647 }
1648
1649 let cfg = FirefoxConfig {
1660 host: Some(host.clone()),
1661 capabilities: {
1662 let mut caps = FirefoxCapabilities::default();
1663 caps.accept_insecure_certs(true);
1664 if let Some(behavior) = config
1665 .unhandled_prompt_behavior
1666 .as_deref()
1667 .and_then(prompt_behavior_capability)
1668 {
1669 caps.unhandled_prompt_behavior(behavior);
1670 }
1671 caps
1672 },
1673 launch_mode: FirefoxLaunchMode::Remote(port),
1674 remote_debugging_port: Some(port),
1675 ..Default::default()
1676 };
1677 let attach = tokio::spawn(async move {
1678 tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
1679 });
1680 let browser = match attach.await {
1681 Ok(Ok(b)) => b,
1682 Ok(Err(_elapsed)) => return Err(anyhow!("BiDi attach to self-managed browser timed out after 30s")),
1683 Err(join) => return Err(anyhow!("BiDi attach to self-managed browser failed: {join}")),
1684 };
1685
1686 Ok(Page {
1687 browser: tokio::sync::Mutex::new(Some(browser)),
1688 profile_dir: Some(profile_dir),
1689 child: std::sync::Mutex::new(Some(child)),
1690 })
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696 use rustenium_bidi_definitions::script::types::{
1697 ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType,
1698 BooleanValue, BooleanValueType, ListRemoteValue, MappingRemoteValue,
1699 NullValue, NullValueType, NumberValue, NumberValueType,
1700 ObjectRemoteValue, ObjectRemoteValueType, PrimitiveProtocolValue,
1701 StringValue, StringValueType, UndefinedValue, UndefinedValueType,
1702 };
1703
1704 #[test]
1707 fn frame_spec_main_aliases() {
1708 assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
1709 assert_eq!(FrameSpec::parse(" "), FrameSpec::Main);
1710 assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
1711 assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
1712 }
1713
1714 #[test]
1715 fn frame_spec_index_forms() {
1716 assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
1718 assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
1719 assert_eq!(
1721 FrameSpec::parse("10737418241"),
1722 FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
1723 );
1724 assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
1726 }
1727
1728 #[test]
1729 fn frame_spec_url_and_name_prefixes() {
1730 assert_eq!(
1731 FrameSpec::parse("url:recaptcha/api2"),
1732 FrameSpec::UrlContains("recaptcha/api2".into())
1733 );
1734 assert_eq!(
1735 FrameSpec::parse("name:checkout-frame"),
1736 FrameSpec::NameEquals("checkout-frame".into())
1737 );
1738 assert_eq!(
1740 FrameSpec::parse("url: https://x.com "),
1741 FrameSpec::UrlContains("https://x.com".into())
1742 );
1743 }
1744
1745 #[test]
1746 fn frame_spec_bare_id_falls_through() {
1747 assert_eq!(
1749 FrameSpec::parse("10737418241-abc"),
1750 FrameSpec::Id("10737418241-abc".into())
1751 );
1752 assert_eq!(
1754 FrameSpec::parse("https://w.com/f"),
1755 FrameSpec::Id("https://w.com/f".into())
1756 );
1757 }
1758
1759 #[test]
1762 fn prompt_behavior_maps_known_values() {
1763 for s in ["accept", "ACCEPT", "dismiss", "ignore", "dismiss and notify", "notify"] {
1764 assert!(
1765 prompt_behavior_capability(s).is_some(),
1766 "'{s}' should map to a capability"
1767 );
1768 }
1769 }
1770
1771 #[test]
1772 fn prompt_behavior_rejects_unknown() {
1773 assert!(prompt_behavior_capability("").is_none());
1774 assert!(prompt_behavior_capability("bogus").is_none());
1775 }
1776
1777 #[test]
1778 fn prompt_behavior_ignore_is_user_prompt_handler_type() {
1779 match prompt_behavior_capability("ignore") {
1780 Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {}
1781 other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
1782 }
1783 }
1784
1785 #[test]
1788 fn wire_string_extracts_value() {
1789 let v = serde_json::json!({"type": "string", "value": "hello"});
1790 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
1791 }
1792
1793 #[test]
1794 fn wire_number_passthrough() {
1795 let v = serde_json::json!({"type": "number", "value": 42.5});
1796 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
1797 }
1798
1799 #[test]
1800 fn wire_boolean_extracts_bool() {
1801 let v = serde_json::json!({"type": "boolean", "value": true});
1802 assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
1803 }
1804
1805 #[test]
1806 fn wire_null_returns_null() {
1807 let v = serde_json::json!({"type": "null"});
1808 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
1809 }
1810
1811 #[test]
1812 fn wire_undefined_returns_null() {
1813 let v = serde_json::json!({"type": "undefined"});
1814 assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
1815 }
1816
1817 #[test]
1818 fn wire_bigint_returns_string() {
1819 let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
1820 assert_eq!(
1821 bidi_wire_value_to_json(&v),
1822 serde_json::json!("9007199254740993")
1823 );
1824 }
1825
1826 #[test]
1827 fn wire_object_recurse() {
1828 let v = serde_json::json!({
1829 "type": "object",
1830 "value": [
1831 ["a", {"type": "string", "value": "alpha"}],
1832 ["b", {"type": "number", "value": 2}]
1833 ]
1834 });
1835 let out = bidi_wire_value_to_json(&v);
1836 assert_eq!(out["a"], "alpha");
1837 assert_eq!(out["b"], 2);
1838 }
1839
1840 #[test]
1841 fn wire_array_recurse() {
1842 let v = serde_json::json!({
1843 "type": "array",
1844 "value": [
1845 {"type": "string", "value": "x"},
1846 {"type": "number", "value": 1}
1847 ]
1848 });
1849 let out = bidi_wire_value_to_json(&v);
1850 assert_eq!(out, serde_json::json!(["x", 1]));
1851 }
1852
1853 #[test]
1854 fn wire_unknown_type_clones_raw() {
1855 let v = serde_json::json!({"type": "special", "payload": 99});
1856 assert_eq!(bidi_wire_value_to_json(&v), v);
1857 }
1858
1859 #[test]
1860 fn wire_missing_type_clones_raw() {
1861 let v = serde_json::json!({"payload": 99});
1862 assert_eq!(bidi_wire_value_to_json(&v), v);
1863 }
1864
1865 #[test]
1868 fn rv_string_value() {
1869 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1870 StringValue::new(StringValueType::String, "hi"),
1871 ));
1872 assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
1873 }
1874
1875 #[test]
1876 fn rv_number_value() {
1877 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
1878 NumberValue::new(NumberValueType::Number, 3.14),
1879 ));
1880 assert_eq!(remote_value_to_json(&rv), serde_json::json!(3.14));
1881 }
1882
1883 #[test]
1884 fn rv_boolean_value() {
1885 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
1886 BooleanValue::new(BooleanValueType::Boolean, true),
1887 ));
1888 assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
1889 }
1890
1891 #[test]
1892 fn rv_null_value() {
1893 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
1894 NullValue::new(NullValueType::Null),
1895 ));
1896 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1897 }
1898
1899 #[test]
1900 fn rv_undefined_value() {
1901 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
1902 UndefinedValue::new(UndefinedValueType::Undefined),
1903 ));
1904 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1905 }
1906
1907 #[test]
1908 fn rv_bigint_value() {
1909 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
1910 BigIntValue::new(BigIntValueType::Bigint, "999n"),
1911 ));
1912 assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
1913 }
1914
1915 #[test]
1916 fn rv_array_value() {
1917 let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1918 StringValue::new(StringValueType::String, "item"),
1919 ));
1920 let arr = ArrayRemoteValue {
1921 r#type: ArrayRemoteValueType::Array,
1922 handle: None,
1923 internal_id: None,
1924 value: Some(ListRemoteValue::new(vec![inner])),
1925 };
1926 let rv = RemoteValue::ArrayRemoteValue(arr);
1927 assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
1928 }
1929
1930 #[test]
1931 fn rv_object_value() {
1932 let obj = ObjectRemoteValue {
1933 r#type: ObjectRemoteValueType::Object,
1934 handle: None,
1935 internal_id: None,
1936 value: Some(MappingRemoteValue::new(vec![vec![
1937 serde_json::json!("key"),
1938 serde_json::json!({"type": "string", "value": "val"}),
1939 ]])),
1940 };
1941 let rv = RemoteValue::ObjectRemoteValue(obj);
1942 let out = remote_value_to_json(&rv);
1943 assert_eq!(out["key"], "val");
1944 }
1945
1946 #[test]
1947 fn rv_unsupported_returns_null() {
1948 let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
1949 rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
1950 );
1951 let rv = RemoteValue::SymbolRemoteValue(sym);
1952 assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1953 }
1954
1955 #[test]
1958 fn eval_result_into_value_deserializes() {
1959 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1960 StringValue::new(StringValueType::String, "deserialized"),
1961 ));
1962 let er = EvaluationResult::new(rv);
1963 let s: String = er.into_value().unwrap();
1964 assert_eq!(s, "deserialized");
1965 }
1966
1967 #[test]
1968 fn eval_result_into_value_number() {
1969 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
1970 NumberValue::new(NumberValueType::Number, 42i32),
1971 ));
1972 let er = EvaluationResult::new(rv);
1973 let n: i32 = er.into_value().unwrap();
1974 assert_eq!(n, 42);
1975 }
1976
1977 #[test]
1978 fn eval_result_remote_value_accessor() {
1979 let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
1980 BooleanValue::new(BooleanValueType::Boolean, false),
1981 ));
1982 let er = EvaluationResult::new(rv.clone());
1983 assert_eq!(er.remote_value(), &rv);
1984 }
1985
1986 #[test]
1989 fn fox_browser_config_default_is_headless_false() {
1990 let cfg = FoxBrowserConfig::default();
1991 assert!(!cfg.headless);
1992 assert!(cfg.executable_path.is_none());
1993 assert!(cfg.profile_dir.is_none());
1994 assert_eq!(cfg.viewport_width, 0);
1995 assert_eq!(cfg.viewport_height, 0);
1996 assert!(cfg.user_agent.is_none());
1997 assert!(cfg.user_js_content.is_none());
1998 }
1999
2000 #[test]
2003 fn write_user_js_creates_file() {
2004 let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2005 let _ = std::fs::remove_dir_all(&tmp);
2006 let content = "user_pref(\"test\", true);\n";
2007 write_user_js(tmp.to_str().unwrap(), content).unwrap();
2008 let path = tmp.join("user.js");
2009 assert!(path.exists());
2010 let read = std::fs::read_to_string(&path).unwrap();
2011 assert_eq!(read, content);
2012 let _ = std::fs::remove_dir_all(&tmp);
2013 }
2014
2015 #[test]
2016 fn write_user_js_creates_nested_dirs() {
2017 let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2018 let _ = std::fs::remove_dir_all(&tmp);
2019 let nested = tmp.join("a").join("b");
2020 write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2021 assert!(nested.join("user.js").exists());
2022 let _ = std::fs::remove_dir_all(&tmp);
2023 }
2024
2025 #[test]
2028 fn proxy_from_url_http_no_auth() {
2029 let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2030 assert_eq!(p.scheme, ProxyScheme::Http);
2031 assert_eq!(p.host, "1.2.3.4");
2032 assert_eq!(p.port, 8080);
2033 assert!(p.username.is_none() && p.password.is_none());
2034 }
2035
2036 #[test]
2037 fn proxy_from_url_socks5_with_auth() {
2038 let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2039 assert_eq!(p.scheme, ProxyScheme::Socks5);
2040 assert_eq!(p.host, "gw.residential.net");
2041 assert_eq!(p.port, 1080);
2042 assert_eq!(p.username.as_deref(), Some("user"));
2043 assert_eq!(p.password.as_deref(), Some("pass"));
2044 }
2045
2046 #[test]
2047 fn proxy_from_url_bare_defaults_http() {
2048 let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2049 assert_eq!(p.scheme, ProxyScheme::Http);
2050 assert_eq!(p.host, "10.0.0.1");
2051 assert_eq!(p.port, 3128);
2052 }
2053
2054 #[test]
2055 fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2056 assert!(ProxyConfig::from_url("http://nohost").is_err());
2057 assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2058 assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2059 }
2060
2061 #[test]
2062 fn proxy_prefs_http_emits_http_ssl_and_type() {
2063 let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2064 assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2065 assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2066 assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2067 assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2068 assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2069 assert!(!prefs.contains("network.proxy.socks"));
2071 }
2072
2073 #[test]
2074 fn proxy_prefs_socks5_emits_socks_and_version() {
2075 let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2076 assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2077 assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2078 assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2079 assert!(!prefs.contains("network.proxy.http_port"));
2081 }
2082
2083 #[test]
2086 fn scroll_direction_up_not_eq_down() {
2087 assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2088 }
2089
2090 #[test]
2091 fn scroll_direction_clone_copy() {
2092 let a = ScrollDirection::Up;
2093 let b = a;
2094 assert_eq!(a, b); }
2096
2097 #[test]
2098 fn scroll_direction_debug() {
2099 let s = format!("{:?}", ScrollDirection::Down);
2100 assert!(s.contains("Down"));
2101 }
2102}