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