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