Skip to main content

runtime_foxdriver/
browser.rs

1//! Firefox browser automation via rustenium (WebDriver BiDi).
2
3use 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
32/// Wrapper around rustenium's `FirefoxBrowser`.
33pub struct Page {
34    browser: tokio::sync::Mutex<Option<FoxBrowser>>,
35    profile_dir: Option<String>,
36    /// Child process when foxdriver spawned the browser itself (the
37    /// [`launch_firefox_self_managed`] / Remote-attach path). In the normal
38    /// `SpawnAndAttach` path rustenium owns the process (`kill_on_drop`), so
39    /// this is `None`; when foxdriver owns the spawn it must kill it here.
40    child: std::sync::Mutex<Option<std::process::Child>>,
41}
42
43impl Drop for Page {
44    fn drop(&mut self) {
45        // Best-effort synchronous cleanup: take the browser out of the
46        // mutex and drop it.  The underlying `Process` is spawned with
47        // `kill_on_drop(true)`, so dropping kills the Firefox process.
48        if let Ok(mut guard) = self.browser.try_lock() {
49            let _ = guard.take();
50        }
51        // A self-managed child (Remote-attach path) is not owned by rustenium
52        // terminate it explicitly so a self-spawned reynard/Camoufox never leaks.
53        // Best-effort GRACEFUL: SIGTERM first so Firefox flushes storage (Drop is
54        // sync and cannot wait long, so cap the wait short; the explicit `close()`
55        // path does the full graceful wait), then SIGKILL as a fallback.
56        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
64/// Terminate a self-managed Firefox child and reap it.
65///
66/// SIGTERM first so Firefox flushes storage (capped wait, `Drop` is sync and
67/// cannot wait long; the explicit `close()` path does the full graceful
68/// wait), then SIGKILL as a fallback. The final `wait()` reaps the child:
69/// without it a SIGKILLed child lingers as a zombie, because `Child`'s own
70/// `Drop` does not wait.
71fn 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/// Ask a self-managed Firefox child to exit GRACEFULLY (SIGTERM) so it flushes
85/// localStorage / IndexedDB / cookies to its profile before exit.
86///
87/// A bare `Child::kill()` (SIGKILL) interrupts Firefox before its LSNG storage
88/// flush, so a persistent `profile_dir` silently loses localStorage/IndexedDB and
89/// recent cookie writes across a restart (confirmed live: localStorage read back
90/// `null` after a restart that reused the same profile dir). SIGTERM triggers
91/// Firefox's normal shutdown, which flushes. `nix::sys::signal::kill` is a safe
92/// wrapper (this crate forbids `unsafe`). On non-unix there is no SIGTERM, so the
93/// caller's SIGKILL fallback is the only option.
94#[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
105/// Poll a child for exit up to `ticks` × 100 ms, reaping it when it exits. Returns
106/// `true` if it exited within the window. Used so a clean Firefox shutdown can
107/// finish flushing storage to disk before we escalate to a signal.
108async 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
119/// Opaque handle to a browsing context (tab or iframe).
120pub type FrameId = rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
121
122/// A browsing context (frame) with the metadata the agent needs to target it:
123/// the opaque `id` to pass back on a frame-scoped command, plus its `url` and
124/// `name` for disambiguation. Returned by [`Page::list_frames`].
125#[derive(Debug, Clone, PartialEq, serde::Serialize)]
126pub struct FrameInfo {
127    /// Opaque browsing-context id (pass this back as the `frame` target).
128    pub id: String,
129    /// The frame's current document URL (`about:blank` for a fresh frame).
130    pub url: String,
131    /// The frame's `window.name`, empty when unset.
132    pub name: String,
133    /// `true` for the top-level document, `false` for an iframe.
134    pub is_main: bool,
135}
136
137/// One node of the live browsing-context tree as reported by WebDriver
138/// BiDi `browsingContext.getTree`.
139///
140/// Unlike [`Page::frames`], a flat id list with no structure, this
141/// preserves true parent linkage and the committed URL of every frame,
142/// including cross-origin iframes whose URL parent-page JS could never
143/// read (it would throw `SecurityError`). It is the structural source of
144/// truth for [`crate::frame_graph::FrameGraph`].
145#[derive(Debug, Clone, PartialEq)]
146pub struct FrameTreeNode {
147    /// Browsing-context id, usable directly as a `frame` target for
148    /// [`Page::eval_in_frame`] / [`Page::click_in_frame`].
149    pub id: FrameId,
150    /// Committed document URL as the browser process sees it.
151    pub url: String,
152    /// Parent browsing-context id; `None` for a top-level (tab) context.
153    pub parent: Option<FrameId>,
154    /// Depth within the tree: a top-level context is `0`, its direct
155    /// iframes `1`, and so on.
156    pub depth: usize,
157}
158
159/// A parsed frame target, the pure classification of a `frame=` spec, factored
160/// out of [`Page::resolve_frame`] so the parsing rules are unit-testable without
161/// a live browser.
162#[derive(Debug, Clone, PartialEq)]
163enum FrameSpec {
164    /// The top-level document (`""`, `main`, `top`).
165    Main,
166    /// Strictly a 0-based index into the frame list (`index:<n>`).
167    Index(usize),
168    /// A bare all-digit spec: Firefox BiDi context ids are ALSO all-digits
169    /// (e.g. `10737418241`), so this is ambiguous, resolve as an exact id
170    /// FIRST, then fall back to the index. `0` carries the parsed index.
171    IdOrIndex(String, usize),
172    /// Exact browsing-context id, with a URL-substring fallback.
173    Id(String),
174    /// First frame whose URL contains this substring (`url:<substr>`).
175    UrlContains(String),
176    /// First frame whose `window.name` equals this (`name:<name>`).
177    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        // A bare integer is ambiguous: a small one is probably a list index, but
198        // a Firefox BiDi context id is also a (large) all-digit string. Try the
199        // exact id first, then the index, so echoing a numeric list_frames id
200        // back works, and `2` still means "the third frame".
201        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/// Result of evaluating JavaScript in the page.
209#[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    /// Attempt to deserialize the evaluation result into `T`.
220    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    /// Raw BiDi remote value.
226    pub fn remote_value(&self) -> &RemoteValue {
227        &self.inner
228    }
229}
230
231/// Convert a raw BiDi wire-format `serde_json::Value` into a plain JSON value.
232fn bidi_wire_value_to_json(v: &serde_json::Value) -> serde_json::Value {
233    match v.get("type").and_then(|t| t.as_str()) {
234        Some("string") => v
235            .get("value")
236            .and_then(|v| v.as_str())
237            .map(|s| serde_json::Value::String(s.to_string()))
238            .unwrap_or(serde_json::Value::Null),
239        Some("number") => v.get("value").cloned().unwrap_or(serde_json::Value::Null),
240        Some("boolean") => v
241            .get("value")
242            .and_then(|v| v.as_bool())
243            .map(serde_json::Value::Bool)
244            .unwrap_or(serde_json::Value::Null),
245        Some("null") | Some("undefined") => serde_json::Value::Null,
246        Some("bigint") => v
247            .get("value")
248            .and_then(|v| v.as_str())
249            .map(|s| serde_json::Value::String(s.to_string()))
250            .unwrap_or(serde_json::Value::Null),
251        Some("object") => {
252            let mut map = serde_json::Map::new();
253            if let Some(serde_json::Value::Array(pairs)) = v.get("value") {
254                for pair in pairs {
255                    if let Some(serde_json::Value::Array(items)) = Some(pair) {
256                        if items.len() >= 2 {
257                            if let (Some(k), Some(val)) = (items[0].as_str(), items.get(1)) {
258                                map.insert(k.to_string(), bidi_wire_value_to_json(val));
259                            }
260                        }
261                    }
262                }
263            }
264            serde_json::Value::Object(map)
265        }
266        Some("array") => {
267            let arr: Vec<serde_json::Value> = v
268                .get("value")
269                .and_then(|v| v.as_array())
270                .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
271                .unwrap_or_default();
272            serde_json::Value::Array(arr)
273        }
274        _ => v.clone(),
275    }
276}
277
278/// Convert a BiDi `RemoteValue` into a plain `serde_json::Value`.
279fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
280    match rv {
281        RemoteValue::PrimitiveProtocolValue(p) => match p {
282            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
283                serde_json::Value::String(s.value.clone())
284            }
285            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
286                match &n.value {
287                    serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
288                    _ => serde_json::Value::Null,
289                }
290            }
291            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
292                serde_json::Value::Bool(b.value)
293            }
294            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
295                serde_json::Value::Null
296            }
297            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(
298                _,
299            ) => serde_json::Value::Null,
300            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
301                serde_json::Value::String(b.value.clone())
302            }
303        },
304        RemoteValue::ArrayRemoteValue(a) => {
305            let arr: Vec<serde_json::Value> = a
306                .value
307                .as_ref()
308                .map(|v| v.inner().iter().map(remote_value_to_json).collect())
309                .unwrap_or_default();
310            serde_json::Value::Array(arr)
311        }
312        RemoteValue::ObjectRemoteValue(o) => {
313            let mut map = serde_json::Map::new();
314            if let Some(ref mapping) = o.value {
315                for pair in mapping.inner() {
316                    if pair.len() >= 2 {
317                        if let (Some(serde_json::Value::String(k)), Some(v)) =
318                            (pair.first(), pair.get(1))
319                        {
320                            map.insert(k.clone(), bidi_wire_value_to_json(v));
321                        }
322                    }
323                }
324            }
325            serde_json::Value::Object(map)
326        }
327        _ => serde_json::Value::Null,
328    }
329}
330
331/// DOM element handle.
332pub struct Element {
333    pub(crate) node: tokio::sync::Mutex<FoxNode>,
334    pub(crate) selector: String,
335}
336
337impl Element {
338    /// Click the element using BiDi pointer actions.
339    pub async fn click(&self) -> Result<()> {
340        let mut node = self.node.lock().await;
341        node.mouse_click()
342            .await
343            .map_err(|e| anyhow!("element click failed: {e:?}"))?;
344        Ok(())
345    }
346
347    /// Return the CSS selector used to locate this element.
348    pub fn selector(&self) -> &str {
349        &self.selector
350    }
351
352    /// Type text into this element.
353    pub async fn type_text(&self, text: &str) -> Result<()> {
354        let mut node = self.node.lock().await;
355        node.type_text(text.to_string())
356            .await
357            .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
358        Ok(())
359    }
360
361    /// Alias for [`type_text`].
362    pub async fn type_str(&self, text: &str) -> Result<()> {
363        self.type_text(text).await
364    }
365}
366
367// Internal aliases.
368type FoxBrowser = FirefoxBrowser;
369type FoxNode =
370    rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
371
372/// Direction for realistic scroll simulation.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum ScrollDirection {
375    Up,
376    Down,
377}
378
379impl Page {
380    /// Launch a new Firefox instance and return its first page.
381    pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
382        launch_firefox(config.unwrap_or_default()).await
383    }
384
385    /// Navigate the active browsing context to `url`.
386    pub async fn goto(&self, url: &str) -> Result<()> {
387        let mut browser = self.browser.lock().await;
388        let browser = match &mut *browser {
389            Some(b) => b,
390            None => return Err(anyhow!("browser closed")),
391        };
392        browser
393            .navigate(url)
394            .await
395            .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
396        Ok(())
397    }
398
399    /// Evaluate a JavaScript expression in the active context.
400    ///
401    /// The returned value is NOT promise-awaited: if `expr` evaluates to a
402    /// `Promise`, the opaque promise handle is returned, not its resolved value.
403    /// Use [`Self::evaluate_await`] for expressions that may be async.
404    pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
405        let mut browser = self.browser.lock().await;
406        let browser = match &mut *browser {
407            Some(b) => b,
408            None => return Err(anyhow!("browser closed")),
409        };
410        let result = browser
411            .evaluate_script(expr.into(), false)
412            .await
413            .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
414        Ok(EvaluationResult::new(result.result))
415    }
416
417    /// Evaluate a JavaScript expression, awaiting the result if it is a `Promise`.
418    ///
419    /// This sets the BiDi `awaitPromise` flag, so an expression that returns a
420    /// `Promise` resolves to its fulfilled value before serialization. A
421    /// non-promise expression is returned unchanged, so this is a safe superset
422    /// of [`Self::evaluate`] for any caller that wants the resolved value (e.g.
423    /// surfaces backed by `MediaCapabilities.decodingInfo`, `Worker`/
424    /// `ServiceWorker` message round-trips, or any `async` probe).
425    pub async fn evaluate_await(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
426        let mut browser = self.browser.lock().await;
427        let browser = match &mut *browser {
428            Some(b) => b,
429            None => return Err(anyhow!("browser closed")),
430        };
431        let result = browser
432            .evaluate_script(expr.into(), true)
433            .await
434            .map_err(|e| anyhow!("evaluate_await failed: {e:?}"))?;
435        Ok(EvaluationResult::new(result.result))
436    }
437
438    /// Evaluate in a specific browsing context (frame).
439    pub async fn evaluate_in_context(
440        &self,
441        expr: impl Into<String>,
442        context: &FrameId,
443    ) -> Result<EvaluationResult> {
444        let mut browser = self.browser.lock().await;
445        let browser = match &mut *browser {
446            Some(b) => b,
447            None => return Err(anyhow!("browser closed")),
448        };
449        let options = EvaluateScriptOptionsBuilder::default()
450            .target(Target::ContextTarget(ContextTarget::new(context.clone())))
451            .build();
452        let result = browser
453            .evaluate_script_with_options(expr.into(), false, options)
454            .await
455            .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
456        Ok(EvaluationResult::new(result.result))
457    }
458
459    /// Find the first element matching `selector`.
460    pub async fn find_element(&self, selector: &str) -> Result<Element> {
461        let mut browser = self.browser.lock().await;
462        let browser = match &mut *browser {
463            Some(b) => b,
464            None => return Err(anyhow!("browser closed")),
465        };
466        let locator =
467            Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
468        match browser.find_node(locator).await {
469            Ok(Some(node)) => Ok(Element {
470                node: tokio::sync::Mutex::new(node),
471                selector: selector.to_string(),
472            }),
473            Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
474            Err(e) => Err(anyhow!("find_element failed: {e:?}")),
475        }
476    }
477
478    /// Find all elements matching `selector`.
479    pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
480        let mut browser = self.browser.lock().await;
481        let browser = match &mut *browser {
482            Some(b) => b,
483            None => return Err(anyhow!("browser closed")),
484        };
485        let locator =
486            Locator::CssLocator(CssLocator::new(CssLocatorType::Css, selector.to_string()));
487        let nodes = browser
488            .find_nodes(locator)
489            .await
490            .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
491        Ok(nodes
492            .into_iter()
493            .map(|n| Element {
494                node: tokio::sync::Mutex::new(n),
495                selector: selector.to_string(),
496            })
497            .collect())
498    }
499
500    /// Set the file(s) on a `<input type=file>` element via BiDi `input.setFiles`.
501    ///
502    /// This is the trusted file-upload primitive: it attaches real local files to
503    /// the input the same way a human's file picker does (no synthetic events), so
504    /// the entire file-upload attack surface, path-traversal filenames,
505    /// content-type bypass, SVG/XML XXE, RCE-via-upload, SSRF (becomes testable).
506    /// `selector` must resolve to the file input; `files` are absolute local paths.
507    pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
508        if files.is_empty() {
509            return Err(anyhow!("set_files: no files provided"));
510        }
511        // Resolve the input element to its shared node reference + owning context
512        // (releases the browser lock before we re-acquire it for the command). Using
513        // the node's own context means a file input inside an iframe works too.
514        let element = self.find_element(selector).await?;
515        let (shared_id, context) = {
516            let node = element.node.lock().await;
517            let id = node.get_shared_id().cloned().ok_or_else(|| {
518                anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
519            })?;
520            (id, node.get_context_id().clone())
521        };
522        let element_ref: SharedReference = SharedReference::builder()
523            .shared_id(shared_id)
524            .build()
525            .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
526        let command = SetFiles::builder()
527            .context(context)
528            .element(element_ref)
529            .files(files)
530            .build()
531            .map_err(|e| anyhow!("set_files: build command: {e}"))?;
532        let mut browser = self.browser.lock().await;
533        let browser = match &mut *browser {
534            Some(b) => b,
535            None => return Err(anyhow!("browser closed")),
536        };
537        let response = browser
538            .driver_mut()
539            .send_command(command)
540            .await
541            .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
542        let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
543            .result
544            .try_into()
545            .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
546        Ok(())
547    }
548
549    /// Capture a viewport screenshot and return raw PNG bytes.
550    pub async fn screenshot(&self) -> Result<Vec<u8>> {
551        let mut browser = self.browser.lock().await;
552        let browser = match &mut *browser {
553            Some(b) => b,
554            None => return Err(anyhow!("browser closed")),
555        };
556        let b64 = browser
557            .screenshot()
558            .await
559            .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
560        base64::engine::general_purpose::STANDARD
561            .decode(b64)
562            .map_err(|e| anyhow!("base64 decode failed: {e}"))
563    }
564
565    /// Reload the active context.
566    pub async fn reload(&self) -> Result<()> {
567        let mut browser = self.browser.lock().await;
568        let browser = match &mut *browser {
569            Some(b) => b,
570            None => return Err(anyhow!("browser closed")),
571        };
572        browser
573            .evaluate_script("location.reload()".to_string(), false)
574            .await
575            .map_err(|e| anyhow!("reload failed: {e:?}"))?;
576        Ok(())
577    }
578
579    /// Current URL of the active context.
580    pub async fn url(&self) -> Result<String> {
581        let eval = self.evaluate("document.URL").await?;
582        eval.into_value::<String>()
583            .map_err(|e| anyhow!("url deserialize failed: {e}"))
584    }
585
586    /// Document title of the active context.
587    pub async fn title(&self) -> Result<String> {
588        let eval = self.evaluate("document.title").await?;
589        eval.into_value::<String>()
590            .map_err(|e| anyhow!("title deserialize failed: {e}"))
591    }
592
593    /// List all browsing-context IDs (main page + every iframe).
594    pub async fn frames(&self) -> Result<Vec<FrameId>> {
595        let browser = self.browser.lock().await;
596        let browser = match &*browser {
597            Some(b) => b,
598            None => return Err(anyhow!("browser closed")),
599        };
600        let contexts = browser
601            .driver()
602            .browsing_contexts
603            .lock()
604            .unwrap_or_else(|e| e.into_inner())
605            .iter()
606            .map(|c| c.id().clone())
607            .collect();
608        Ok(contexts)
609    }
610
611    /// Return the active (main) browsing context.
612    pub async fn mainframe(&self) -> Result<Option<FrameId>> {
613        let browser = self.browser.lock().await;
614        let browser = match &*browser {
615            Some(b) => b,
616            None => return Err(anyhow!("browser closed")),
617        };
618        match browser.driver().get_active_context_id() {
619            Ok(ctx) => Ok(Some(ctx)),
620            Err(e) => {
621                tracing::debug!("get_active_context_id failed: {e:?}");
622                Ok(None)
623            }
624        }
625    }
626
627    /// Verify a browsing context still exists.
628    pub async fn frame_execution_context(&self, frame_id: FrameId) -> Result<Option<FrameId>> {
629        let browser = self.browser.lock().await;
630        let browser = match &*browser {
631            Some(b) => b,
632            None => return Err(anyhow!("browser closed")),
633        };
634        let exists = browser
635            .driver()
636            .browsing_contexts
637            .lock()
638            .unwrap_or_else(|e| e.into_inner())
639            .iter()
640            .any(|c| c.id() == &frame_id);
641        Ok(if exists { Some(frame_id) } else { None })
642    }
643
644    /// List every browsing context (main document + all iframes) with the
645    /// metadata an agent needs to target one: opaque `id`, current `url`,
646    /// `window.name`, and whether it is the main frame.
647    ///
648    /// This is the discovery primitive for cross-origin iframe interaction
649    /// embedded apps, OAuth/payment widgets, postMessage surfaces, captcha tiles.
650    /// Pass a returned `id` back as the `frame` target to
651    /// [`Page::eval_in_frame`] / [`Page::click_in_frame`] /
652    /// [`Page::type_in_frame`].
653    pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
654        let frame_ids = self.frames().await?;
655        let main = self.mainframe().await?;
656        let mut out = Vec::with_capacity(frame_ids.len());
657        for fid in frame_ids {
658            // Read url + name from inside the frame's own context so a
659            // cross-origin iframe (where parent JS would throw SecurityError)
660            // still reports correctly. A frame that vanished mid-walk is skipped.
661            let (url, name) = match self
662                .evaluate_in_context("({u: document.URL, n: (window.name || \"\")})", &fid)
663                .await
664            {
665                Ok(eval) => match eval.into_value::<serde_json::Value>() {
666                    Ok(v) => (
667                        v["u"].as_str().unwrap_or("").to_string(),
668                        v["n"].as_str().unwrap_or("").to_string(),
669                    ),
670                    Err(_) => (String::new(), String::new()),
671                },
672                Err(e) => {
673                    tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
674                    (String::new(), String::new())
675                }
676            };
677            out.push(FrameInfo {
678                is_main: Some(&fid) == main.as_ref(),
679                id: fid.inner().to_string(),
680                url,
681                name,
682            });
683        }
684        Ok(out)
685    }
686
687    /// Walk the live frame tree via WebDriver BiDi `browsingContext.getTree`.
688    ///
689    /// Returns every browsing context, the main document plus every nested
690    /// iframe at any origin, in **pre-order** (a parent always precedes its
691    /// children), each carrying true parent linkage, committed URL, and depth.
692    ///
693    /// This is the structural primitive [`Page::frames`] cannot provide: that
694    /// method flattens the tree to a bare id list, discarding which iframe is
695    /// nested inside which. Cross-origin captcha challenges (reCAPTCHA's
696    /// `bframe` inside its `anchor`, an hCaptcha challenge inside its checkbox)
697    /// are exactly the topologies that flattening destroys, so the solver must
698    /// re-derive structure every pass. `getTree` recovers it in one round-trip,
699    /// and, unlike reading `document.URL` from inside each frame, reports the
700    /// URL of a cross-origin frame the browser knows but in-frame JS may not yet
701    /// expose.
702    pub async fn frame_tree(&self) -> Result<Vec<FrameTreeNode>> {
703        use rustenium_bidi_definitions::browsing_context::commands::GetTree;
704        use rustenium_bidi_definitions::browsing_context::results::GetTreeResult;
705        use rustenium_bidi_definitions::browsing_context::types::{Info, InfoList};
706
707        let command = GetTree::builder().build();
708        let mut browser = self.browser.lock().await;
709        let browser = match &mut *browser {
710            Some(b) => b,
711            None => return Err(anyhow!("browser closed")),
712        };
713        let response = browser
714            .driver_mut()
715            .send_command(command)
716            .await
717            .map_err(|e| anyhow!("browsingContext.getTree BiDi command failed: {e:?}"))?;
718        let result: GetTreeResult = response
719            .result
720            .try_into()
721            .map_err(|e| anyhow!("browsingContext.getTree result parse failed: {e}"))?;
722
723        // The BiDi nesting IS the parentage: walk it depth-first, emitting each
724        // node before its children so consumers can build a parent→index map in
725        // a single pass.
726        fn walk(
727            list: &InfoList,
728            parent: Option<&FrameId>,
729            depth: usize,
730            out: &mut Vec<FrameTreeNode>,
731        ) {
732            for info in list.inner() {
733                let info: &Info = info;
734                out.push(FrameTreeNode {
735                    id: info.context.clone(),
736                    url: info.url.clone(),
737                    parent: parent.cloned(),
738                    depth,
739                });
740                if let Some(children) = &info.children {
741                    walk(children, Some(&info.context), depth + 1, out);
742                }
743            }
744        }
745        let mut out = Vec::new();
746        walk(&result.contexts, None, 0, &mut out);
747        Ok(out)
748    }
749
750    /// Resolve a frame target spec to a concrete [`FrameId`], polling briefly so
751    /// an iframe that attaches asynchronously (captcha widgets, lazy embeds,
752    /// post-navigation frames) is found rather than racing to a "no such frame".
753    ///
754    /// Accepts every shape an agent naturally has on hand, so it never has to
755    /// call `list_frames` first:
756    /// - exact browsing-context id (from [`Page::list_frames`])
757    /// - `index:<n>` or a bare 0-based integer into the frame list
758    /// - `url:<substr>`: first frame whose URL contains the substring
759    /// - `name:<name>`: first frame whose `window.name` equals it
760    /// - any other string, tried as an exact id, then as a URL substring
761    /// - empty / `main` / `top` → the main document
762    pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
763        self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
764            .await
765    }
766
767    /// [`Page::resolve_frame`] with an explicit overall timeout for the attach
768    /// poll. `timeout` of zero means a single attempt.
769    pub async fn resolve_frame_within(
770        &self,
771        spec: &str,
772        timeout: std::time::Duration,
773    ) -> Result<FrameId> {
774        let parsed = FrameSpec::parse(spec);
775        let deadline = std::time::Instant::now() + timeout;
776        loop {
777            if let Some(fid) = self.try_resolve_frame(&parsed).await? {
778                return Ok(fid);
779            }
780            if std::time::Instant::now() >= deadline {
781                return Err(anyhow!(
782                    "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
783                ));
784            }
785            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
786        }
787    }
788
789    /// One non-retrying resolution attempt. `Ok(None)` means "not found yet"
790    /// (caller may retry); `Err` is a hard failure (browser closed, bad index).
791    async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
792        if matches!(parsed, FrameSpec::Main) {
793            return self.mainframe().await;
794        }
795        let frames = self.frames().await?;
796        match parsed {
797            FrameSpec::Main => unreachable!(),
798            FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
799            FrameSpec::IdOrIndex(id, idx) => {
800                // Exact (numeric) id first; then the list index.
801                if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
802                    return Ok(Some(fid.clone()));
803                }
804                Ok(frames.get(*idx).cloned())
805            }
806            FrameSpec::Id(id) => {
807                if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
808                    return Ok(Some(fid.clone()));
809                }
810                // Fall back to a URL-substring match so a bare iframe URL works
811                // without the explicit `url:` prefix.
812                self.frame_by_url_contains(id).await
813            }
814            FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
815            FrameSpec::NameEquals(name) => {
816                for info in self.list_frames().await? {
817                    if &info.name == name {
818                        return Ok(Some(FrameId::new(info.id)));
819                    }
820                }
821                Ok(None)
822            }
823        }
824    }
825
826    /// First frame whose current URL contains `sub`. Main frame included so
827    /// `url:` can also target the top document.
828    async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
829        for info in self.list_frames().await? {
830            if info.url.contains(sub) {
831                return Ok(Some(FrameId::new(info.id)));
832            }
833        }
834        Ok(None)
835    }
836
837    /// Evaluate `expr` inside the frame named by `spec` (id, index, or
838    /// main/top). Full read/write JS runs in that frame's own context, so the
839    /// agent can read or mutate a cross-origin iframe's DOM, drive postMessage,
840    /// or land a DOM-XSS PoC inside an embedded document.
841    pub async fn eval_in_frame(
842        &self,
843        spec: &str,
844        expr: impl Into<String>,
845    ) -> Result<EvaluationResult> {
846        let fid = self.resolve_frame(spec).await?;
847        self.evaluate_in_context(expr, &fid).await
848    }
849
850    /// TRUSTED click on `selector` inside the frame named by `spec`.
851    ///
852    /// Resolves the element's centre in the frame's own viewport, then dispatches
853    /// a real BiDi pointer event in that context via [`Page::click_at_in`], so
854    /// `event.isTrusted` is `true` even for a cross-origin iframe. Returns an
855    /// error if the selector matches nothing visible in the frame.
856    pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
857        let fid = self.resolve_frame(spec).await?;
858        let escaped = crate::frame::escape_js_string(selector);
859        let js = format!(
860            r#"(function() {{
861                const el = document.querySelector('{escaped}');
862                if (!el) return null;
863                const r = el.getBoundingClientRect();
864                if (r.width <= 0 || r.height <= 0) return null;
865                return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
866            }})()"#
867        );
868        // Poll for the element's visible rect, it may render a beat after the
869        // frame attaches (lazy widgets, post-XHR content).
870        let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
871        loop {
872            if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
873                if let Ok(val) = eval.into_value::<serde_json::Value>() {
874                    if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
875                        return self.click_at_in(&fid, x, y).await;
876                    }
877                }
878            }
879            if std::time::Instant::now() >= deadline {
880                return Err(anyhow!(
881                    "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
882                ));
883            }
884            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
885        }
886    }
887
888    /// Focus `selector` inside the frame named by `spec` and type `text` into it
889    /// with human-like timing. The keystrokes are dispatched in the frame's own
890    /// context so they land in the cross-origin iframe's focused element.
891    pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
892        let fid = self.resolve_frame(spec).await?;
893        let escaped = crate::frame::escape_js_string(selector);
894        let focus_js = format!(
895            r#"(function() {{
896                const el = document.querySelector('{escaped}');
897                if (!el) return false;
898                el.focus();
899                return document.activeElement === el;
900            }})()"#
901        );
902        // Poll for the field to exist + accept focus before typing.
903        let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
904        loop {
905            let focused = self
906                .evaluate_in_context(&focus_js, &fid)
907                .await
908                .ok()
909                .and_then(|e| e.into_value::<bool>().ok())
910                .unwrap_or(false);
911            if focused {
912                break;
913            }
914            if std::time::Instant::now() >= deadline {
915                return Err(anyhow!(
916                    "type_in_frame: could not focus '{selector}' in frame '{spec}'"
917                ));
918            }
919            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
920        }
921        let browser = self.browser.lock().await;
922        let browser = match &*browser {
923            Some(b) => b,
924            None => return Err(anyhow!("browser closed")),
925        };
926        browser
927            .keyboard()
928            .type_text(text, &fid, None)
929            .await
930            .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
931        Ok(())
932    }
933
934    // ------------------------------------------------------------------
935    // Dialogs (alert / confirm / prompt / beforeunload) + downloads
936    // ------------------------------------------------------------------
937
938    /// Start capturing JS dialogs and page-initiated downloads via BiDi
939    /// `browsingContext.*` events. Returns a [`crate::dialog::DialogLog`] handle
940    /// (cheap to clone) that accumulates events for the life of the page.
941    ///
942    /// This is how the agent confirms alert-based XSS (the `alert()` message is
943    /// recorded even when the prompt auto-handles, so there is no hang), reads
944    /// `confirm`/`prompt` text, and inspects downloads. Pair with
945    /// [`Page::handle_user_prompt`] to answer a prompt left open by the `ignore`
946    /// handler. Mirrors [`Page::start_network_log`].
947    pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
948        let mut browser = self.browser.lock().await;
949        let browser = match &mut *browser {
950            Some(b) => b,
951            None => return Err(anyhow!("browser closed")),
952        };
953        let log = crate::dialog::DialogLog::new();
954        let handler = crate::dialog::make_dialog_handler(log.clone());
955        let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
956        browser
957            .subscribe_events(events, handler)
958            .await
959            .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
960        Ok(log)
961    }
962
963    // ------------------------------------------------------------------
964    // Sensor grid (the "Omniscient Page")
965    // ------------------------------------------------------------------
966
967    /// Install the passive instrumentation grid (see [`crate::sensors`]) so the
968    /// page reports DOM-XSS sink writes, console output, uncaught errors, CSP
969    /// violations, and inbound postMessage on its own.
970    ///
971    /// Injected twice: as a preload (runs in the MAIN world before page scripts
972    /// on every future navigation) AND evaluated once on the current document so
973    /// a page already loaded at launch is covered. The script is idempotent, so
974    /// the double-install is safe. Read what it captured with
975    /// [`Page::read_signals`]. Mirrors [`Page::start_network_log`].
976    pub async fn start_sensors(&self) -> Result<String> {
977        let id = self
978            .add_preload_script(crate::sensors::SENSOR_SCRIPT)
979            .await?;
980        // Best-effort cover the already-loaded document; a fresh tab on
981        // about:blank may not accept eval yet, which is fine, the preload will
982        // fire on the first real navigation.
983        let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
984        Ok(id)
985    }
986
987    /// Read the captured signal buffer. With `clear` true the buffer is emptied
988    /// after the snapshot so the next read returns only NEW signals (deltas)
989    /// the basis for "what did my last action trigger?" telemetry.
990    pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
991        let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
992        eval.into_value::<serde_json::Value>()
993            .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
994    }
995
996    /// Answer an open JS user prompt in `context` (or the active frame when
997    /// `None`): `accept` true clicks OK / accepts `beforeunload`; `user_text`
998    /// fills a `prompt()` box before accepting. Only effective when the page was
999    /// launched with the `ignore` prompt handler (otherwise Firefox auto-handles
1000    /// the prompt before this runs). Mirrors the [`Page::set_files`] command path.
1001    pub async fn handle_user_prompt(
1002        &self,
1003        context: Option<&FrameId>,
1004        accept: bool,
1005        user_text: Option<&str>,
1006    ) -> Result<()> {
1007        let ctx = match context {
1008            Some(c) => c.clone(),
1009            None => self
1010                .mainframe()
1011                .await?
1012                .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
1013        };
1014        let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
1015        if let Some(text) = user_text {
1016            builder = builder.user_text(text.to_string());
1017        }
1018        let command = builder
1019            .build()
1020            .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
1021        let mut browser = self.browser.lock().await;
1022        let browser = match &mut *browser {
1023            Some(b) => b,
1024            None => return Err(anyhow!("browser closed")),
1025        };
1026        let response = browser
1027            .driver_mut()
1028            .send_command(command)
1029            .await
1030            .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
1031        let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
1032            response
1033                .result
1034                .try_into()
1035                .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
1036        Ok(())
1037    }
1038
1039    // ------------------------------------------------------------------
1040    // Input
1041    // ------------------------------------------------------------------
1042
1043    /// Move the mouse from `(x0, y0)` to `(x1, y1)` using human-like curves.
1044    pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
1045        let browser = self.browser.lock().await;
1046        let browser = match &*browser {
1047            Some(b) => b,
1048            None => return Err(anyhow!("browser closed")),
1049        };
1050        let context = browser
1051            .driver()
1052            .get_active_context_id()
1053            .map_err(|e| anyhow!("{e:?}"))?;
1054        let hm = browser.human_mouse();
1055        hm.set_last_position(Point { x: x0, y: y0 });
1056        hm.move_to(
1057            Point { x: x1, y: y1 },
1058            &context,
1059            MouseMoveOptions::default(),
1060        )
1061        .await
1062        .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
1063        Ok(())
1064    }
1065
1066    /// Mouse-down at `(x, y)` in the active context.
1067    pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
1068        let browser = self.browser.lock().await;
1069        let browser = match &*browser {
1070            Some(b) => b,
1071            None => return Err(anyhow!("browser closed")),
1072        };
1073        let context = browser
1074            .driver()
1075            .get_active_context_id()
1076            .map_err(|e| anyhow!("{e:?}"))?;
1077        let hm = browser.human_mouse();
1078        hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
1079            .await
1080            .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
1081        hm.down(
1082            &context,
1083            MouseOptions {
1084                button: Some(MouseButton::Left),
1085            },
1086        )
1087        .await
1088        .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
1089        Ok(())
1090    }
1091
1092    /// Mouse-up at `(x, y)` in the active context.
1093    pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
1094        let browser = self.browser.lock().await;
1095        let browser = match &*browser {
1096            Some(b) => b,
1097            None => return Err(anyhow!("browser closed")),
1098        };
1099        let context = browser
1100            .driver()
1101            .get_active_context_id()
1102            .map_err(|e| anyhow!("{e:?}"))?;
1103        let hm = browser.human_mouse();
1104        hm.up(
1105            &context,
1106            MouseOptions {
1107                button: Some(MouseButton::Left),
1108            },
1109        )
1110        .await
1111        .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
1112        Ok(())
1113    }
1114
1115    /// Click at `(x, y)` in the active (top-level) context with realistic
1116    /// press/release timing.
1117    ///
1118    /// NOTE: for a target inside a cross-origin iframe (the production captcha
1119    /// case: Turnstile/hCaptcha/reCAPTCHA all render their checkbox in an
1120    /// OOPIF), prefer [`Page::click_at_in`] with the iframe's context. A
1121    /// pointer action dispatched in the *top* context does not reliably route
1122    /// across a Fission process boundary, which is why a top-context viewport
1123    /// click on a captcha checkbox silently fails to deliver.
1124    pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
1125        let context = self
1126            .mainframe()
1127            .await?
1128            .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
1129        self.click_at_in(&context, x, y).await
1130    }
1131
1132    /// Click at `(x, y)` within a SPECIFIC browsing context.
1133    ///
1134    /// This is the cross-origin-correct click path: BiDi
1135    /// `input.performActions` is dispatched in `context`, so the *trusted*
1136    /// pointer event is delivered into that frame's content process. For a
1137    /// cross-origin iframe checkbox, pass the iframe's [`FrameId`] (from
1138    /// [`Page::frames`]) with coordinates in that frame's own viewport space
1139    /// (origin at the iframe's top-left). Because the event is real BiDi input
1140    /// (not a synthetic JS `MouseEvent`), `event.isTrusted` is `true`: the
1141    /// property every modern captcha gates its checkbox on.
1142    pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
1143        let browser = self.browser.lock().await;
1144        let browser = match &*browser {
1145            Some(b) => b,
1146            None => return Err(anyhow!("browser closed")),
1147        };
1148        let hm = browser.human_mouse();
1149        // Seed the cursor origin INSIDE the target context's viewport. The
1150        // shared HumanMouse remembers its last position across calls; that
1151        // position is in whatever viewport the previous action used (often the
1152        // top frame, which is larger than a captcha iframe). Moving from a
1153        // stale top-frame coordinate into a small iframe viewport makes Firefox
1154        // BiDi reject the action with MoveTargetOutOfBounds. Anchoring at the
1155        // target keeps every dispatched coordinate within the iframe's bounds.
1156        hm.set_last_position(Point { x, y });
1157        let options = MouseClickOptions {
1158            button: Some(MouseButton::Left),
1159            count: Some(1),
1160            delay: Some(80),
1161            origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1162        };
1163        hm.click(Some(Point { x, y }), context, options)
1164            .await
1165            .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
1166        Ok(())
1167    }
1168
1169    /// Move the pointer to an absolute viewport coordinate as a single
1170    /// TRUSTED BiDi `input.performActions` PointerMove (no synthetic JS
1171    /// `MouseEvent`).
1172    ///
1173    /// This is the trusted primitive that human-trajectory generators must
1174    /// dispatch each interpolated point through. A `document.dispatchEvent(new
1175    /// MouseEvent('mousemove', …))` produces `isTrusted === false`, which every
1176    /// modern anti-bot scorer flags on sight, so a beautifully shaped but
1177    /// JS-dispatched path is worse than useless. Routing each point through
1178    /// here makes the whole trajectory trusted and lets it cross into
1179    /// cross-origin frames by viewport hit-test.
1180    pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
1181        let browser = self.browser.lock().await;
1182        let browser = match &*browser {
1183            Some(b) => b,
1184            None => return Err(anyhow!("browser closed")),
1185        };
1186        let context = browser
1187            .driver()
1188            .get_active_context_id()
1189            .map_err(|e| anyhow!("{e:?}"))?;
1190        browser
1191            .mouse()
1192            .move_to(
1193                Point { x, y },
1194                &context,
1195                MouseMoveOptions {
1196                    steps: Some(0),
1197                    origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
1198                },
1199            )
1200            .await
1201            .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
1202        Ok(())
1203    }
1204
1205    /// Scroll the wheel at the current mouse position.
1206    pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
1207        let browser = self.browser.lock().await;
1208        let browser = match &*browser {
1209            Some(b) => b,
1210            None => return Err(anyhow!("browser closed")),
1211        };
1212        let context = browser
1213            .driver()
1214            .get_active_context_id()
1215            .map_err(|e| anyhow!("{e:?}"))?;
1216        browser
1217            .mouse()
1218            .wheel(
1219                &context,
1220                MouseWheelOptions {
1221                    delta_x: Some(dx),
1222                    delta_y: Some(dy),
1223                },
1224            )
1225            .await
1226            .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1227        Ok(())
1228    }
1229
1230    /// Human-like scroll (smooth easing with noise).
1231    pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1232        let browser = self.browser.lock().await;
1233        let browser = match &*browser {
1234            Some(b) => b,
1235            None => return Err(anyhow!("browser closed")),
1236        };
1237        let context = browser
1238            .driver()
1239            .get_active_context_id()
1240            .map_err(|e| anyhow!("{e:?}"))?;
1241        let y_distance = match direction {
1242            ScrollDirection::Down => amount as i32,
1243            ScrollDirection::Up => -(amount as i32),
1244        };
1245        browser
1246            .human_mouse()
1247            .scroll(y_distance, 0, &context)
1248            .await
1249            .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1250        Ok(())
1251    }
1252
1253    /// Type `text` into the active context with human-like delays.
1254    pub async fn type_text(&self, text: &str) -> Result<()> {
1255        let browser = self.browser.lock().await;
1256        let browser = match &*browser {
1257            Some(b) => b,
1258            None => return Err(anyhow!("browser closed")),
1259        };
1260        let context = browser
1261            .driver()
1262            .get_active_context_id()
1263            .map_err(|e| anyhow!("{e:?}"))?;
1264        browser
1265            .keyboard()
1266            .type_text(text, &context, None)
1267            .await
1268            .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1269        Ok(())
1270    }
1271
1272    /// Press a key down in the active context.
1273    pub async fn key_down(&self, key: &str) -> Result<()> {
1274        let browser = self.browser.lock().await;
1275        let browser = match &*browser {
1276            Some(b) => b,
1277            None => return Err(anyhow!("browser closed")),
1278        };
1279        let context = browser
1280            .driver()
1281            .get_active_context_id()
1282            .map_err(|e| anyhow!("{e:?}"))?;
1283        browser
1284            .keyboard()
1285            .down(key, &context)
1286            .await
1287            .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1288        Ok(())
1289    }
1290
1291    /// Release a key in the active context.
1292    pub async fn key_up(&self, key: &str) -> Result<()> {
1293        let browser = self.browser.lock().await;
1294        let browser = match &*browser {
1295            Some(b) => b,
1296            None => return Err(anyhow!("browser closed")),
1297        };
1298        let context = browser
1299            .driver()
1300            .get_active_context_id()
1301            .map_err(|e| anyhow!("{e:?}"))?;
1302        browser
1303            .keyboard()
1304            .up(key, &context)
1305            .await
1306            .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1307        Ok(())
1308    }
1309
1310    /// Press and release a key in the active context.
1311    pub async fn key_press(&self, key: &str) -> Result<()> {
1312        let browser = self.browser.lock().await;
1313        let browser = match &*browser {
1314            Some(b) => b,
1315            None => return Err(anyhow!("browser closed")),
1316        };
1317        let context = browser
1318            .driver()
1319            .get_active_context_id()
1320            .map_err(|e| anyhow!("{e:?}"))?;
1321        browser
1322            .keyboard()
1323            .press(key, &context, None)
1324            .await
1325            .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1326        Ok(())
1327    }
1328
1329    // ------------------------------------------------------------------
1330    // Stealth / scripting
1331    // ------------------------------------------------------------------
1332
1333    /// Inject a preload script that runs in the page's main world before any
1334    /// page script, on every new document.
1335    ///
1336    /// `source` is a SCRIPT BODY (statements), matching CDP's
1337    /// `Page.addScriptToEvaluateOnNewDocument` semantics. WebDriver BiDi's
1338    /// `script.addPreloadScript` instead takes a `functionDeclaration` that it
1339    /// *invokes* as a function, so a bare body, or a self-invoking IIFE like
1340    /// `(() => {…})()` (which evaluates to `undefined`, not a callable), is
1341    /// silently never run, nullifying the script. We therefore wrap the body in
1342    /// an arrow function here so callers can pass a plain body and have it
1343    /// actually execute. This is the single point that made guise's stealth
1344    /// preloads (all written as IIFE bodies) no-ops.
1345    pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1346        let function_declaration = format!("() => {{\n{source}\n}}");
1347        let mut browser = self.browser.lock().await;
1348        let browser = match &mut *browser {
1349            Some(b) => b,
1350            None => return Err(anyhow!("browser closed")),
1351        };
1352        let id = browser
1353            .add_preload_script(function_declaration)
1354            .await
1355            .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1356        Ok(id)
1357    }
1358
1359    /// Capture all cookies (including HttpOnly) via BiDi `storage.getCookies`.
1360    pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1361        let mut browser = self.browser.lock().await;
1362        let browser = match &mut *browser {
1363            Some(b) => b,
1364            None => return Err(anyhow!("browser closed")),
1365        };
1366        let response = browser
1367            .driver_mut()
1368            .send_command(GetCookies {
1369                method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1370                params: Default::default(),
1371            })
1372            .await
1373            .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1374        let result: rustenium_bidi_definitions::storage::results::GetCookiesResult = response
1375            .result
1376            .try_into()
1377            .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1378        Ok(result
1379            .cookies
1380            .into_iter()
1381            .map(|c| crate::cookies::CapturedCookie {
1382                name: c.name,
1383                value: match c.value {
1384                    BytesValue::StringValue(s) => s.value,
1385                    BytesValue::Base64Value(b) => b.value,
1386                },
1387                domain: c.domain,
1388                path: c.path,
1389                expires: c.expiry.map(|e| e as i64),
1390                secure: c.secure,
1391                http_only: c.http_only,
1392                same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1393            })
1394            .collect())
1395    }
1396
1397    /// Set a cookie via BiDi `storage.setCookie`.
1398    #[allow(clippy::too_many_arguments)] // a cookie's fields are the domain arity
1399    pub async fn set_cookie(
1400        &self,
1401        name: &str,
1402        value: &str,
1403        domain: &str,
1404        path: Option<&str>,
1405        expires: Option<u64>,
1406        secure: Option<bool>,
1407        http_only: Option<bool>,
1408        same_site: Option<SameSite>,
1409    ) -> Result<()> {
1410        let mut browser = self.browser.lock().await;
1411        let browser = match &mut *browser {
1412            Some(b) => b,
1413            None => return Err(anyhow!("browser closed")),
1414        };
1415        let cookie = PartialCookie {
1416            name: name.to_string(),
1417            value: BytesValue::StringValue(StringValue::new(
1418                StringValueType::String,
1419                value.to_string(),
1420            )),
1421            domain: domain.to_string(),
1422            path: path.map(|p| p.to_string()),
1423            http_only,
1424            secure,
1425            same_site,
1426            expiry: expires,
1427            extensible: Default::default(),
1428        };
1429        let response = browser
1430            .driver_mut()
1431            .send_command(SetCookie {
1432                method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1433                params: SetCookieParams::new(cookie),
1434            })
1435            .await
1436            .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1437        let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1438            .result
1439            .try_into()
1440            .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1441        Ok(())
1442    }
1443
1444    /// Return the Firefox profile directory path, if known.
1445    pub fn profile_dir(&self) -> Option<&str> {
1446        self.profile_dir.as_deref()
1447    }
1448
1449    /// Start capturing all network traffic (requests + responses) via BiDi.
1450    ///
1451    /// Returns a [`crate::network::NetworkLog`] handle that can be queried at
1452    /// any time while the browser is alive.  The log is shared (Clone is cheap)
1453    /// and accumulates events until the page is closed.
1454    ///
1455    /// # Example
1456    /// ```ignore
1457    /// let log = page.start_network_log().await?;
1458    /// page.goto("https://example.com").await?;
1459    /// let entries = log.entries().await;
1460    /// let tokens = log.extract_tokens().await;
1461    /// ```
1462    pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1463        let mut browser = self.browser.lock().await;
1464        let browser = match &mut *browser {
1465            Some(b) => b,
1466            None => return Err(anyhow!("browser closed")),
1467        };
1468        let log = crate::network::NetworkLog::new();
1469        let handler = crate::network::make_network_handler(log.clone());
1470        let events: HashSet<&str> = [
1471            "network.beforeRequestSent",
1472            "network.responseCompleted",
1473            "network.fetchError",
1474        ]
1475        .into_iter()
1476        .collect();
1477        browser
1478            .subscribe_events(events, handler)
1479            .await
1480            .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1481        Ok(log)
1482    }
1483
1484    /// Close the browser. For a self-managed (Remote-attach) child this performs a
1485    /// CLEAN quit so the profile's localStorage / IndexedDB / cookies are flushed to
1486    /// disk before exit; capped so a hung engine still tears down.
1487    ///
1488    /// Persistence depends on this being called, a dropped [`Page`] (see [`Drop`])
1489    /// can only best-effort SIGTERM/SIGKILL, which does NOT flush localStorage on
1490    /// this engine, so a reused `profile_dir` would lose recent writes.
1491    pub async fn close(&self) -> Result<()> {
1492        // Take the self-managed child (Remote-attach path) OUT of the std mutex
1493        // first (never hold a std guard across `.await`).
1494        let child_opt = self.child.lock().ok().and_then(|mut g| g.take());
1495
1496        if let Some(mut c) = child_opt {
1497            // PERSISTENCE, why this is a BiDi `browser.close`, not a SIGKILL:
1498            //
1499            // Firefox's LSNG localStorage buffers writes in the content process,
1500            // hands them to the parent Datastore, and the Datastore writes to disk
1501            // only on a HARDCODED 5 s timer (`kFlushTimeoutMs`, no pref) OR when the
1502            // Datastore closes. A SIGKILL, or rustenium's own `fuser -k <port>`
1503            // by-port kill in `FirefoxBrowser::close`: interrupts that, so a reused
1504            // `profile_dir` silently loses recent localStorage/IndexedDB across a
1505            // restart (confirmed live: localStorage read back `null`). SIGTERM does
1506            // NOT help on this engine (it is ignored: the process stays alive).
1507            //
1508            // The BiDi `browser.close` command closes every top-level tab with
1509            // `skipPermitUnload` and then shuts the browser down. Closing a tab tears
1510            // down that origin's content-process localStorage handle, which makes the
1511            // parent `Datastore::Close` → `Connection::Close` cancel the 5 s timer and
1512            // FLUSH IMMEDIATELY; the subsequent shutdown runs `QuotaManager::Shutdown`,
1513            // finalizing every remaining datastore. By the time the process exits,
1514            // storage is on disk. This is the only flush path that survives a restart.
1515            {
1516                let mut guard = self.browser.lock().await;
1517                if let Some(browser) = guard.as_mut() {
1518                    let cmd = BrowserCommand::Close(BrowserCloseCmd {
1519                        method: BrowserCloseMethod::Close,
1520                        params: BrowserCloseParams {},
1521                    });
1522                    // The engine drops the BiDi socket as it shuts down, so this can
1523                    // return an error/timeout, the flush is driven by the tab
1524                    // teardown the command triggers, not by the response, so the
1525                    // outcome is intentionally ignored.
1526                    let _ = tokio::time::timeout(
1527                        std::time::Duration::from_secs(10),
1528                        browser.driver_mut().send_command(cmd),
1529                    )
1530                    .await;
1531                }
1532            }
1533            // Firefox flushes storage during the clean shutdown `browser.close`
1534            // started; it has exited (storage on disk) by the time this returns.
1535            if !wait_for_exit(&mut c, 100).await {
1536                // The engine did not exit on its own (e.g. headless kept the parent
1537                // process alive). The per-origin flush already landed when the tabs
1538                // closed above, so it is safe to escalate now: SIGTERM, then SIGKILL.
1539                request_graceful_terminate(c.id());
1540                if !wait_for_exit(&mut c, 30).await {
1541                    let _ = c.kill();
1542                    let _ = c.wait();
1543                }
1544            }
1545            // Drop the (now-dead) BiDi connection. We do NOT call rustenium's
1546            // `FirefoxBrowser::close` here: its `fuser -k <port>` SIGKILL would race
1547            // the flush above, and the process is already gone.
1548            let _ = self.browser.lock().await.take();
1549        } else if let Some(browser) = self.browser.lock().await.take() {
1550            // rustenium-managed path (it owns the process): end the BiDi session.
1551            let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1552        }
1553        Ok(())
1554    }
1555}
1556
1557// ------------------------------------------------------------------
1558// Browser launch configuration
1559// ------------------------------------------------------------------
1560
1561/// Upstream proxy transport for a launched Firefox.
1562#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1563pub enum ProxyScheme {
1564    /// HTTP/HTTPS proxy (`network.proxy.http` + `ssl`, shared).
1565    #[default]
1566    Http,
1567    /// SOCKS5 proxy (`network.proxy.socks`, remote DNS on).
1568    Socks5,
1569}
1570
1571/// A proxy to route a launched Firefox through. Emitted as `network.proxy.*`
1572/// prefs into the profile `user.js` at launch, the right place, since Firefox
1573/// has no `--proxy-server` flag.
1574///
1575/// IP-whitelisted gateways work fully via prefs. Firefox cannot carry
1576/// **proxy-auth credentials** in prefs (it would prompt), so `username`/
1577/// `password` are plumbed but require a local unauthenticated relay (e.g.
1578/// `proxywire`) in front of the authenticated upstream; [`proxy_prefs`] logs a
1579/// warning rather than silently dropping them.
1580#[derive(Debug, Clone, Default)]
1581pub struct ProxyConfig {
1582    pub scheme: ProxyScheme,
1583    pub host: String,
1584    pub port: u16,
1585    pub username: Option<String>,
1586    pub password: Option<String>,
1587}
1588
1589impl ProxyConfig {
1590    /// Parse `scheme://[user:pass@]host:port`. Scheme defaults to `http`;
1591    /// `socks5`/`socks` selects SOCKS5.
1592    pub fn from_url(url: &str) -> Result<Self> {
1593        let (scheme, rest) = match url.split_once("://") {
1594            Some((s, r)) => (s.to_ascii_lowercase(), r),
1595            None => ("http".to_string(), url),
1596        };
1597        let scheme = match scheme.as_str() {
1598            "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1599            "http" | "https" => ProxyScheme::Http,
1600            other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1601        };
1602        let (auth, hostport) = match rest.rsplit_once('@') {
1603            Some((a, hp)) => (Some(a), hp),
1604            None => (None, rest),
1605        };
1606        let (username, password) = match auth {
1607            Some(a) => match a.split_once(':') {
1608                Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1609                None => (Some(a.to_string()), None),
1610            },
1611            None => (None, None),
1612        };
1613        let (host, port) = hostport
1614            .rsplit_once(':')
1615            .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1616        let port: u16 = port
1617            .parse()
1618            .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1619        if host.is_empty() {
1620            return Err(anyhow!("proxy URL missing host: {url}"));
1621        }
1622        Ok(Self {
1623            scheme,
1624            host: host.to_string(),
1625            port,
1626            username,
1627            password,
1628        })
1629    }
1630}
1631
1632/// Build the Firefox `network.proxy.*` `user_pref` lines for `proxy`.
1633pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1634    if proxy.username.is_some() || proxy.password.is_some() {
1635        tracing::warn!(
1636            "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1637             front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1638             foxdriver at that. Emitting host:port prefs only."
1639        );
1640    }
1641    let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1642    match proxy.scheme {
1643        ProxyScheme::Http => {
1644            lines.push(format!(
1645                r#"user_pref("network.proxy.http", "{}");"#,
1646                proxy.host
1647            ));
1648            lines.push(format!(
1649                r#"user_pref("network.proxy.http_port", {});"#,
1650                proxy.port
1651            ));
1652            lines.push(format!(
1653                r#"user_pref("network.proxy.ssl", "{}");"#,
1654                proxy.host
1655            ));
1656            lines.push(format!(
1657                r#"user_pref("network.proxy.ssl_port", {});"#,
1658                proxy.port
1659            ));
1660            lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1661        }
1662        ProxyScheme::Socks5 => {
1663            lines.push(format!(
1664                r#"user_pref("network.proxy.socks", "{}");"#,
1665                proxy.host
1666            ));
1667            lines.push(format!(
1668                r#"user_pref("network.proxy.socks_port", {});"#,
1669                proxy.port
1670            ));
1671            lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1672            lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1673        }
1674    }
1675    // Do not bypass the proxy for localhost, a residential run must egress
1676    // every request through the upstream, including any IP-echo check.
1677    lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1678
1679    // WebRTC IP-leak prevention, proxy-conditional by design. Without this,
1680    // ICE candidate gathering opens a DIRECT UDP socket to STUN servers,
1681    // bypassing the proxy entirely and exposing the host's real public
1682    // (server-reflexive) AND LAN (host) addresses. Behind a proxy that real IP
1683    // CONTRADICTS the proxy egress IP, the classic WebRTC deanonymization that
1684    // silently blows the operator's cover even when every HTTP byte is proxied.
1685    // `ice.proxy_only` forces ALL ICE traffic through the configured proxy, so
1686    // no real-IP candidate is ever gathered; `no_host` suppresses LAN-address
1687    // candidates; `default_address_only` exposes only the default route (no
1688    // multi-homed interface enumeration). WebRTC stays ENABLED, disabling it
1689    // (`media.peerconnection.enabled=false`) is itself a fingerprint tell, it
1690    // simply cannot egress outside the proxy.
1691    lines.push(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#.to_string());
1692    lines.push(r#"user_pref("media.peerconnection.ice.no_host", true);"#.to_string());
1693    lines.push(r#"user_pref("media.peerconnection.ice.default_address_only", true);"#.to_string());
1694
1695    // DNS-leak prevention, proxy-conditional. DNS prefetch (`<link
1696    // rel=dns-prefetch>`, anchor pre-resolution), the network predictor, and
1697    // speculative connections resolve hostnames via the OS resolver OUTSIDE the
1698    // proxy, leaking the visited/linked domains AND the host's real DNS path
1699    // even when every NAVIGATED request is proxied. Disabling them makes the
1700    // proxy the ONLY resolver path (the SOCKS form additionally forces lookups
1701    // through the proxy via `socks_remote_dns` above; an HTTP proxy resolves
1702    // server-side from the full-URI request). Without these, a single
1703    // `dns-prefetch` link silently emits a clear-text DNS query from the host.
1704    lines.push(r#"user_pref("network.dns.disablePrefetch", true);"#.to_string());
1705    lines.push(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#.to_string());
1706    lines.push(r#"user_pref("network.predictor.enabled", false);"#.to_string());
1707    lines.push(r#"user_pref("network.http.speculative-parallel-limit", 0);"#.to_string());
1708    lines.push(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#.to_string());
1709
1710    lines.push('\n'.to_string());
1711    lines.join("\n")
1712}
1713
1714#[derive(Debug, Clone, Default)]
1715pub struct FoxBrowserConfig {
1716    pub executable_path: Option<String>,
1717    /// Firefox profile directory.
1718    ///
1719    /// Supply a STABLE path to get a PERSISTENT persona: cookies, localStorage,
1720    /// IndexedDB, and the per-identity device fingerprint (canvas/audio seed, when
1721    /// launched via `guise`) all survive a restart that reuses the same path, a
1722    /// returning logged-in user, not a brand-new browser each launch. Persistence
1723    /// requires the session to end through [`Page::close`], which performs the clean
1724    /// `browser.close` shutdown that flushes Firefox's QuotaManager storage to disk
1725    /// (a bare SIGKILL would lose unflushed localStorage/IndexedDB).
1726    ///
1727    /// `None` synthesizes a fresh temporary profile per launch, an ephemeral,
1728    /// one-shot persona with no cross-launch state.
1729    pub profile_dir: Option<String>,
1730    pub headless: bool,
1731    pub viewport_width: u32,
1732    pub viewport_height: u32,
1733    pub user_agent: Option<String>,
1734    /// Raw `user.js` content to write into the profile directory before
1735    /// Firefox starts. The caller (typically `guise`) is responsible for
1736    /// building this string from profile overrides.
1737    pub user_js_content: Option<String>,
1738    /// Optional upstream proxy. Emitted as `network.proxy.*` prefs appended to
1739    /// `user_js_content` at launch (requires `profile_dir`).
1740    pub proxy: Option<ProxyConfig>,
1741    /// How Firefox handles JS user prompts (`alert`/`confirm`/`prompt`/
1742    /// `beforeunload`). One of `accept`, `dismiss`, `ignore`, `dismiss and
1743    /// notify`. `None` keeps the BiDi default (`dismiss and notify`), which
1744    /// never hangs and still emits the events the dialog log records. Set
1745    /// `ignore` to keep prompts OPEN so [`Page::handle_user_prompt`] can answer
1746    /// them; set `accept` to auto-accept (a `confirm()` guard returns true,
1747    /// `beforeunload` never blocks navigation).
1748    pub unhandled_prompt_behavior: Option<String>,
1749    /// Extra environment variables to set on the spawned Firefox process, on top
1750    /// of the inherited parent env. ONLY honored by [`launch_firefox_self_managed`]
1751    /// (foxdriver owns that spawn); the rustenium-managed [`launch_firefox`] cannot
1752    /// set per-process env. The canonical use is `TZ=<IANA zone>` so ICU reports the
1753    /// persona timezone in EVERY realm, including dedicated Workers, which a
1754    /// window-realm JS `Intl`/`Date` preload can never reach (a worker that read the
1755    /// host zone while the window claimed the persona zone was a trivially-detected
1756    /// leak). Per-process, so concurrent launches with different zones never race
1757    /// (unlike mutating the parent process's `TZ`).
1758    pub env: Vec<(String, String)>,
1759}
1760
1761/// Map a prompt-behavior string to the typed BiDi capability value. Returns
1762/// `None` for an unrecognized value so launch falls back to the BiDi default
1763/// rather than failing.
1764fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1765    let handler = match s.trim().to_ascii_lowercase().as_str() {
1766        "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1767        "dismiss" => UserPromptHandlerType::Dismiss,
1768        "ignore" => UserPromptHandlerType::Ignore,
1769        "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1770            UserPromptHandlerType::DismissAndNotify
1771        }
1772        _ => return None,
1773    };
1774    Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1775}
1776
1777/// Write `user.js` into the given profile directory.
1778fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1779    let dir = std::path::Path::new(profile_dir);
1780    std::fs::create_dir_all(dir)
1781        .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1782    let path = dir.join("user.js");
1783    std::fs::write(&path, content)
1784        .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1785    Ok(())
1786}
1787
1788/// Launch Firefox with the given config and return a `Page` handle.
1789pub async fn launch_firefox(mut config: FoxBrowserConfig) -> Result<Page> {
1790    let mut caps = FirefoxCapabilities::default();
1791    caps.accept_insecure_certs(true);
1792    if let Some(behavior) = config
1793        .unhandled_prompt_behavior
1794        .as_deref()
1795        .and_then(prompt_behavior_capability)
1796    {
1797        caps.unhandled_prompt_behavior(behavior);
1798    }
1799
1800    let mut args = Vec::new();
1801    if config.headless {
1802        args.push("--headless".to_string());
1803    }
1804    if let Some(ref ua) = config.user_agent {
1805        args.push(format!("--user-agent={}", ua));
1806    }
1807    if config.viewport_width > 0 {
1808        args.push(format!("--width={}", config.viewport_width));
1809    }
1810    if config.viewport_height > 0 {
1811        args.push(format!("--height={}", config.viewport_height));
1812    }
1813
1814    // Assemble the final user.js: caller-supplied prefs plus, if a proxy is
1815    // configured, the network.proxy.* lines. Written before launch so prefs are
1816    // live from the first request (a proxied run must NOT leak the real IP on
1817    // the initial navigation).
1818    let mut user_js = config.user_js_content.clone().unwrap_or_default();
1819    if let Some(ref proxy) = config.proxy {
1820        if !user_js.is_empty() && !user_js.ends_with('\n') {
1821            user_js.push('\n');
1822        }
1823        user_js.push_str(&proxy_prefs(proxy));
1824    }
1825    if !user_js.is_empty() {
1826        // A non-empty user.js means the caller set engine-level prefs (persona UA
1827        // override, dom.maxHardwareConcurrency, automation prefs, proxy). Firefox
1828        // only reads user.js from a profile directory, so if none was supplied we
1829        // MUST synthesize one, the old behaviour silently dropped every pref
1830        // behind a `tracing::warn`, shipping a browser that LOOKS launched but is
1831        // missing exactly those prefs: a half-applied disguise (e.g. a Worker realm
1832        // reporting the real hardwareConcurrency) and, with a proxy configured, a
1833        // real-IP leak on the first navigation. That is an invisible recall hole,
1834        // not a warning to continue past (Law 10 / fail-closed for stealth).
1835        if config
1836            .profile_dir
1837            .as_deref()
1838            .filter(|d| !d.is_empty())
1839            .is_none()
1840        {
1841            static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1842            let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1843            let dir =
1844                std::env::temp_dir().join(format!("foxdriver-profile-{}-{n}", std::process::id()));
1845            config.profile_dir = Some(dir.to_string_lossy().into_owned());
1846        }
1847        let profile_dir = config
1848            .profile_dir
1849            .as_deref()
1850            .expect("profile_dir synthesized above when missing");
1851        // Fail closed: a stealth/proxy pref that does not get written produces a
1852        // detectable, potentially IP-leaking browser (surface it, never continue).
1853        write_user_js(profile_dir, &user_js)
1854            .map_err(|e| anyhow!("failed to write user.js to profile {profile_dir:?}: {e}"))?;
1855    }
1856
1857    let profile_dir = config.profile_dir.clone();
1858    let cfg = FirefoxConfig {
1859        capabilities: caps,
1860        firefox_executable_path: config.executable_path,
1861        profile_dir: config.profile_dir,
1862        browser_flags: Some(args),
1863        ..Default::default()
1864    };
1865
1866    let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1867        .await
1868        .map_err(|_| anyhow!("Firefox launch timed out after 30s, check that Firefox is installed and not already running with a locked profile"))?;
1869    Ok(Page {
1870        browser: tokio::sync::Mutex::new(Some(browser)),
1871        profile_dir,
1872        child: std::sync::Mutex::new(None),
1873    })
1874}
1875
1876/// Reserve an ephemeral TCP port by binding `127.0.0.1:0` and reading back the
1877/// OS-assigned port, then releasing it. There is an unavoidable TOCTOU window
1878/// between release and the browser binding it; in practice the browser claims it
1879/// within milliseconds and a collision surfaces as a clean readiness-timeout.
1880fn reserve_local_port() -> Result<u16> {
1881    let listener = std::net::TcpListener::bind("127.0.0.1:0")
1882        .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1883    let port = listener
1884        .local_addr()
1885        .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1886        .port();
1887    Ok(port)
1888}
1889
1890/// Resolve the Firefox binary: the caller's explicit `executable_path` if set,
1891/// otherwise the first match on `PATH` and then the standard install locations.
1892///
1893/// [`launch_firefox`] gets PATH resolution for free because it hands a possibly-
1894/// `None` path to rustenium, which finds Firefox itself. When foxdriver owns the
1895/// spawn ([`launch_firefox_self_managed`]) it must do the same so the robust
1896/// readiness-poll launcher is a true drop-in, a caller that relies on
1897/// Firefox-on-PATH (e.g. captchaforge's `drive_browser`) can adopt it without
1898/// hard-coding a path.
1899fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1900    if let Some(p) = explicit {
1901        return Ok(p);
1902    }
1903    const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1904    if let Ok(path) = std::env::var("PATH") {
1905        let sep = if cfg!(windows) { ';' } else { ':' };
1906        for dir in path.split(sep).filter(|d| !d.is_empty()) {
1907            for name in NAMES {
1908                let cand = std::path::Path::new(dir).join(name);
1909                if cand.is_file() {
1910                    return Ok(cand.to_string_lossy().into_owned());
1911                }
1912            }
1913        }
1914    }
1915    // Standard locations that are not always on PATH (snap/opt/macOS/Windows).
1916    const FIXED: &[&str] = &[
1917        "/usr/local/bin/firefox",
1918        "/usr/bin/firefox",
1919        "/opt/firefox/firefox",
1920        "/snap/bin/firefox",
1921        "/Applications/Firefox.app/Contents/MacOS/firefox",
1922        "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1923        "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1924    ];
1925    for p in FIXED {
1926        if std::path::Path::new(p).is_file() {
1927            return Ok((*p).to_string());
1928        }
1929    }
1930    Err(anyhow!(
1931        "could not find a Firefox binary, set FoxBrowserConfig.executable_path or install Firefox on PATH"
1932    ))
1933}
1934
1935/// Launch Firefox where **foxdriver owns the spawn and the readiness wait**, then
1936/// attaches over BiDi in rustenium `Remote` mode.
1937///
1938/// The default [`launch_firefox`] delegates spawning to rustenium, which sleeps a
1939/// fixed 500 ms after exec before connecting to the BiDi WebSocket. That races any
1940/// build whose remote agent binds slowly, a freshly-built Camoufox/reynard takes
1941/// ~1 s, yielding a `ConnectionRefused` panic. Here foxdriver spawns the process,
1942/// polls the debugging port until it actually accepts a connection (Law-7:
1943/// readiness, never a fixed sleep), and only then hands rustenium an already-live
1944/// port via [`FirefoxLaunchMode::Remote`]. The spawned [`std::process::Child`] is
1945/// owned by the returned [`Page`] and killed on `close`/drop.
1946///
1947/// `config.executable_path` is resolved via [`resolve_firefox_binary`], the
1948/// explicit path if set, else PATH / standard install locations (this path never
1949/// auto-downloads Firefox).
1950pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
1951    let exe = resolve_firefox_binary(config.executable_path.clone())?;
1952
1953    let host = "127.0.0.1".to_string();
1954    let port = reserve_local_port()?;
1955
1956    // Profile dir: caller-supplied or a unique temp dir. Written with the same
1957    // user.js (incl. proxy prefs) as the managed path so prefs are live from the
1958    // first request.
1959    let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
1960        std::env::temp_dir()
1961            .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
1962            .display()
1963            .to_string()
1964    });
1965    std::fs::create_dir_all(&profile_dir)
1966        .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
1967
1968    let mut user_js = config.user_js_content.clone().unwrap_or_default();
1969    if let Some(ref proxy) = config.proxy {
1970        if !user_js.is_empty() && !user_js.ends_with('\n') {
1971            user_js.push('\n');
1972        }
1973        user_js.push_str(&proxy_prefs(proxy));
1974    }
1975    if !user_js.is_empty() {
1976        write_user_js(&profile_dir, &user_js)?;
1977    }
1978
1979    // Assemble args. `--no-remote` + the explicit debugging port mirror what
1980    // rustenium would pass in SpawnAndAttach; the rest come from the viewport /
1981    // headless / UA config.
1982    let mut args = vec![
1983        format!("--remote-debugging-port={port}"),
1984        "--profile".to_string(),
1985        profile_dir.clone(),
1986        "--no-remote".to_string(),
1987    ];
1988    if config.headless {
1989        args.push("--headless".to_string());
1990    }
1991    if let Some(ref ua) = config.user_agent {
1992        args.push(format!("--user-agent={ua}"));
1993    }
1994    if config.viewport_width > 0 {
1995        args.push(format!("--width={}", config.viewport_width));
1996    }
1997    if config.viewport_height > 0 {
1998        args.push(format!("--height={}", config.viewport_height));
1999    }
2000
2001    // Spawn the process. The parent env is inherited (so a launch wrapper's
2002    // exported config / sandbox toggles propagate); match rustenium's
2003    // MOZ_LAUNCHER_PROCESS=0 so the parent PID is the actual browser. Caller env
2004    // (e.g. TZ for worker-realm timezone coherence) is applied per-process here so
2005    // concurrent launches with different values never race on the parent env.
2006    let mut command = std::process::Command::new(&exe);
2007    command.args(&args).env("MOZ_LAUNCHER_PROCESS", "0");
2008    for (key, value) in &config.env {
2009        command.env(key, value);
2010    }
2011    let child = command
2012        .spawn()
2013        .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
2014
2015    // Poll the debugging port until it accepts a connection, or time out. This is
2016    // the wait rustenium's fixed 500 ms sleep gets wrong for slow-binding builds.
2017    let addr: std::net::SocketAddr = format!("{host}:{port}")
2018        .parse()
2019        .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
2020    let start = std::time::Instant::now();
2021    let ready_timeout = std::time::Duration::from_secs(30);
2022    loop {
2023        if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250))
2024            .is_ok()
2025        {
2026            break;
2027        }
2028        if start.elapsed() >= ready_timeout {
2029            return Err(anyhow!(
2030                "browser debug port {port} never came up within {}s, the spawn likely failed (check {exe:?})",
2031                ready_timeout.as_secs()
2032            ));
2033        }
2034        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2035    }
2036
2037    // Attach over BiDi to the already-live port (no spawn, no fixed-sleep race).
2038    //
2039    // A SINGLE attach: rustenium's `BidiSession::new` waits a hardcoded 5 s for
2040    // the `session.new` response and PANICS on timeout, but the session is still
2041    // CREATED on the browser, and a BiDi browser allows only one active session,
2042    // so a retry just hits "Maximum number of active sessions". The right lever is
2043    // therefore to give the engine enough head start that its single `session.new`
2044    // answers within that window (see the post-readiness settle below), not to
2045    // retry. The attach runs in a task so a timeout surfaces as a clean error
2046    // instead of unwinding this function.
2047    let cfg = FirefoxConfig {
2048        host: Some(host.clone()),
2049        capabilities: {
2050            let mut caps = FirefoxCapabilities::default();
2051            caps.accept_insecure_certs(true);
2052            if let Some(behavior) = config
2053                .unhandled_prompt_behavior
2054                .as_deref()
2055                .and_then(prompt_behavior_capability)
2056            {
2057                caps.unhandled_prompt_behavior(behavior);
2058            }
2059            caps
2060        },
2061        launch_mode: FirefoxLaunchMode::Remote(port),
2062        remote_debugging_port: Some(port),
2063        ..Default::default()
2064    };
2065    let attach = tokio::spawn(async move {
2066        tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
2067    });
2068    let browser = match attach.await {
2069        Ok(Ok(b)) => b,
2070        Ok(Err(_elapsed)) => {
2071            return Err(anyhow!(
2072                "BiDi attach to self-managed browser timed out after 30s"
2073            ))
2074        }
2075        Err(join) => {
2076            return Err(anyhow!(
2077                "BiDi attach to self-managed browser failed: {join}"
2078            ))
2079        }
2080    };
2081
2082    Ok(Page {
2083        browser: tokio::sync::Mutex::new(Some(browser)),
2084        profile_dir: Some(profile_dir),
2085        child: std::sync::Mutex::new(Some(child)),
2086    })
2087}
2088
2089#[cfg(test)]
2090mod tests {
2091    use super::*;
2092
2093    /// Regression: the `Drop` cleanup for a self-managed Firefox child sent
2094    /// SIGKILL but never called `wait()`, so a killed browser lingered as a
2095    /// zombie until the whole foxdriver process exited. `terminate_and_reap`
2096    /// must leave no zombie behind. A process that ignores SIGTERM forces the
2097    /// SIGKILL path; after the call its pid must be fully reaped (gone from
2098    /// /proc, not merely defunct).
2099    #[cfg(unix)]
2100    #[test]
2101    fn terminate_and_reap_leaves_no_zombie() {
2102        let child = std::process::Command::new("/bin/sh")
2103            .args(["-c", "trap '' TERM; sleep 300"])
2104            .spawn()
2105            .expect("spawn test child");
2106        let pid = child.id();
2107        terminate_and_reap(child);
2108        // A reaped child vanishes from /proc; a zombie would still show up.
2109        assert!(
2110            !std::path::Path::new(&format!("/proc/{pid}")).exists(),
2111            "pid {pid} still present after terminate_and_reap (zombie leak)"
2112        );
2113    }
2114
2115    /// Regression: `click_in_frame` and `type_in_frame` escaped selectors
2116    /// with a local ad-hoc escaper that only handled backslash and single
2117    /// quote, beside the crate's shared `escape_js_string`. Both now use the
2118    /// shared escaper; this locks the shared behavior every JS-interpolation
2119    /// site relies on.
2120    #[test]
2121    fn shared_escaper_covers_quote_and_control_breakout() {
2122        let malicious = "'); alert(1); //";
2123        let escaped = crate::frame::escape_js_string(malicious);
2124        // The single quote is escaped, so the JS string literal cannot be
2125        // closed early.
2126        assert_eq!(escaped, "\\'); alert(1); //");
2127        // Every quote and backslash in the output is prefixed by a backslash.
2128        for (index, _) in escaped.match_indices('\'') {
2129            assert_eq!(escaped.as_bytes()[index - 1], b'\\');
2130        }
2131    }
2132
2133    use rustenium_bidi_definitions::script::types::{
2134        ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType, BooleanValue,
2135        BooleanValueType, ListRemoteValue, MappingRemoteValue, NullValue, NullValueType,
2136        NumberValue, NumberValueType, ObjectRemoteValue, ObjectRemoteValueType,
2137        PrimitiveProtocolValue, StringValue, StringValueType, UndefinedValue, UndefinedValueType,
2138    };
2139
2140    // ─── FrameSpec::parse ───
2141
2142    #[test]
2143    fn frame_spec_main_aliases() {
2144        assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
2145        assert_eq!(FrameSpec::parse("  "), FrameSpec::Main);
2146        assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
2147        assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
2148    }
2149
2150    #[test]
2151    fn frame_spec_index_forms() {
2152        // Bare digits are ambiguous (numeric BiDi id OR index) → IdOrIndex.
2153        assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
2154        assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
2155        // A large numeric Firefox context id is still resolvable by exact id.
2156        assert_eq!(
2157            FrameSpec::parse("10737418241"),
2158            FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
2159        );
2160        // `index:` forces a strict index.
2161        assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
2162    }
2163
2164    #[test]
2165    fn frame_spec_url_and_name_prefixes() {
2166        assert_eq!(
2167            FrameSpec::parse("url:recaptcha/api2"),
2168            FrameSpec::UrlContains("recaptcha/api2".into())
2169        );
2170        assert_eq!(
2171            FrameSpec::parse("name:checkout-frame"),
2172            FrameSpec::NameEquals("checkout-frame".into())
2173        );
2174        // Whitespace inside the value is trimmed.
2175        assert_eq!(
2176            FrameSpec::parse("url:  https://x.com "),
2177            FrameSpec::UrlContains("https://x.com".into())
2178        );
2179    }
2180
2181    #[test]
2182    fn frame_spec_bare_id_falls_through() {
2183        // An opaque BiDi context id (non-numeric, no prefix) is an Id.
2184        assert_eq!(
2185            FrameSpec::parse("10737418241-abc"),
2186            FrameSpec::Id("10737418241-abc".into())
2187        );
2188        // A bare URL with no prefix is also an Id (resolve falls back to URL match).
2189        assert_eq!(
2190            FrameSpec::parse("https://w.com/f"),
2191            FrameSpec::Id("https://w.com/f".into())
2192        );
2193    }
2194
2195    // ─── prompt_behavior_capability ───
2196
2197    #[test]
2198    fn prompt_behavior_maps_known_values() {
2199        for s in [
2200            "accept",
2201            "ACCEPT",
2202            "dismiss",
2203            "ignore",
2204            "dismiss and notify",
2205            "notify",
2206        ] {
2207            assert!(
2208                prompt_behavior_capability(s).is_some(),
2209                "'{s}' should map to a capability"
2210            );
2211        }
2212    }
2213
2214    #[test]
2215    fn prompt_behavior_rejects_unknown() {
2216        assert!(prompt_behavior_capability("").is_none());
2217        assert!(prompt_behavior_capability("bogus").is_none());
2218    }
2219
2220    #[test]
2221    fn prompt_behavior_ignore_is_user_prompt_handler_type() {
2222        match prompt_behavior_capability("ignore") {
2223            Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {
2224            }
2225            other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
2226        }
2227    }
2228
2229    // ─── bidi_wire_value_to_json ───
2230
2231    #[test]
2232    fn wire_string_extracts_value() {
2233        let v = serde_json::json!({"type": "string", "value": "hello"});
2234        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
2235    }
2236
2237    #[test]
2238    fn wire_number_passthrough() {
2239        let v = serde_json::json!({"type": "number", "value": 42.5});
2240        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
2241    }
2242
2243    #[test]
2244    fn wire_boolean_extracts_bool() {
2245        let v = serde_json::json!({"type": "boolean", "value": true});
2246        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
2247    }
2248
2249    #[test]
2250    fn wire_null_returns_null() {
2251        let v = serde_json::json!({"type": "null"});
2252        assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2253    }
2254
2255    #[test]
2256    fn wire_undefined_returns_null() {
2257        let v = serde_json::json!({"type": "undefined"});
2258        assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
2259    }
2260
2261    #[test]
2262    fn wire_bigint_returns_string() {
2263        let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
2264        assert_eq!(
2265            bidi_wire_value_to_json(&v),
2266            serde_json::json!("9007199254740993")
2267        );
2268    }
2269
2270    #[test]
2271    fn wire_object_recurse() {
2272        let v = serde_json::json!({
2273            "type": "object",
2274            "value": [
2275                ["a", {"type": "string", "value": "alpha"}],
2276                ["b", {"type": "number", "value": 2}]
2277            ]
2278        });
2279        let out = bidi_wire_value_to_json(&v);
2280        assert_eq!(out["a"], "alpha");
2281        assert_eq!(out["b"], 2);
2282    }
2283
2284    #[test]
2285    fn wire_array_recurse() {
2286        let v = serde_json::json!({
2287            "type": "array",
2288            "value": [
2289                {"type": "string", "value": "x"},
2290                {"type": "number", "value": 1}
2291            ]
2292        });
2293        let out = bidi_wire_value_to_json(&v);
2294        assert_eq!(out, serde_json::json!(["x", 1]));
2295    }
2296
2297    #[test]
2298    fn wire_unknown_type_clones_raw() {
2299        let v = serde_json::json!({"type": "special", "payload": 99});
2300        assert_eq!(bidi_wire_value_to_json(&v), v);
2301    }
2302
2303    #[test]
2304    fn wire_missing_type_clones_raw() {
2305        let v = serde_json::json!({"payload": 99});
2306        assert_eq!(bidi_wire_value_to_json(&v), v);
2307    }
2308
2309    // ─── remote_value_to_json ───
2310
2311    #[test]
2312    fn rv_string_value() {
2313        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2314            StringValue::new(StringValueType::String, "hi"),
2315        ));
2316        assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
2317    }
2318
2319    #[test]
2320    fn rv_number_value() {
2321        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2322            NumberValue::new(NumberValueType::Number, 2.5),
2323        ));
2324        assert_eq!(remote_value_to_json(&rv), serde_json::json!(2.5));
2325    }
2326
2327    #[test]
2328    fn rv_boolean_value() {
2329        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2330            BooleanValue::new(BooleanValueType::Boolean, true),
2331        ));
2332        assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
2333    }
2334
2335    #[test]
2336    fn rv_null_value() {
2337        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
2338            NullValue::new(NullValueType::Null),
2339        ));
2340        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2341    }
2342
2343    #[test]
2344    fn rv_undefined_value() {
2345        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
2346            UndefinedValue::new(UndefinedValueType::Undefined),
2347        ));
2348        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2349    }
2350
2351    #[test]
2352    fn rv_bigint_value() {
2353        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
2354            BigIntValue::new(BigIntValueType::Bigint, "999n"),
2355        ));
2356        assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
2357    }
2358
2359    #[test]
2360    fn rv_array_value() {
2361        let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2362            StringValue::new(StringValueType::String, "item"),
2363        ));
2364        let arr = ArrayRemoteValue {
2365            r#type: ArrayRemoteValueType::Array,
2366            handle: None,
2367            internal_id: None,
2368            value: Some(ListRemoteValue::new(vec![inner])),
2369        };
2370        let rv = RemoteValue::ArrayRemoteValue(arr);
2371        assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
2372    }
2373
2374    #[test]
2375    fn rv_object_value() {
2376        let obj = ObjectRemoteValue {
2377            r#type: ObjectRemoteValueType::Object,
2378            handle: None,
2379            internal_id: None,
2380            value: Some(MappingRemoteValue::new(vec![vec![
2381                serde_json::json!("key"),
2382                serde_json::json!({"type": "string", "value": "val"}),
2383            ]])),
2384        };
2385        let rv = RemoteValue::ObjectRemoteValue(obj);
2386        let out = remote_value_to_json(&rv);
2387        assert_eq!(out["key"], "val");
2388    }
2389
2390    #[test]
2391    fn rv_unsupported_returns_null() {
2392        let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
2393            rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
2394        );
2395        let rv = RemoteValue::SymbolRemoteValue(sym);
2396        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
2397    }
2398
2399    // ─── EvaluationResult ───
2400
2401    #[test]
2402    fn eval_result_into_value_deserializes() {
2403        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
2404            StringValue::new(StringValueType::String, "deserialized"),
2405        ));
2406        let er = EvaluationResult::new(rv);
2407        let s: String = er.into_value().unwrap();
2408        assert_eq!(s, "deserialized");
2409    }
2410
2411    #[test]
2412    fn eval_result_into_value_number() {
2413        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
2414            NumberValue::new(NumberValueType::Number, 42i32),
2415        ));
2416        let er = EvaluationResult::new(rv);
2417        let n: i32 = er.into_value().unwrap();
2418        assert_eq!(n, 42);
2419    }
2420
2421    #[test]
2422    fn eval_result_remote_value_accessor() {
2423        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
2424            BooleanValue::new(BooleanValueType::Boolean, false),
2425        ));
2426        let er = EvaluationResult::new(rv.clone());
2427        assert_eq!(er.remote_value(), &rv);
2428    }
2429
2430    // ─── FoxBrowserConfig ───
2431
2432    #[test]
2433    fn fox_browser_config_default_is_headless_false() {
2434        let cfg = FoxBrowserConfig::default();
2435        assert!(!cfg.headless);
2436        assert!(cfg.executable_path.is_none());
2437        assert!(cfg.profile_dir.is_none());
2438        assert_eq!(cfg.viewport_width, 0);
2439        assert_eq!(cfg.viewport_height, 0);
2440        assert!(cfg.user_agent.is_none());
2441        assert!(cfg.user_js_content.is_none());
2442    }
2443
2444    // ─── write_user_js ───
2445
2446    #[test]
2447    fn write_user_js_creates_file() {
2448        let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2449        let _ = std::fs::remove_dir_all(&tmp);
2450        let content = "user_pref(\"test\", true);\n";
2451        write_user_js(tmp.to_str().unwrap(), content).unwrap();
2452        let path = tmp.join("user.js");
2453        assert!(path.exists());
2454        let read = std::fs::read_to_string(&path).unwrap();
2455        assert_eq!(read, content);
2456        let _ = std::fs::remove_dir_all(&tmp);
2457    }
2458
2459    #[test]
2460    fn write_user_js_creates_nested_dirs() {
2461        let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2462        let _ = std::fs::remove_dir_all(&tmp);
2463        let nested = tmp.join("a").join("b");
2464        write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2465        assert!(nested.join("user.js").exists());
2466        let _ = std::fs::remove_dir_all(&tmp);
2467    }
2468
2469    // ─── ProxyConfig / proxy_prefs ───
2470
2471    #[test]
2472    fn proxy_from_url_http_no_auth() {
2473        let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2474        assert_eq!(p.scheme, ProxyScheme::Http);
2475        assert_eq!(p.host, "1.2.3.4");
2476        assert_eq!(p.port, 8080);
2477        assert!(p.username.is_none() && p.password.is_none());
2478    }
2479
2480    #[test]
2481    fn proxy_from_url_socks5_with_auth() {
2482        let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2483        assert_eq!(p.scheme, ProxyScheme::Socks5);
2484        assert_eq!(p.host, "gw.residential.net");
2485        assert_eq!(p.port, 1080);
2486        assert_eq!(p.username.as_deref(), Some("user"));
2487        assert_eq!(p.password.as_deref(), Some("pass"));
2488    }
2489
2490    #[test]
2491    fn proxy_from_url_bare_defaults_http() {
2492        let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2493        assert_eq!(p.scheme, ProxyScheme::Http);
2494        assert_eq!(p.host, "10.0.0.1");
2495        assert_eq!(p.port, 3128);
2496    }
2497
2498    #[test]
2499    fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2500        assert!(ProxyConfig::from_url("http://nohost").is_err());
2501        assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2502        assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2503    }
2504
2505    #[test]
2506    fn proxy_prefs_http_emits_http_ssl_and_type() {
2507        let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2508        assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2509        assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2510        assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2511        assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2512        assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2513        // Negative twin: the HTTP form must NOT emit SOCKS prefs.
2514        assert!(!prefs.contains("network.proxy.socks"));
2515    }
2516
2517    #[test]
2518    fn proxy_prefs_socks5_emits_socks_and_version() {
2519        let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2520        assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2521        assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2522        assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2523        // Negative twin: the SOCKS form must NOT emit the HTTP-proxy prefs.
2524        assert!(!prefs.contains("network.proxy.http_port"));
2525    }
2526
2527    #[test]
2528    fn proxy_prefs_close_the_webrtc_ip_leak_for_both_schemes() {
2529        // A proxied egress MUST also force WebRTC ICE through the proxy, else a
2530        // direct-UDP STUN gather leaks the host's real public IP (srflx) and LAN
2531        // IP (host) (contradicting the proxy egress and deanonymizing the run).
2532        // Both HTTP and SOCKS proxies are affected, so both must carry the fix.
2533        for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2534            let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2535            assert!(
2536                prefs.contains(r#"user_pref("media.peerconnection.ice.proxy_only", true);"#),
2537                "{url}: must force ICE through the proxy (no direct-UDP srflx leak)"
2538            );
2539            assert!(
2540                prefs.contains(r#"user_pref("media.peerconnection.ice.no_host", true);"#),
2541                "{url}: must suppress LAN host candidates"
2542            );
2543            assert!(
2544                prefs.contains(
2545                    r#"user_pref("media.peerconnection.ice.default_address_only", true);"#
2546                ),
2547                "{url}: must expose only the default route address"
2548            );
2549            // Soundness: WebRTC stays ENABLED (disabling it is itself a tell).
2550            assert!(
2551                !prefs.contains("media.peerconnection.enabled"),
2552                "{url}: must NOT disable WebRTC outright (a fingerprint tell); only confine ICE"
2553            );
2554        }
2555    }
2556
2557    #[test]
2558    fn proxy_prefs_close_the_dns_prefetch_leak_for_both_schemes() {
2559        // A proxied egress must also stop DNS prefetch / predictor / speculative
2560        // connections, which resolve hostnames via the OS resolver OUTSIDE the
2561        // proxy (leaking the visited/linked domains. Both schemes are affected).
2562        for url in ["http://5.6.7.8:9000", "socks5://h:1080"] {
2563            let prefs = proxy_prefs(&ProxyConfig::from_url(url).unwrap());
2564            assert!(
2565                prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#),
2566                "{url}: must disable DNS prefetch (a `dns-prefetch` link leaks a clear-text query)"
2567            );
2568            assert!(
2569                prefs.contains(r#"user_pref("network.dns.disablePrefetchFromHTTPS", true);"#),
2570                "{url}: must disable DNS prefetch from HTTPS origins too"
2571            );
2572            assert!(
2573                prefs.contains(r#"user_pref("network.predictor.enabled", false);"#),
2574                "{url}: must disable the network predictor (history-driven pre-resolution)"
2575            );
2576            assert!(
2577                prefs.contains(r#"user_pref("network.http.speculative-parallel-limit", 0);"#),
2578                "{url}: must stop speculative parallel connections"
2579            );
2580            assert!(
2581                prefs.contains(r#"user_pref("browser.urlbar.speculativeConnect.enabled", false);"#),
2582                "{url}: must stop urlbar speculative connect"
2583            );
2584        }
2585    }
2586
2587    #[test]
2588    fn proxy_prefs_socks_keeps_remote_dns_alongside_the_leak_guards() {
2589        // Regression fence: the SOCKS resolver-through-proxy pref must survive
2590        // next to the new prefetch/predictor guards (defense in depth, remote
2591        // DNS routes navigated lookups, the guards kill the out-of-band ones).
2592        let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2593        assert!(prefs.contains(r#"user_pref("network.proxy.socks_remote_dns", true);"#));
2594        assert!(prefs.contains(r#"user_pref("network.dns.disablePrefetch", true);"#));
2595    }
2596
2597    // ─── ScrollDirection ───
2598
2599    #[test]
2600    fn scroll_direction_up_not_eq_down() {
2601        assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2602    }
2603
2604    #[test]
2605    fn scroll_direction_clone_copy() {
2606        let a = ScrollDirection::Up;
2607        let b = a;
2608        assert_eq!(a, b); // copy, not move
2609    }
2610
2611    #[test]
2612    fn scroll_direction_debug() {
2613        let s = format!("{:?}", ScrollDirection::Down);
2614        assert!(s.contains("Down"));
2615    }
2616}