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::browsing_context::commands::HandleUserPrompt;
14use rustenium_bidi_definitions::browsing_context::types::{CssLocator, CssLocatorType, Locator};
15use rustenium_bidi_definitions::network::types::{BytesValue, SameSite, StringValue, StringValueType};
16use rustenium_bidi_definitions::input::commands::SetFiles;
17use rustenium_bidi_definitions::script::types::{ContextTarget, RemoteValue, SharedReference, Target};
18use rustenium_bidi_definitions::session::types::{UnhandledPromptBehavior, UserPromptHandlerType};
19use rustenium_bidi_definitions::storage::commands::{GetCookies, SetCookie, SetCookieParams};
20use rustenium_bidi_definitions::storage::types::PartialCookie;
21use serde::de::DeserializeOwned;
22use std::collections::HashSet;
23
24/// Wrapper around rustenium's `FirefoxBrowser`.
25pub struct Page {
26    browser: tokio::sync::Mutex<Option<FoxBrowser>>,
27    profile_dir: Option<String>,
28    /// Child process when foxdriver spawned the browser itself (the
29    /// [`launch_firefox_self_managed`] / Remote-attach path). In the normal
30    /// `SpawnAndAttach` path rustenium owns the process (`kill_on_drop`), so
31    /// this is `None`; when foxdriver owns the spawn it must kill it here.
32    child: std::sync::Mutex<Option<std::process::Child>>,
33}
34
35impl Drop for Page {
36    fn drop(&mut self) {
37        // Best-effort synchronous cleanup: take the browser out of the
38        // mutex and drop it.  The underlying `Process` is spawned with
39        // `kill_on_drop(true)`, so dropping kills the Firefox process.
40        if let Ok(mut guard) = self.browser.try_lock() {
41            let _ = guard.take();
42        }
43        // A self-managed child (Remote-attach path) is not owned by rustenium —
44        // kill it explicitly so a self-spawned reynard/Camoufox never leaks.
45        if let Ok(mut child) = self.child.try_lock() {
46            if let Some(mut c) = child.take() {
47                let _ = c.kill();
48            }
49        }
50    }
51}
52
53/// Opaque handle to a browsing context (tab or iframe).
54pub type FrameId = rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
55
56/// A browsing context (frame) with the metadata the agent needs to target it:
57/// the opaque `id` to pass back on a frame-scoped command, plus its `url` and
58/// `name` for disambiguation. Returned by [`Page::list_frames`].
59#[derive(Debug, Clone, PartialEq, serde::Serialize)]
60pub struct FrameInfo {
61    /// Opaque browsing-context id — pass this back as the `frame` target.
62    pub id: String,
63    /// The frame's current document URL (`about:blank` for a fresh frame).
64    pub url: String,
65    /// The frame's `window.name`, empty when unset.
66    pub name: String,
67    /// `true` for the top-level document, `false` for an iframe.
68    pub is_main: bool,
69}
70
71/// A parsed frame target — the pure classification of a `frame=` spec, factored
72/// out of [`Page::resolve_frame`] so the parsing rules are unit-testable without
73/// a live browser.
74#[derive(Debug, Clone, PartialEq)]
75enum FrameSpec {
76    /// The top-level document (`""`, `main`, `top`).
77    Main,
78    /// Strictly a 0-based index into the frame list (`index:<n>`).
79    Index(usize),
80    /// A bare all-digit spec: Firefox BiDi context ids are ALSO all-digits
81    /// (e.g. `10737418241`), so this is ambiguous — resolve as an exact id
82    /// FIRST, then fall back to the index. `0` carries the parsed index.
83    IdOrIndex(String, usize),
84    /// Exact browsing-context id, with a URL-substring fallback.
85    Id(String),
86    /// First frame whose URL contains this substring (`url:<substr>`).
87    UrlContains(String),
88    /// First frame whose `window.name` equals this (`name:<name>`).
89    NameEquals(String),
90}
91
92impl FrameSpec {
93    fn parse(spec: &str) -> Self {
94        let s = spec.trim();
95        if s.is_empty() || s.eq_ignore_ascii_case("main") || s.eq_ignore_ascii_case("top") {
96            return FrameSpec::Main;
97        }
98        if let Some(rest) = s.strip_prefix("url:") {
99            return FrameSpec::UrlContains(rest.trim().to_string());
100        }
101        if let Some(rest) = s.strip_prefix("name:") {
102            return FrameSpec::NameEquals(rest.trim().to_string());
103        }
104        if let Some(rest) = s.strip_prefix("index:") {
105            if let Ok(n) = rest.trim().parse::<usize>() {
106                return FrameSpec::Index(n);
107            }
108        }
109        // A bare integer is ambiguous: a small one is probably a list index, but
110        // a Firefox BiDi context id is also a (large) all-digit string. Try the
111        // exact id first, then the index — so echoing a numeric list_frames id
112        // back works, and `2` still means "the third frame".
113        if let Ok(n) = s.parse::<usize>() {
114            return FrameSpec::IdOrIndex(s.to_string(), n);
115        }
116        FrameSpec::Id(s.to_string())
117    }
118}
119
120/// Result of evaluating JavaScript in the page.
121#[derive(Debug, Clone)]
122pub struct EvaluationResult {
123    inner: RemoteValue,
124}
125
126impl EvaluationResult {
127    pub fn new(inner: RemoteValue) -> Self {
128        Self { inner }
129    }
130
131    /// Attempt to deserialize the evaluation result into `T`.
132    pub fn into_value<T: DeserializeOwned>(self) -> serde_json::Result<T> {
133        let json = remote_value_to_json(&self.inner);
134        serde_json::from_value(json)
135    }
136
137    /// Raw BiDi remote value.
138    pub fn remote_value(&self) -> &RemoteValue {
139        &self.inner
140    }
141}
142
143/// Convert a raw BiDi wire-format `serde_json::Value` into a plain JSON value.
144fn bidi_wire_value_to_json(v: &serde_json::Value) -> serde_json::Value {
145    match v.get("type").and_then(|t| t.as_str()) {
146        Some("string") => v
147            .get("value")
148            .and_then(|v| v.as_str())
149            .map(|s| serde_json::Value::String(s.to_string()))
150            .unwrap_or(serde_json::Value::Null),
151        Some("number") => v.get("value").cloned().unwrap_or(serde_json::Value::Null),
152        Some("boolean") => v
153            .get("value")
154            .and_then(|v| v.as_bool())
155            .map(serde_json::Value::Bool)
156            .unwrap_or(serde_json::Value::Null),
157        Some("null") | Some("undefined") => serde_json::Value::Null,
158        Some("bigint") => v
159            .get("value")
160            .and_then(|v| v.as_str())
161            .map(|s| serde_json::Value::String(s.to_string()))
162            .unwrap_or(serde_json::Value::Null),
163        Some("object") => {
164            let mut map = serde_json::Map::new();
165            if let Some(serde_json::Value::Array(pairs)) = v.get("value") {
166                for pair in pairs {
167                    if let Some(serde_json::Value::Array(items)) = Some(pair) {
168                        if items.len() >= 2 {
169                            if let (Some(k), Some(val)) =
170                                (items[0].as_str(), items.get(1))
171                            {
172                                map.insert(k.to_string(), bidi_wire_value_to_json(val));
173                            }
174                        }
175                    }
176                }
177            }
178            serde_json::Value::Object(map)
179        }
180        Some("array") => {
181            let arr: Vec<serde_json::Value> = v
182                .get("value")
183                .and_then(|v| v.as_array())
184                .map(|a| a.iter().map(bidi_wire_value_to_json).collect())
185                .unwrap_or_default();
186            serde_json::Value::Array(arr)
187        }
188        _ => v.clone(),
189    }
190}
191
192/// Convert a BiDi `RemoteValue` into a plain `serde_json::Value`.
193fn remote_value_to_json(rv: &RemoteValue) -> serde_json::Value {
194    match rv {
195        RemoteValue::PrimitiveProtocolValue(p) => match p {
196            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::StringValue(s) => {
197                serde_json::Value::String(s.value.clone())
198            }
199            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NumberValue(n) => {
200                match &n.value {
201                    serde_json::Value::Number(num) => serde_json::Value::Number(num.clone()),
202                    _ => serde_json::Value::Null,
203                }
204            }
205            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BooleanValue(b) => {
206                serde_json::Value::Bool(b.value)
207            }
208            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::NullValue(_) => {
209                serde_json::Value::Null
210            }
211            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::UndefinedValue(_) => {
212                serde_json::Value::Null
213            }
214            rustenium_bidi_definitions::script::types::PrimitiveProtocolValue::BigIntValue(b) => {
215                serde_json::Value::String(b.value.clone())
216            }
217        },
218        RemoteValue::ArrayRemoteValue(a) => {
219            let arr: Vec<serde_json::Value> = a
220                .value
221                .as_ref()
222                .map(|v| v.inner().iter().map(remote_value_to_json).collect())
223                .unwrap_or_default();
224            serde_json::Value::Array(arr)
225        }
226        RemoteValue::ObjectRemoteValue(o) => {
227            let mut map = serde_json::Map::new();
228            if let Some(ref mapping) = o.value {
229                for pair in mapping.inner() {
230                    if pair.len() >= 2 {
231                        if let (Some(serde_json::Value::String(k)), Some(v)) =
232                            (pair.first(), pair.get(1))
233                        {
234                            map.insert(k.clone(), bidi_wire_value_to_json(v));
235                        }
236                    }
237                }
238            }
239            serde_json::Value::Object(map)
240        }
241        _ => serde_json::Value::Null,
242    }
243}
244
245/// DOM element handle.
246pub struct Element {
247    pub(crate) node: tokio::sync::Mutex<FoxNode>,
248    pub(crate) selector: String,
249}
250
251impl Element {
252    /// Click the element using BiDi pointer actions.
253    pub async fn click(&self) -> Result<()> {
254        let mut node = self.node.lock().await;
255        node.mouse_click()
256            .await
257            .map_err(|e| anyhow!("element click failed: {e:?}"))?;
258        Ok(())
259    }
260
261    /// Return the CSS selector used to locate this element.
262    pub fn selector(&self) -> &str {
263        &self.selector
264    }
265
266    /// Type text into this element.
267    pub async fn type_text(&self, text: &str) -> Result<()> {
268        let mut node = self.node.lock().await;
269        node.type_text(text.to_string())
270            .await
271            .map_err(|e| anyhow!("element type_text failed: {e:?}"))?;
272        Ok(())
273    }
274
275    /// Alias for [`type_text`].
276    pub async fn type_str(&self, text: &str) -> Result<()> {
277        self.type_text(text).await
278    }
279}
280
281// Internal aliases.
282type FoxBrowser = FirefoxBrowser;
283type FoxNode = rustenium::nodes::FirefoxNode<rustenium_core::transport::WebsocketConnectionTransport>;
284
285/// Direction for realistic scroll simulation.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum ScrollDirection {
288    Up,
289    Down,
290}
291
292impl Page {
293    /// Launch a new Firefox instance and return its first page.
294    pub async fn launch(config: Option<FoxBrowserConfig>) -> Result<Self> {
295        launch_firefox(config.unwrap_or_default()).await
296    }
297
298    /// Navigate the active browsing context to `url`.
299    pub async fn goto(&self, url: &str) -> Result<()> {
300        let mut browser = self.browser.lock().await;
301        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
302        browser
303            .navigate(url)
304            .await
305            .map_err(|e| anyhow!("navigate failed: {e:?}"))?;
306        Ok(())
307    }
308
309    /// Evaluate a JavaScript expression in the active context.
310    pub async fn evaluate(&self, expr: impl Into<String>) -> Result<EvaluationResult> {
311        let mut browser = self.browser.lock().await;
312        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
313        let result = browser
314            .evaluate_script(expr.into(), false)
315            .await
316            .map_err(|e| anyhow!("evaluate failed: {e:?}"))?;
317        Ok(EvaluationResult::new(result.result))
318    }
319
320    /// Evaluate in a specific browsing context (frame).
321    pub async fn evaluate_in_context(
322        &self,
323        expr: impl Into<String>,
324        context: &FrameId,
325    ) -> Result<EvaluationResult> {
326        let mut browser = self.browser.lock().await;
327        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
328        let options = EvaluateScriptOptionsBuilder::default()
329            .target(Target::ContextTarget(ContextTarget::new(context.clone())))
330            .build();
331        let result = browser
332            .evaluate_script_with_options(expr.into(), false, options)
333            .await
334            .map_err(|e| anyhow!("evaluate_in_context failed: {e:?}"))?;
335        Ok(EvaluationResult::new(result.result))
336    }
337
338    /// Find the first element matching `selector`.
339    pub async fn find_element(&self, selector: &str) -> Result<Element> {
340        let mut browser = self.browser.lock().await;
341        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
342        let locator = Locator::CssLocator(CssLocator::new(
343            CssLocatorType::Css,
344            selector.to_string(),
345        ));
346        match browser.find_node(locator).await {
347            Ok(Some(node)) => {
348                Ok(Element {
349                    node: tokio::sync::Mutex::new(node),
350                    selector: selector.to_string(),
351                })
352            }
353            Ok(None) => Err(anyhow!("find_element: no element matched '{}'", selector)),
354            Err(e) => Err(anyhow!("find_element failed: {e:?}")),
355        }
356    }
357
358    /// Find all elements matching `selector`.
359    pub async fn find_elements(&self, selector: &str) -> Result<Vec<Element>> {
360        let mut browser = self.browser.lock().await;
361        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
362        let locator = Locator::CssLocator(CssLocator::new(
363            CssLocatorType::Css,
364            selector.to_string(),
365        ));
366        let nodes = browser
367            .find_nodes(locator)
368            .await
369            .map_err(|e| anyhow!("find_elements failed: {e:?}"))?;
370        Ok(nodes
371            .into_iter()
372            .map(|n| Element {
373                node: tokio::sync::Mutex::new(n),
374                selector: selector.to_string(),
375            })
376            .collect())
377    }
378
379    /// Set the file(s) on a `<input type=file>` element via BiDi `input.setFiles`.
380    ///
381    /// This is the trusted file-upload primitive: it attaches real local files to
382    /// the input the same way a human's file picker does (no synthetic events), so
383    /// the entire file-upload attack surface — path-traversal filenames,
384    /// content-type bypass, SVG/XML XXE, RCE-via-upload, SSRF — becomes testable.
385    /// `selector` must resolve to the file input; `files` are absolute local paths.
386    pub async fn set_files(&self, selector: &str, files: Vec<String>) -> Result<()> {
387        if files.is_empty() {
388            return Err(anyhow!("set_files: no files provided"));
389        }
390        // Resolve the input element to its shared node reference + owning context
391        // (releases the browser lock before we re-acquire it for the command). Using
392        // the node's own context means a file input inside an iframe works too.
393        let element = self.find_element(selector).await?;
394        let (shared_id, context) = {
395            let node = element.node.lock().await;
396            let id = node.get_shared_id().cloned().ok_or_else(|| {
397                anyhow!("set_files: '{selector}' is not a resolvable element (no shared id)")
398            })?;
399            (id, node.get_context_id().clone())
400        };
401        let element_ref: SharedReference = SharedReference::builder()
402            .shared_id(shared_id)
403            .build()
404            .map_err(|e| anyhow!("set_files: build shared reference: {e}"))?;
405        let command = SetFiles::builder()
406            .context(context)
407            .element(element_ref)
408            .files(files)
409            .build()
410            .map_err(|e| anyhow!("set_files: build command: {e}"))?;
411        let mut browser = self.browser.lock().await;
412        let browser = match &mut *browser {
413            Some(b) => b,
414            None => return Err(anyhow!("browser closed")),
415        };
416        let response = browser
417            .driver_mut()
418            .send_command(command)
419            .await
420            .map_err(|e| anyhow!("set_files BiDi command failed: {e:?}"))?;
421        let _result: rustenium_bidi_definitions::input::results::SetFilesResult = response
422            .result
423            .try_into()
424            .map_err(|e| anyhow!("set_files result parse failed: {e}"))?;
425        Ok(())
426    }
427
428    /// Capture a viewport screenshot and return raw PNG bytes.
429    pub async fn screenshot(&self) -> Result<Vec<u8>> {
430        let mut browser = self.browser.lock().await;
431        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
432        let b64 = browser
433            .screenshot()
434            .await
435            .map_err(|e| anyhow!("screenshot failed: {e:?}"))?;
436        base64::engine::general_purpose::STANDARD
437            .decode(b64)
438            .map_err(|e| anyhow!("base64 decode failed: {e}"))
439    }
440
441    /// Reload the active context.
442    pub async fn reload(&self) -> Result<()> {
443        let mut browser = self.browser.lock().await;
444        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
445        browser
446            .evaluate_script("location.reload()".to_string(), false)
447            .await
448            .map_err(|e| anyhow!("reload failed: {e:?}"))?;
449        Ok(())
450    }
451
452    /// Current URL of the active context.
453    pub async fn url(&self) -> Result<String> {
454        let eval = self.evaluate("document.URL").await?;
455        eval.into_value::<String>()
456            .map_err(|e| anyhow!("url deserialize failed: {e}"))
457    }
458
459    /// Document title of the active context.
460    pub async fn title(&self) -> Result<String> {
461        let eval = self.evaluate("document.title").await?;
462        eval.into_value::<String>()
463            .map_err(|e| anyhow!("title deserialize failed: {e}"))
464    }
465
466    /// List all browsing-context IDs (main page + every iframe).
467    pub async fn frames(&self) -> Result<Vec<FrameId>> {
468        let browser = self.browser.lock().await;
469        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
470        let contexts = browser
471            .driver()
472            .browsing_contexts
473            .lock()
474            .unwrap_or_else(|e| e.into_inner())
475            .iter()
476            .map(|c| c.id().clone())
477            .collect();
478        Ok(contexts)
479    }
480
481    /// Return the active (main) browsing context.
482    pub async fn mainframe(&self) -> Result<Option<FrameId>> {
483        let browser = self.browser.lock().await;
484        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
485        match browser.driver().get_active_context_id() {
486            Ok(ctx) => Ok(Some(ctx)),
487            Err(e) => {
488                tracing::debug!("get_active_context_id failed: {e:?}");
489                Ok(None)
490            }
491        }
492    }
493
494    /// Verify a browsing context still exists.
495    pub async fn frame_execution_context(
496        &self,
497        frame_id: FrameId,
498    ) -> Result<Option<FrameId>> {
499        let browser = self.browser.lock().await;
500        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
501        let exists = browser
502            .driver()
503            .browsing_contexts
504            .lock()
505            .unwrap_or_else(|e| e.into_inner())
506            .iter()
507            .any(|c| c.id() == &frame_id);
508        Ok(if exists { Some(frame_id) } else { None })
509    }
510
511    /// List every browsing context (main document + all iframes) with the
512    /// metadata an agent needs to target one: opaque `id`, current `url`,
513    /// `window.name`, and whether it is the main frame.
514    ///
515    /// This is the discovery primitive for cross-origin iframe interaction —
516    /// embedded apps, OAuth/payment widgets, postMessage surfaces, captcha tiles.
517    /// Pass a returned `id` back as the `frame` target to
518    /// [`Page::eval_in_frame`] / [`Page::click_in_frame`] /
519    /// [`Page::type_in_frame`].
520    pub async fn list_frames(&self) -> Result<Vec<FrameInfo>> {
521        let frame_ids = self.frames().await?;
522        let main = self.mainframe().await?;
523        let mut out = Vec::with_capacity(frame_ids.len());
524        for fid in frame_ids {
525            // Read url + name from inside the frame's own context so a
526            // cross-origin iframe (where parent JS would throw SecurityError)
527            // still reports correctly. A frame that vanished mid-walk is skipped.
528            let (url, name) = match self
529                .evaluate_in_context(
530                    "({u: document.URL, n: (window.name || \"\")})",
531                    &fid,
532                )
533                .await
534            {
535                Ok(eval) => match eval.into_value::<serde_json::Value>() {
536                    Ok(v) => (
537                        v["u"].as_str().unwrap_or("").to_string(),
538                        v["n"].as_str().unwrap_or("").to_string(),
539                    ),
540                    Err(_) => (String::new(), String::new()),
541                },
542                Err(e) => {
543                    tracing::debug!("frame {:?} unreadable during list_frames: {}", fid, e);
544                    (String::new(), String::new())
545                }
546            };
547            out.push(FrameInfo {
548                is_main: Some(&fid) == main.as_ref(),
549                id: fid.inner().to_string(),
550                url,
551                name,
552            });
553        }
554        Ok(out)
555    }
556
557    /// Resolve a frame target spec to a concrete [`FrameId`], polling briefly so
558    /// an iframe that attaches asynchronously (captcha widgets, lazy embeds,
559    /// post-navigation frames) is found rather than racing to a "no such frame".
560    ///
561    /// Accepts every shape an agent naturally has on hand — so it never has to
562    /// call `list_frames` first:
563    /// - exact browsing-context id (from [`Page::list_frames`])
564    /// - `index:<n>` or a bare 0-based integer into the frame list
565    /// - `url:<substr>` — first frame whose URL contains the substring
566    /// - `name:<name>` — first frame whose `window.name` equals it
567    /// - any other string — tried as an exact id, then as a URL substring
568    /// - empty / `main` / `top` → the main document
569    pub async fn resolve_frame(&self, spec: &str) -> Result<FrameId> {
570        self.resolve_frame_within(spec, crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT)
571            .await
572    }
573
574    /// [`Page::resolve_frame`] with an explicit overall timeout for the attach
575    /// poll. `timeout` of zero means a single attempt.
576    pub async fn resolve_frame_within(
577        &self,
578        spec: &str,
579        timeout: std::time::Duration,
580    ) -> Result<FrameId> {
581        let parsed = FrameSpec::parse(spec);
582        let deadline = std::time::Instant::now() + timeout;
583        loop {
584            if let Some(fid) = self.try_resolve_frame(&parsed).await? {
585                return Ok(fid);
586            }
587            if std::time::Instant::now() >= deadline {
588                return Err(anyhow!(
589                    "resolve_frame: no frame matches '{spec}' (use a list_frames id, index:<n>, url:<substr>, or name:<name>)"
590                ));
591            }
592            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
593        }
594    }
595
596    /// One non-retrying resolution attempt. `Ok(None)` means "not found yet"
597    /// (caller may retry); `Err` is a hard failure (browser closed, bad index).
598    async fn try_resolve_frame(&self, parsed: &FrameSpec) -> Result<Option<FrameId>> {
599        if matches!(parsed, FrameSpec::Main) {
600            return Ok(self.mainframe().await?);
601        }
602        let frames = self.frames().await?;
603        match parsed {
604            FrameSpec::Main => unreachable!(),
605            FrameSpec::Index(idx) => Ok(frames.get(*idx).cloned()),
606            FrameSpec::IdOrIndex(id, idx) => {
607                // Exact (numeric) id first; then the list index.
608                if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
609                    return Ok(Some(fid.clone()));
610                }
611                Ok(frames.get(*idx).cloned())
612            }
613            FrameSpec::Id(id) => {
614                if let Some(fid) = frames.iter().find(|f| f.inner() == id) {
615                    return Ok(Some(fid.clone()));
616                }
617                // Fall back to a URL-substring match so a bare iframe URL works
618                // without the explicit `url:` prefix.
619                self.frame_by_url_contains(id).await
620            }
621            FrameSpec::UrlContains(sub) => self.frame_by_url_contains(sub).await,
622            FrameSpec::NameEquals(name) => {
623                for info in self.list_frames().await? {
624                    if &info.name == name {
625                        return Ok(Some(FrameId::new(info.id)));
626                    }
627                }
628                Ok(None)
629            }
630        }
631    }
632
633    /// First frame whose current URL contains `sub`. Main frame included so
634    /// `url:` can also target the top document.
635    async fn frame_by_url_contains(&self, sub: &str) -> Result<Option<FrameId>> {
636        for info in self.list_frames().await? {
637            if info.url.contains(sub) {
638                return Ok(Some(FrameId::new(info.id)));
639            }
640        }
641        Ok(None)
642    }
643
644    /// Evaluate `expr` inside the frame named by `spec` (id, index, or
645    /// main/top). Full read/write JS runs in that frame's own context, so the
646    /// agent can read or mutate a cross-origin iframe's DOM, drive postMessage,
647    /// or land a DOM-XSS PoC inside an embedded document.
648    pub async fn eval_in_frame(
649        &self,
650        spec: &str,
651        expr: impl Into<String>,
652    ) -> Result<EvaluationResult> {
653        let fid = self.resolve_frame(spec).await?;
654        self.evaluate_in_context(expr, &fid).await
655    }
656
657    /// TRUSTED click on `selector` inside the frame named by `spec`.
658    ///
659    /// Resolves the element's centre in the frame's own viewport, then dispatches
660    /// a real BiDi pointer event in that context via [`Page::click_at_in`] — so
661    /// `event.isTrusted` is `true` even for a cross-origin iframe. Returns an
662    /// error if the selector matches nothing visible in the frame.
663    pub async fn click_in_frame(&self, spec: &str, selector: &str) -> Result<()> {
664        let fid = self.resolve_frame(spec).await?;
665        let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
666        let js = format!(
667            r#"(function() {{
668                const el = document.querySelector('{escaped}');
669                if (!el) return null;
670                const r = el.getBoundingClientRect();
671                if (r.width <= 0 || r.height <= 0) return null;
672                return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
673            }})()"#
674        );
675        // Poll for the element's visible rect — it may render a beat after the
676        // frame attaches (lazy widgets, post-XHR content).
677        let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
678        loop {
679            if let Ok(eval) = self.evaluate_in_context(&js, &fid).await {
680                if let Ok(val) = eval.into_value::<serde_json::Value>() {
681                    if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
682                        return self.click_at_in(&fid, x, y).await;
683                    }
684                }
685            }
686            if std::time::Instant::now() >= deadline {
687                return Err(anyhow!(
688                    "click_in_frame: '{selector}' not found or not visible in frame '{spec}'"
689                ));
690            }
691            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
692        }
693    }
694
695    /// Focus `selector` inside the frame named by `spec` and type `text` into it
696    /// with human-like timing. The keystrokes are dispatched in the frame's own
697    /// context so they land in the cross-origin iframe's focused element.
698    pub async fn type_in_frame(&self, spec: &str, selector: &str, text: &str) -> Result<()> {
699        let fid = self.resolve_frame(spec).await?;
700        let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
701        let focus_js = format!(
702            r#"(function() {{
703                const el = document.querySelector('{escaped}');
704                if (!el) return false;
705                el.focus();
706                return document.activeElement === el;
707            }})()"#
708        );
709        // Poll for the field to exist + accept focus before typing.
710        let deadline = std::time::Instant::now() + crate::frame::DEFAULT_FRAME_RETRY_TIMEOUT;
711        loop {
712            let focused = self
713                .evaluate_in_context(&focus_js, &fid)
714                .await
715                .ok()
716                .and_then(|e| e.into_value::<bool>().ok())
717                .unwrap_or(false);
718            if focused {
719                break;
720            }
721            if std::time::Instant::now() >= deadline {
722                return Err(anyhow!(
723                    "type_in_frame: could not focus '{selector}' in frame '{spec}'"
724                ));
725            }
726            tokio::time::sleep(crate::frame::DEFAULT_FRAME_RETRY_INTERVAL).await;
727        }
728        let browser = self.browser.lock().await;
729        let browser = match &*browser {
730            Some(b) => b,
731            None => return Err(anyhow!("browser closed")),
732        };
733        browser
734            .keyboard()
735            .type_text(text, &fid, None)
736            .await
737            .map_err(|e| anyhow!("type_in_frame: type failed: {e:?}"))?;
738        Ok(())
739    }
740
741    // ------------------------------------------------------------------
742    // Dialogs (alert / confirm / prompt / beforeunload) + downloads
743    // ------------------------------------------------------------------
744
745    /// Start capturing JS dialogs and page-initiated downloads via BiDi
746    /// `browsingContext.*` events. Returns a [`crate::dialog::DialogLog`] handle
747    /// (cheap to clone) that accumulates events for the life of the page.
748    ///
749    /// This is how the agent confirms alert-based XSS (the `alert()` message is
750    /// recorded even when the prompt auto-handles, so there is no hang), reads
751    /// `confirm`/`prompt` text, and inspects downloads. Pair with
752    /// [`Page::handle_user_prompt`] to answer a prompt left open by the `ignore`
753    /// handler. Mirrors [`Page::start_network_log`].
754    pub async fn start_dialog_log(&self) -> Result<crate::dialog::DialogLog> {
755        let mut browser = self.browser.lock().await;
756        let browser = match &mut *browser {
757            Some(b) => b,
758            None => return Err(anyhow!("browser closed")),
759        };
760        let log = crate::dialog::DialogLog::new();
761        let handler = crate::dialog::make_dialog_handler(log.clone());
762        let events: HashSet<&str> = crate::dialog::DIALOG_EVENTS.iter().copied().collect();
763        browser
764            .subscribe_events(events, handler)
765            .await
766            .map_err(|e| anyhow!("failed to subscribe to dialog/download events: {e:?}"))?;
767        Ok(log)
768    }
769
770    // ------------------------------------------------------------------
771    // Sensor grid (the "Omniscient Page")
772    // ------------------------------------------------------------------
773
774    /// Install the passive instrumentation grid (see [`crate::sensors`]) so the
775    /// page reports DOM-XSS sink writes, console output, uncaught errors, CSP
776    /// violations, and inbound postMessage on its own.
777    ///
778    /// Injected twice: as a preload (runs in the MAIN world before page scripts
779    /// on every future navigation) AND evaluated once on the current document so
780    /// a page already loaded at launch is covered. The script is idempotent, so
781    /// the double-install is safe. Read what it captured with
782    /// [`Page::read_signals`]. Mirrors [`Page::start_network_log`].
783    pub async fn start_sensors(&self) -> Result<String> {
784        let id = self.add_preload_script(crate::sensors::SENSOR_SCRIPT).await?;
785        // Best-effort cover the already-loaded document; a fresh tab on
786        // about:blank may not accept eval yet, which is fine — the preload will
787        // fire on the first real navigation.
788        let _ = self.evaluate(crate::sensors::SENSOR_SCRIPT).await;
789        Ok(id)
790    }
791
792    /// Read the captured signal buffer. With `clear` true the buffer is emptied
793    /// after the snapshot so the next read returns only NEW signals (deltas) —
794    /// the basis for "what did my last action trigger?" telemetry.
795    pub async fn read_signals(&self, clear: bool) -> Result<serde_json::Value> {
796        let eval = self.evaluate(crate::sensors::sensor_reader(clear)).await?;
797        eval.into_value::<serde_json::Value>()
798            .map_err(|e| anyhow!("read_signals: decode failed: {e}"))
799    }
800
801    /// Answer an open JS user prompt in `context` (or the active frame when
802    /// `None`): `accept` true clicks OK / accepts `beforeunload`; `user_text`
803    /// fills a `prompt()` box before accepting. Only effective when the page was
804    /// launched with the `ignore` prompt handler (otherwise Firefox auto-handles
805    /// the prompt before this runs). Mirrors the [`Page::set_files`] command path.
806    pub async fn handle_user_prompt(
807        &self,
808        context: Option<&FrameId>,
809        accept: bool,
810        user_text: Option<&str>,
811    ) -> Result<()> {
812        let ctx = match context {
813            Some(c) => c.clone(),
814            None => self
815                .mainframe()
816                .await?
817                .ok_or_else(|| anyhow!("handle_user_prompt: no active browsing context"))?,
818        };
819        let mut builder = HandleUserPrompt::builder().context(ctx).accept(accept);
820        if let Some(text) = user_text {
821            builder = builder.user_text(text.to_string());
822        }
823        let command = builder
824            .build()
825            .map_err(|e| anyhow!("handle_user_prompt: build command: {e}"))?;
826        let mut browser = self.browser.lock().await;
827        let browser = match &mut *browser {
828            Some(b) => b,
829            None => return Err(anyhow!("browser closed")),
830        };
831        let response = browser
832            .driver_mut()
833            .send_command(command)
834            .await
835            .map_err(|e| anyhow!("handle_user_prompt BiDi command failed: {e:?}"))?;
836        let _result: rustenium_bidi_definitions::browsing_context::results::HandleUserPromptResult =
837            response
838                .result
839                .try_into()
840                .map_err(|e| anyhow!("handle_user_prompt result parse failed: {e}"))?;
841        Ok(())
842    }
843
844    // ------------------------------------------------------------------
845    // Input
846    // ------------------------------------------------------------------
847
848    /// Move the mouse from `(x0, y0)` to `(x1, y1)` using human-like curves.
849    pub async fn mouse_move_human(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
850        let browser = self.browser.lock().await;
851        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
852        let context = browser
853            .driver()
854            .get_active_context_id()
855            .map_err(|e| anyhow!("{e:?}"))?;
856        let hm = browser.human_mouse();
857        hm.set_last_position(Point { x: x0, y: y0 });
858        hm.move_to(Point { x: x1, y: y1 }, &context, MouseMoveOptions::default())
859            .await
860            .map_err(|e| anyhow!("mouse_move_human failed: {e:?}"))?;
861        Ok(())
862    }
863
864    /// Mouse-down at `(x, y)` in the active context.
865    pub async fn mouse_down(&self, x: f64, y: f64) -> Result<()> {
866        let browser = self.browser.lock().await;
867        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
868        let context = browser
869            .driver()
870            .get_active_context_id()
871            .map_err(|e| anyhow!("{e:?}"))?;
872        let hm = browser.human_mouse();
873        hm.move_to(Point { x, y }, &context, MouseMoveOptions::default())
874            .await
875            .map_err(|e| anyhow!("mouse_down move failed: {e:?}"))?;
876        hm.down(&context, MouseOptions {
877            button: Some(MouseButton::Left),
878        })
879        .await
880        .map_err(|e| anyhow!("mouse_down failed: {e:?}"))?;
881        Ok(())
882    }
883
884    /// Mouse-up at `(x, y)` in the active context.
885    pub async fn mouse_up(&self, _x: f64, _y: f64) -> Result<()> {
886        let browser = self.browser.lock().await;
887        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
888        let context = browser
889            .driver()
890            .get_active_context_id()
891            .map_err(|e| anyhow!("{e:?}"))?;
892        let hm = browser.human_mouse();
893        hm.up(&context, MouseOptions {
894            button: Some(MouseButton::Left),
895        })
896        .await
897        .map_err(|e| anyhow!("mouse_up failed: {e:?}"))?;
898        Ok(())
899    }
900
901    /// Click at `(x, y)` in the active (top-level) context with realistic
902    /// press/release timing.
903    ///
904    /// NOTE: for a target inside a cross-origin iframe (the production captcha
905    /// case — Turnstile/hCaptcha/reCAPTCHA all render their checkbox in an
906    /// OOPIF), prefer [`Page::click_at_in`] with the iframe's context. A
907    /// pointer action dispatched in the *top* context does not reliably route
908    /// across a Fission process boundary, which is why a top-context viewport
909    /// click on a captcha checkbox silently fails to deliver.
910    pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
911        let context = self
912            .mainframe()
913            .await?
914            .ok_or_else(|| anyhow!("click_at: no active browsing context"))?;
915        self.click_at_in(&context, x, y).await
916    }
917
918    /// Click at `(x, y)` within a SPECIFIC browsing context.
919    ///
920    /// This is the cross-origin-correct click path: BiDi
921    /// `input.performActions` is dispatched in `context`, so the *trusted*
922    /// pointer event is delivered into that frame's content process. For a
923    /// cross-origin iframe checkbox, pass the iframe's [`FrameId`] (from
924    /// [`Page::frames`]) with coordinates in that frame's own viewport space
925    /// (origin at the iframe's top-left). Because the event is real BiDi input
926    /// (not a synthetic JS `MouseEvent`), `event.isTrusted` is `true` — the
927    /// property every modern captcha gates its checkbox on.
928    pub async fn click_at_in(&self, context: &FrameId, x: f64, y: f64) -> Result<()> {
929        let browser = self.browser.lock().await;
930        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
931        let hm = browser.human_mouse();
932        // Seed the cursor origin INSIDE the target context's viewport. The
933        // shared HumanMouse remembers its last position across calls; that
934        // position is in whatever viewport the previous action used (often the
935        // top frame, which is larger than a captcha iframe). Moving from a
936        // stale top-frame coordinate into a small iframe viewport makes Firefox
937        // BiDi reject the action with MoveTargetOutOfBounds. Anchoring at the
938        // target keeps every dispatched coordinate within the iframe's bounds.
939        hm.set_last_position(Point { x, y });
940        let options = MouseClickOptions {
941            button: Some(MouseButton::Left),
942            count: Some(1),
943            delay: Some(80),
944            origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
945        };
946        hm.click(Some(Point { x, y }), context, options)
947            .await
948            .map_err(|e| anyhow!("click_at_in failed: {e:?}"))?;
949        Ok(())
950    }
951
952    /// Move the pointer to an absolute viewport coordinate as a single
953    /// TRUSTED BiDi `input.performActions` PointerMove (no synthetic JS
954    /// `MouseEvent`).
955    ///
956    /// This is the trusted primitive that human-trajectory generators must
957    /// dispatch each interpolated point through. A `document.dispatchEvent(new
958    /// MouseEvent('mousemove', …))` produces `isTrusted === false`, which every
959    /// modern anti-bot scorer flags on sight — so a beautifully shaped but
960    /// JS-dispatched path is worse than useless. Routing each point through
961    /// here makes the whole trajectory trusted and lets it cross into
962    /// cross-origin frames by viewport hit-test.
963    pub async fn move_mouse_to(&self, x: f64, y: f64) -> Result<()> {
964        let browser = self.browser.lock().await;
965        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
966        let context = browser
967            .driver()
968            .get_active_context_id()
969            .map_err(|e| anyhow!("{e:?}"))?;
970        browser
971            .mouse()
972            .move_to(
973                Point { x, y },
974                &context,
975                MouseMoveOptions {
976                    steps: Some(0),
977                    origin: Some(rustenium_bidi_definitions::input::types::Origin::Viewport),
978                },
979            )
980            .await
981            .map_err(|e| anyhow!("move_mouse_to failed: {e:?}"))?;
982        Ok(())
983    }
984
985    /// Scroll the wheel at the current mouse position.
986    pub async fn scroll(&self, dx: i64, dy: i64) -> Result<()> {
987        let browser = self.browser.lock().await;
988        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
989        let context = browser
990            .driver()
991            .get_active_context_id()
992            .map_err(|e| anyhow!("{e:?}"))?;
993        browser
994            .mouse()
995            .wheel(
996                &context,
997                MouseWheelOptions {
998                    delta_x: Some(dx),
999                    delta_y: Some(dy),
1000                },
1001            )
1002            .await
1003            .map_err(|e| anyhow!("scroll failed: {e:?}"))?;
1004        Ok(())
1005    }
1006
1007    /// Human-like scroll (smooth easing with noise).
1008    pub async fn scroll_realistic(&self, direction: ScrollDirection, amount: u32) -> Result<()> {
1009        let browser = self.browser.lock().await;
1010        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1011        let context = browser
1012            .driver()
1013            .get_active_context_id()
1014            .map_err(|e| anyhow!("{e:?}"))?;
1015        let y_distance = match direction {
1016            ScrollDirection::Down => amount as i32,
1017            ScrollDirection::Up => -(amount as i32),
1018        };
1019        browser
1020            .human_mouse()
1021            .scroll(y_distance, 0, &context)
1022            .await
1023            .map_err(|e| anyhow!("scroll_realistic failed: {e:?}"))?;
1024        Ok(())
1025    }
1026
1027    /// Type `text` into the active context with human-like delays.
1028    pub async fn type_text(&self, text: &str) -> Result<()> {
1029        let browser = self.browser.lock().await;
1030        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1031        let context = browser
1032            .driver()
1033            .get_active_context_id()
1034            .map_err(|e| anyhow!("{e:?}"))?;
1035        browser
1036            .keyboard()
1037            .type_text(text, &context, None)
1038            .await
1039            .map_err(|e| anyhow!("type_text failed: {e:?}"))?;
1040        Ok(())
1041    }
1042
1043    /// Press a key down in the active context.
1044    pub async fn key_down(&self, key: &str) -> Result<()> {
1045        let browser = self.browser.lock().await;
1046        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1047        let context = browser
1048            .driver()
1049            .get_active_context_id()
1050            .map_err(|e| anyhow!("{e:?}"))?;
1051        browser
1052            .keyboard()
1053            .down(key, &context)
1054            .await
1055            .map_err(|e| anyhow!("key_down failed: {e:?}"))?;
1056        Ok(())
1057    }
1058
1059    /// Release a key in the active context.
1060    pub async fn key_up(&self, key: &str) -> Result<()> {
1061        let browser = self.browser.lock().await;
1062        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1063        let context = browser
1064            .driver()
1065            .get_active_context_id()
1066            .map_err(|e| anyhow!("{e:?}"))?;
1067        browser
1068            .keyboard()
1069            .up(key, &context)
1070            .await
1071            .map_err(|e| anyhow!("key_up failed: {e:?}"))?;
1072        Ok(())
1073    }
1074
1075    /// Press and release a key in the active context.
1076    pub async fn key_press(&self, key: &str) -> Result<()> {
1077        let browser = self.browser.lock().await;
1078        let browser = match &*browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1079        let context = browser
1080            .driver()
1081            .get_active_context_id()
1082            .map_err(|e| anyhow!("{e:?}"))?;
1083        browser
1084            .keyboard()
1085            .press(key, &context, None)
1086            .await
1087            .map_err(|e| anyhow!("key_press failed: {e:?}"))?;
1088        Ok(())
1089    }
1090
1091    // ------------------------------------------------------------------
1092    // Stealth / scripting
1093    // ------------------------------------------------------------------
1094
1095    /// Inject a preload script that runs in the page's main world before any
1096    /// page script, on every new document.
1097    ///
1098    /// `source` is a SCRIPT BODY (statements), matching CDP's
1099    /// `Page.addScriptToEvaluateOnNewDocument` semantics. WebDriver BiDi's
1100    /// `script.addPreloadScript` instead takes a `functionDeclaration` that it
1101    /// *invokes* as a function — so a bare body, or a self-invoking IIFE like
1102    /// `(() => {…})()` (which evaluates to `undefined`, not a callable), is
1103    /// silently never run, nullifying the script. We therefore wrap the body in
1104    /// an arrow function here so callers can pass a plain body and have it
1105    /// actually execute. This is the single point that made guise's stealth
1106    /// preloads (all written as IIFE bodies) no-ops.
1107    pub async fn add_preload_script(&self, source: &str) -> Result<String> {
1108        let function_declaration = format!("() => {{\n{source}\n}}");
1109        let mut browser = self.browser.lock().await;
1110        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1111        let id = browser
1112            .add_preload_script(function_declaration)
1113            .await
1114            .map_err(|e| anyhow!("add_preload_script failed: {e:?}"))?;
1115        Ok(id)
1116    }
1117
1118    /// Capture all cookies (including HttpOnly) via BiDi `storage.getCookies`.
1119    pub async fn get_cookies(&self) -> Result<Vec<crate::cookies::CapturedCookie>> {
1120        let mut browser = self.browser.lock().await;
1121        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1122        let response = browser
1123            .driver_mut()
1124            .send_command(GetCookies {
1125                method: rustenium_bidi_definitions::storage::commands::GetCookiesMethod::GetCookies,
1126                params: Default::default(),
1127            })
1128            .await
1129            .map_err(|e| anyhow!("get_cookies BiDi command failed: {e:?}"))?;
1130        let result: rustenium_bidi_definitions::storage::results::GetCookiesResult =
1131            response
1132                .result
1133                .try_into()
1134                .map_err(|e| anyhow!("get_cookies result parse failed: {e}"))?;
1135        Ok(result
1136            .cookies
1137            .into_iter()
1138            .map(|c| crate::cookies::CapturedCookie {
1139                name: c.name,
1140                value: match c.value {
1141                    BytesValue::StringValue(s) => s.value,
1142                    BytesValue::Base64Value(b) => b.value,
1143                },
1144                domain: c.domain,
1145                path: c.path,
1146                expires: c.expiry.map(|e| e as i64),
1147                secure: c.secure,
1148                http_only: c.http_only,
1149                same_site: Some(format!("{:?}", c.same_site).to_lowercase()),
1150            })
1151            .collect())
1152    }
1153
1154    /// Set a cookie via BiDi `storage.setCookie`.
1155    pub async fn set_cookie(
1156        &self,
1157        name: &str,
1158        value: &str,
1159        domain: &str,
1160        path: Option<&str>,
1161        expires: Option<u64>,
1162        secure: Option<bool>,
1163        http_only: Option<bool>,
1164        same_site: Option<SameSite>,
1165    ) -> Result<()> {
1166        let mut browser = self.browser.lock().await;
1167        let browser = match &mut *browser { Some(b) => b, None => return Err(anyhow!("browser closed")), };
1168        let cookie = PartialCookie {
1169            name: name.to_string(),
1170            value: BytesValue::StringValue(StringValue::new(
1171                StringValueType::String,
1172                value.to_string(),
1173            )),
1174            domain: domain.to_string(),
1175            path: path.map(|p| p.to_string()),
1176            http_only,
1177            secure,
1178            same_site,
1179            expiry: expires,
1180            extensible: Default::default(),
1181        };
1182        let response = browser
1183            .driver_mut()
1184            .send_command(SetCookie {
1185                method: rustenium_bidi_definitions::storage::commands::SetCookieMethod::SetCookie,
1186                params: SetCookieParams::new(cookie),
1187            })
1188            .await
1189            .map_err(|e| anyhow!("set_cookie BiDi command failed: {e:?}"))?;
1190        let _result: rustenium_bidi_definitions::storage::results::SetCookieResult = response
1191            .result
1192            .try_into()
1193            .map_err(|e| anyhow!("set_cookie result parse failed: {e}"))?;
1194        Ok(())
1195    }
1196
1197    /// Return the Firefox profile directory path, if known.
1198    pub fn profile_dir(&self) -> Option<&str> {
1199        self.profile_dir.as_deref()
1200    }
1201
1202    /// Start capturing all network traffic (requests + responses) via BiDi.
1203    ///
1204    /// Returns a [`crate::network::NetworkLog`] handle that can be queried at
1205    /// any time while the browser is alive.  The log is shared (Clone is cheap)
1206    /// and accumulates events until the page is closed.
1207    ///
1208    /// # Example
1209    /// ```ignore
1210    /// let log = page.start_network_log().await?;
1211    /// page.goto("https://example.com").await?;
1212    /// let entries = log.entries().await;
1213    /// let tokens = log.extract_tokens().await;
1214    /// ```
1215    pub async fn start_network_log(&self) -> Result<crate::network::NetworkLog> {
1216        let mut browser = self.browser.lock().await;
1217        let browser = match &mut *browser {
1218            Some(b) => b,
1219            None => return Err(anyhow!("browser closed")),
1220        };
1221        let log = crate::network::NetworkLog::new();
1222        let handler = crate::network::make_network_handler(log.clone());
1223        let events: HashSet<&str> = [
1224            "network.beforeRequestSent",
1225            "network.responseCompleted",
1226            "network.fetchError",
1227        ]
1228        .into_iter()
1229        .collect();
1230        browser
1231            .subscribe_events(events, handler)
1232            .await
1233            .map_err(|e| anyhow!("failed to subscribe to network events: {e:?}"))?;
1234        Ok(log)
1235    }
1236
1237    /// Close the browser (best-effort, capped at 5 s).
1238    pub async fn close(&self) -> Result<()> {
1239        if let Some(browser) = self.browser.lock().await.take() {
1240            let _ = tokio::time::timeout(std::time::Duration::from_secs(5), browser.close()).await;
1241        }
1242        // Kill a self-managed child (Remote-attach path); rustenium's `close`
1243        // only ends the BiDi session for a process it does not own.
1244        if let Ok(mut child) = self.child.lock() {
1245            if let Some(mut c) = child.take() {
1246                let _ = c.kill();
1247                let _ = c.wait();
1248            }
1249        }
1250        Ok(())
1251    }
1252}
1253
1254// ------------------------------------------------------------------
1255// Browser launch configuration
1256// ------------------------------------------------------------------
1257
1258/// Upstream proxy transport for a launched Firefox.
1259#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1260pub enum ProxyScheme {
1261    /// HTTP/HTTPS proxy (`network.proxy.http` + `ssl`, shared).
1262    #[default]
1263    Http,
1264    /// SOCKS5 proxy (`network.proxy.socks`, remote DNS on).
1265    Socks5,
1266}
1267
1268/// A proxy to route a launched Firefox through. Emitted as `network.proxy.*`
1269/// prefs into the profile `user.js` at launch — the right place, since Firefox
1270/// has no `--proxy-server` flag.
1271///
1272/// IP-whitelisted gateways work fully via prefs. Firefox cannot carry
1273/// **proxy-auth credentials** in prefs (it would prompt), so `username`/
1274/// `password` are plumbed but require a local unauthenticated relay (e.g.
1275/// `proxywire`) in front of the authenticated upstream; [`proxy_prefs`] logs a
1276/// warning rather than silently dropping them.
1277#[derive(Debug, Clone, Default)]
1278pub struct ProxyConfig {
1279    pub scheme: ProxyScheme,
1280    pub host: String,
1281    pub port: u16,
1282    pub username: Option<String>,
1283    pub password: Option<String>,
1284}
1285
1286impl ProxyConfig {
1287    /// Parse `scheme://[user:pass@]host:port`. Scheme defaults to `http`;
1288    /// `socks5`/`socks` selects SOCKS5.
1289    pub fn from_url(url: &str) -> Result<Self> {
1290        let (scheme, rest) = match url.split_once("://") {
1291            Some((s, r)) => (s.to_ascii_lowercase(), r),
1292            None => ("http".to_string(), url),
1293        };
1294        let scheme = match scheme.as_str() {
1295            "socks5" | "socks" | "socks5h" => ProxyScheme::Socks5,
1296            "http" | "https" => ProxyScheme::Http,
1297            other => return Err(anyhow!("unsupported proxy scheme: {other}")),
1298        };
1299        let (auth, hostport) = match rest.rsplit_once('@') {
1300            Some((a, hp)) => (Some(a), hp),
1301            None => (None, rest),
1302        };
1303        let (username, password) = match auth {
1304            Some(a) => match a.split_once(':') {
1305                Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
1306                None => (Some(a.to_string()), None),
1307            },
1308            None => (None, None),
1309        };
1310        let (host, port) = hostport
1311            .rsplit_once(':')
1312            .ok_or_else(|| anyhow!("proxy URL missing host:port: {url}"))?;
1313        let port: u16 = port
1314            .parse()
1315            .map_err(|_| anyhow!("invalid proxy port in {url}"))?;
1316        if host.is_empty() {
1317            return Err(anyhow!("proxy URL missing host: {url}"));
1318        }
1319        Ok(Self {
1320            scheme,
1321            host: host.to_string(),
1322            port,
1323            username,
1324            password,
1325        })
1326    }
1327}
1328
1329/// Build the Firefox `network.proxy.*` `user_pref` lines for `proxy`.
1330pub fn proxy_prefs(proxy: &ProxyConfig) -> String {
1331    if proxy.username.is_some() || proxy.password.is_some() {
1332        tracing::warn!(
1333            "ProxyConfig carries credentials, but Firefox cannot apply proxy auth via prefs; \
1334             front the upstream with a local unauthenticated relay (e.g. proxywire) and point \
1335             foxdriver at that. Emitting host:port prefs only."
1336        );
1337    }
1338    let mut lines = vec![r#"user_pref("network.proxy.type", 1);"#.to_string()];
1339    match proxy.scheme {
1340        ProxyScheme::Http => {
1341            lines.push(format!(
1342                r#"user_pref("network.proxy.http", "{}");"#,
1343                proxy.host
1344            ));
1345            lines.push(format!(
1346                r#"user_pref("network.proxy.http_port", {});"#,
1347                proxy.port
1348            ));
1349            lines.push(format!(r#"user_pref("network.proxy.ssl", "{}");"#, proxy.host));
1350            lines.push(format!(
1351                r#"user_pref("network.proxy.ssl_port", {});"#,
1352                proxy.port
1353            ));
1354            lines.push(r#"user_pref("network.proxy.share_proxy_settings", true);"#.to_string());
1355        }
1356        ProxyScheme::Socks5 => {
1357            lines.push(format!(
1358                r#"user_pref("network.proxy.socks", "{}");"#,
1359                proxy.host
1360            ));
1361            lines.push(format!(
1362                r#"user_pref("network.proxy.socks_port", {});"#,
1363                proxy.port
1364            ));
1365            lines.push(r#"user_pref("network.proxy.socks_version", 5);"#.to_string());
1366            lines.push(r#"user_pref("network.proxy.socks_remote_dns", true);"#.to_string());
1367        }
1368    }
1369    // Do not bypass the proxy for localhost — a residential run must egress
1370    // every request through the upstream, including any IP-echo check.
1371    lines.push(r#"user_pref("network.proxy.no_proxies_on", "");"#.to_string());
1372    lines.push('\n'.to_string());
1373    lines.join("\n")
1374}
1375
1376#[derive(Debug, Clone, Default)]
1377pub struct FoxBrowserConfig {
1378    pub executable_path: Option<String>,
1379    pub profile_dir: Option<String>,
1380    pub headless: bool,
1381    pub viewport_width: u32,
1382    pub viewport_height: u32,
1383    pub user_agent: Option<String>,
1384    /// Raw `user.js` content to write into the profile directory before
1385    /// Firefox starts. The caller (typically `guise`) is responsible for
1386    /// building this string from profile overrides.
1387    pub user_js_content: Option<String>,
1388    /// Optional upstream proxy. Emitted as `network.proxy.*` prefs appended to
1389    /// `user_js_content` at launch (requires `profile_dir`).
1390    pub proxy: Option<ProxyConfig>,
1391    /// How Firefox handles JS user prompts (`alert`/`confirm`/`prompt`/
1392    /// `beforeunload`). One of `accept`, `dismiss`, `ignore`, `dismiss and
1393    /// notify`. `None` keeps the BiDi default (`dismiss and notify`), which
1394    /// never hangs and still emits the events the dialog log records. Set
1395    /// `ignore` to keep prompts OPEN so [`Page::handle_user_prompt`] can answer
1396    /// them; set `accept` to auto-accept (a `confirm()` guard returns true,
1397    /// `beforeunload` never blocks navigation).
1398    pub unhandled_prompt_behavior: Option<String>,
1399}
1400
1401/// Map a prompt-behavior string to the typed BiDi capability value. Returns
1402/// `None` for an unrecognized value so launch falls back to the BiDi default
1403/// rather than failing.
1404fn prompt_behavior_capability(s: &str) -> Option<UnhandledPromptBehavior> {
1405    let handler = match s.trim().to_ascii_lowercase().as_str() {
1406        "accept" | "accept and notify" => UserPromptHandlerType::Accept,
1407        "dismiss" => UserPromptHandlerType::Dismiss,
1408        "ignore" => UserPromptHandlerType::Ignore,
1409        "dismiss and notify" | "dismiss_and_notify" | "notify" => {
1410            UserPromptHandlerType::DismissAndNotify
1411        }
1412        _ => return None,
1413    };
1414    Some(UnhandledPromptBehavior::UserPromptHandlerType(handler))
1415}
1416
1417/// Write `user.js` into the given profile directory.
1418fn write_user_js(profile_dir: &str, content: &str) -> Result<()> {
1419    let dir = std::path::Path::new(profile_dir);
1420    std::fs::create_dir_all(dir)
1421        .map_err(|e| anyhow!("failed to create profile dir {:?}: {}", dir, e))?;
1422    let path = dir.join("user.js");
1423    std::fs::write(&path, content)
1424        .map_err(|e| anyhow!("failed to write user.js to {:?}: {}", path, e))?;
1425    Ok(())
1426}
1427
1428/// Launch Firefox with the given config and return a `Page` handle.
1429pub async fn launch_firefox(config: FoxBrowserConfig) -> Result<Page> {
1430    let mut caps = FirefoxCapabilities::default();
1431    caps.accept_insecure_certs(true);
1432    if let Some(behavior) = config
1433        .unhandled_prompt_behavior
1434        .as_deref()
1435        .and_then(prompt_behavior_capability)
1436    {
1437        caps.unhandled_prompt_behavior(behavior);
1438    }
1439
1440    let mut args = Vec::new();
1441    if config.headless {
1442        args.push("--headless".to_string());
1443    }
1444    if let Some(ref ua) = config.user_agent {
1445        args.push(format!("--user-agent={}", ua));
1446    }
1447    if config.viewport_width > 0 {
1448        args.push(format!("--width={}", config.viewport_width));
1449    }
1450    if config.viewport_height > 0 {
1451        args.push(format!("--height={}", config.viewport_height));
1452    }
1453
1454    // Assemble the final user.js: caller-supplied prefs plus, if a proxy is
1455    // configured, the network.proxy.* lines. Written before launch so prefs are
1456    // live from the first request (a proxied run must NOT leak the real IP on
1457    // the initial navigation).
1458    let mut user_js = config.user_js_content.clone().unwrap_or_default();
1459    if let Some(ref proxy) = config.proxy {
1460        if !user_js.is_empty() && !user_js.ends_with('\n') {
1461            user_js.push('\n');
1462        }
1463        user_js.push_str(&proxy_prefs(proxy));
1464    }
1465    if !user_js.is_empty() {
1466        if let Some(ref profile_dir) = &config.profile_dir {
1467            if let Err(e) = write_user_js(profile_dir, &user_js) {
1468                tracing::warn!("failed to write user.js for profile: {e}");
1469            }
1470        } else {
1471            tracing::warn!("user.js prefs (incl. proxy) ignored because profile_dir is not set");
1472        }
1473    }
1474
1475    let profile_dir = config.profile_dir.clone();
1476    let cfg = FirefoxConfig {
1477        capabilities: caps,
1478        firefox_executable_path: config.executable_path,
1479        profile_dir: config.profile_dir,
1480        browser_flags: Some(args),
1481        ..Default::default()
1482    };
1483
1484    let browser = tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg)))
1485        .await
1486        .map_err(|_| anyhow!("Firefox launch timed out after 30s — check that Firefox is installed and not already running with a locked profile"))?;
1487    Ok(Page {
1488        browser: tokio::sync::Mutex::new(Some(browser)),
1489        profile_dir,
1490        child: std::sync::Mutex::new(None),
1491    })
1492}
1493
1494/// Reserve an ephemeral TCP port by binding `127.0.0.1:0` and reading back the
1495/// OS-assigned port, then releasing it. There is an unavoidable TOCTOU window
1496/// between release and the browser binding it; in practice the browser claims it
1497/// within milliseconds and a collision surfaces as a clean readiness-timeout.
1498fn reserve_local_port() -> Result<u16> {
1499    let listener = std::net::TcpListener::bind("127.0.0.1:0")
1500        .map_err(|e| anyhow!("failed to reserve a local port: {e}"))?;
1501    let port = listener
1502        .local_addr()
1503        .map_err(|e| anyhow!("failed to read reserved port: {e}"))?
1504        .port();
1505    Ok(port)
1506}
1507
1508/// Resolve the Firefox binary: the caller's explicit `executable_path` if set,
1509/// otherwise the first match on `PATH` and then the standard install locations.
1510///
1511/// [`launch_firefox`] gets PATH resolution for free because it hands a possibly-
1512/// `None` path to rustenium, which finds Firefox itself. When foxdriver owns the
1513/// spawn ([`launch_firefox_self_managed`]) it must do the same so the robust
1514/// readiness-poll launcher is a true drop-in — a caller that relies on
1515/// Firefox-on-PATH (e.g. captchaforge's `drive_browser`) can adopt it without
1516/// hard-coding a path.
1517fn resolve_firefox_binary(explicit: Option<String>) -> Result<String> {
1518    if let Some(p) = explicit {
1519        return Ok(p);
1520    }
1521    const NAMES: &[&str] = &["firefox", "firefox-esr", "firefox-bin", "firefox.exe"];
1522    if let Ok(path) = std::env::var("PATH") {
1523        let sep = if cfg!(windows) { ';' } else { ':' };
1524        for dir in path.split(sep).filter(|d| !d.is_empty()) {
1525            for name in NAMES {
1526                let cand = std::path::Path::new(dir).join(name);
1527                if cand.is_file() {
1528                    return Ok(cand.to_string_lossy().into_owned());
1529                }
1530            }
1531        }
1532    }
1533    // Standard locations that are not always on PATH (snap/opt/macOS/Windows).
1534    const FIXED: &[&str] = &[
1535        "/usr/local/bin/firefox",
1536        "/usr/bin/firefox",
1537        "/opt/firefox/firefox",
1538        "/snap/bin/firefox",
1539        "/Applications/Firefox.app/Contents/MacOS/firefox",
1540        "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
1541        "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
1542    ];
1543    for p in FIXED {
1544        if std::path::Path::new(p).is_file() {
1545            return Ok((*p).to_string());
1546        }
1547    }
1548    Err(anyhow!(
1549        "could not find a Firefox binary — set FoxBrowserConfig.executable_path or install Firefox on PATH"
1550    ))
1551}
1552
1553/// Launch Firefox where **foxdriver owns the spawn and the readiness wait**, then
1554/// attaches over BiDi in rustenium `Remote` mode.
1555///
1556/// The default [`launch_firefox`] delegates spawning to rustenium, which sleeps a
1557/// fixed 500 ms after exec before connecting to the BiDi WebSocket. That races any
1558/// build whose remote agent binds slowly — a freshly-built Camoufox/reynard takes
1559/// ~1 s — yielding a `ConnectionRefused` panic. Here foxdriver spawns the process,
1560/// polls the debugging port until it actually accepts a connection (Law-7:
1561/// readiness, never a fixed sleep), and only then hands rustenium an already-live
1562/// port via [`FirefoxLaunchMode::Remote`]. The spawned [`std::process::Child`] is
1563/// owned by the returned [`Page`] and killed on `close`/drop.
1564///
1565/// `config.executable_path` is resolved via [`resolve_firefox_binary`] — the
1566/// explicit path if set, else PATH / standard install locations (this path never
1567/// auto-downloads Firefox).
1568pub async fn launch_firefox_self_managed(config: FoxBrowserConfig) -> Result<Page> {
1569    let exe = resolve_firefox_binary(config.executable_path.clone())?;
1570
1571    let host = "127.0.0.1".to_string();
1572    let port = reserve_local_port()?;
1573
1574    // Profile dir: caller-supplied or a unique temp dir. Written with the same
1575    // user.js (incl. proxy prefs) as the managed path so prefs are live from the
1576    // first request.
1577    let profile_dir = config.profile_dir.clone().unwrap_or_else(|| {
1578        std::env::temp_dir()
1579            .join(format!("foxdriver-self-{}-{}", std::process::id(), port))
1580            .display()
1581            .to_string()
1582    });
1583    std::fs::create_dir_all(&profile_dir)
1584        .map_err(|e| anyhow!("failed to create profile dir {profile_dir:?}: {e}"))?;
1585
1586    let mut user_js = config.user_js_content.clone().unwrap_or_default();
1587    if let Some(ref proxy) = config.proxy {
1588        if !user_js.is_empty() && !user_js.ends_with('\n') {
1589            user_js.push('\n');
1590        }
1591        user_js.push_str(&proxy_prefs(proxy));
1592    }
1593    if !user_js.is_empty() {
1594        write_user_js(&profile_dir, &user_js)?;
1595    }
1596
1597    // Assemble args. `--no-remote` + the explicit debugging port mirror what
1598    // rustenium would pass in SpawnAndAttach; the rest come from the viewport /
1599    // headless / UA config.
1600    let mut args = vec![
1601        format!("--remote-debugging-port={port}"),
1602        "--profile".to_string(),
1603        profile_dir.clone(),
1604        "--no-remote".to_string(),
1605    ];
1606    if config.headless {
1607        args.push("--headless".to_string());
1608    }
1609    if let Some(ref ua) = config.user_agent {
1610        args.push(format!("--user-agent={ua}"));
1611    }
1612    if config.viewport_width > 0 {
1613        args.push(format!("--width={}", config.viewport_width));
1614    }
1615    if config.viewport_height > 0 {
1616        args.push(format!("--height={}", config.viewport_height));
1617    }
1618
1619    // Spawn the process. The parent env is inherited (so a launch wrapper's
1620    // exported config / sandbox toggles propagate); match rustenium's
1621    // MOZ_LAUNCHER_PROCESS=0 so the parent PID is the actual browser.
1622    let child = std::process::Command::new(&exe)
1623        .args(&args)
1624        .env("MOZ_LAUNCHER_PROCESS", "0")
1625        .spawn()
1626        .map_err(|e| anyhow!("failed to spawn browser {exe:?}: {e}"))?;
1627
1628    // Poll the debugging port until it accepts a connection, or time out. This is
1629    // the wait rustenium's fixed 500 ms sleep gets wrong for slow-binding builds.
1630    let addr: std::net::SocketAddr = format!("{host}:{port}")
1631        .parse()
1632        .map_err(|e| anyhow!("bad debug addr {host}:{port}: {e}"))?;
1633    let start = std::time::Instant::now();
1634    let ready_timeout = std::time::Duration::from_secs(30);
1635    loop {
1636        if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(250)).is_ok()
1637        {
1638            break;
1639        }
1640        if start.elapsed() >= ready_timeout {
1641            return Err(anyhow!(
1642                "browser debug port {port} never came up within {}s — the spawn likely failed (check {exe:?})",
1643                ready_timeout.as_secs()
1644            ));
1645        }
1646        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1647    }
1648
1649    // Attach over BiDi to the already-live port — no spawn, no fixed-sleep race.
1650    //
1651    // A SINGLE attach: rustenium's `BidiSession::new` waits a hardcoded 5 s for
1652    // the `session.new` response and PANICS on timeout, but the session is still
1653    // CREATED on the browser — and a BiDi browser allows only one active session,
1654    // so a retry just hits "Maximum number of active sessions". The right lever is
1655    // therefore to give the engine enough head start that its single `session.new`
1656    // answers within that window (see the post-readiness settle below), not to
1657    // retry. The attach runs in a task so a timeout surfaces as a clean error
1658    // instead of unwinding this function.
1659    let cfg = FirefoxConfig {
1660        host: Some(host.clone()),
1661        capabilities: {
1662            let mut caps = FirefoxCapabilities::default();
1663            caps.accept_insecure_certs(true);
1664            if let Some(behavior) = config
1665                .unhandled_prompt_behavior
1666                .as_deref()
1667                .and_then(prompt_behavior_capability)
1668            {
1669                caps.unhandled_prompt_behavior(behavior);
1670            }
1671            caps
1672        },
1673        launch_mode: FirefoxLaunchMode::Remote(port),
1674        remote_debugging_port: Some(port),
1675        ..Default::default()
1676    };
1677    let attach = tokio::spawn(async move {
1678        tokio::time::timeout(std::time::Duration::from_secs(30), firefox(Some(cfg))).await
1679    });
1680    let browser = match attach.await {
1681        Ok(Ok(b)) => b,
1682        Ok(Err(_elapsed)) => return Err(anyhow!("BiDi attach to self-managed browser timed out after 30s")),
1683        Err(join) => return Err(anyhow!("BiDi attach to self-managed browser failed: {join}")),
1684    };
1685
1686    Ok(Page {
1687        browser: tokio::sync::Mutex::new(Some(browser)),
1688        profile_dir: Some(profile_dir),
1689        child: std::sync::Mutex::new(Some(child)),
1690    })
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696    use rustenium_bidi_definitions::script::types::{
1697        ArrayRemoteValue, ArrayRemoteValueType, BigIntValue, BigIntValueType,
1698        BooleanValue, BooleanValueType, ListRemoteValue, MappingRemoteValue,
1699        NullValue, NullValueType, NumberValue, NumberValueType,
1700        ObjectRemoteValue, ObjectRemoteValueType, PrimitiveProtocolValue,
1701        StringValue, StringValueType, UndefinedValue, UndefinedValueType,
1702    };
1703
1704    // ─── FrameSpec::parse ───
1705
1706    #[test]
1707    fn frame_spec_main_aliases() {
1708        assert_eq!(FrameSpec::parse(""), FrameSpec::Main);
1709        assert_eq!(FrameSpec::parse("  "), FrameSpec::Main);
1710        assert_eq!(FrameSpec::parse("main"), FrameSpec::Main);
1711        assert_eq!(FrameSpec::parse("TOP"), FrameSpec::Main);
1712    }
1713
1714    #[test]
1715    fn frame_spec_index_forms() {
1716        // Bare digits are ambiguous (numeric BiDi id OR index) → IdOrIndex.
1717        assert_eq!(FrameSpec::parse("0"), FrameSpec::IdOrIndex("0".into(), 0));
1718        assert_eq!(FrameSpec::parse("3"), FrameSpec::IdOrIndex("3".into(), 3));
1719        // A large numeric Firefox context id is still resolvable by exact id.
1720        assert_eq!(
1721            FrameSpec::parse("10737418241"),
1722            FrameSpec::IdOrIndex("10737418241".into(), 10737418241)
1723        );
1724        // `index:` forces a strict index.
1725        assert_eq!(FrameSpec::parse("index:2"), FrameSpec::Index(2));
1726    }
1727
1728    #[test]
1729    fn frame_spec_url_and_name_prefixes() {
1730        assert_eq!(
1731            FrameSpec::parse("url:recaptcha/api2"),
1732            FrameSpec::UrlContains("recaptcha/api2".into())
1733        );
1734        assert_eq!(
1735            FrameSpec::parse("name:checkout-frame"),
1736            FrameSpec::NameEquals("checkout-frame".into())
1737        );
1738        // Whitespace inside the value is trimmed.
1739        assert_eq!(
1740            FrameSpec::parse("url:  https://x.com "),
1741            FrameSpec::UrlContains("https://x.com".into())
1742        );
1743    }
1744
1745    #[test]
1746    fn frame_spec_bare_id_falls_through() {
1747        // An opaque BiDi context id (non-numeric, no prefix) is an Id.
1748        assert_eq!(
1749            FrameSpec::parse("10737418241-abc"),
1750            FrameSpec::Id("10737418241-abc".into())
1751        );
1752        // A bare URL with no prefix is also an Id (resolve falls back to URL match).
1753        assert_eq!(
1754            FrameSpec::parse("https://w.com/f"),
1755            FrameSpec::Id("https://w.com/f".into())
1756        );
1757    }
1758
1759    // ─── prompt_behavior_capability ───
1760
1761    #[test]
1762    fn prompt_behavior_maps_known_values() {
1763        for s in ["accept", "ACCEPT", "dismiss", "ignore", "dismiss and notify", "notify"] {
1764            assert!(
1765                prompt_behavior_capability(s).is_some(),
1766                "'{s}' should map to a capability"
1767            );
1768        }
1769    }
1770
1771    #[test]
1772    fn prompt_behavior_rejects_unknown() {
1773        assert!(prompt_behavior_capability("").is_none());
1774        assert!(prompt_behavior_capability("bogus").is_none());
1775    }
1776
1777    #[test]
1778    fn prompt_behavior_ignore_is_user_prompt_handler_type() {
1779        match prompt_behavior_capability("ignore") {
1780            Some(UnhandledPromptBehavior::UserPromptHandlerType(UserPromptHandlerType::Ignore)) => {}
1781            other => panic!("ignore should map to UserPromptHandlerType::Ignore, got {other:?}"),
1782        }
1783    }
1784
1785    // ─── bidi_wire_value_to_json ───
1786
1787    #[test]
1788    fn wire_string_extracts_value() {
1789        let v = serde_json::json!({"type": "string", "value": "hello"});
1790        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!("hello"));
1791    }
1792
1793    #[test]
1794    fn wire_number_passthrough() {
1795        let v = serde_json::json!({"type": "number", "value": 42.5});
1796        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(42.5));
1797    }
1798
1799    #[test]
1800    fn wire_boolean_extracts_bool() {
1801        let v = serde_json::json!({"type": "boolean", "value": true});
1802        assert_eq!(bidi_wire_value_to_json(&v), serde_json::json!(true));
1803    }
1804
1805    #[test]
1806    fn wire_null_returns_null() {
1807        let v = serde_json::json!({"type": "null"});
1808        assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
1809    }
1810
1811    #[test]
1812    fn wire_undefined_returns_null() {
1813        let v = serde_json::json!({"type": "undefined"});
1814        assert_eq!(bidi_wire_value_to_json(&v), serde_json::Value::Null);
1815    }
1816
1817    #[test]
1818    fn wire_bigint_returns_string() {
1819        let v = serde_json::json!({"type": "bigint", "value": "9007199254740993"});
1820        assert_eq!(
1821            bidi_wire_value_to_json(&v),
1822            serde_json::json!("9007199254740993")
1823        );
1824    }
1825
1826    #[test]
1827    fn wire_object_recurse() {
1828        let v = serde_json::json!({
1829            "type": "object",
1830            "value": [
1831                ["a", {"type": "string", "value": "alpha"}],
1832                ["b", {"type": "number", "value": 2}]
1833            ]
1834        });
1835        let out = bidi_wire_value_to_json(&v);
1836        assert_eq!(out["a"], "alpha");
1837        assert_eq!(out["b"], 2);
1838    }
1839
1840    #[test]
1841    fn wire_array_recurse() {
1842        let v = serde_json::json!({
1843            "type": "array",
1844            "value": [
1845                {"type": "string", "value": "x"},
1846                {"type": "number", "value": 1}
1847            ]
1848        });
1849        let out = bidi_wire_value_to_json(&v);
1850        assert_eq!(out, serde_json::json!(["x", 1]));
1851    }
1852
1853    #[test]
1854    fn wire_unknown_type_clones_raw() {
1855        let v = serde_json::json!({"type": "special", "payload": 99});
1856        assert_eq!(bidi_wire_value_to_json(&v), v);
1857    }
1858
1859    #[test]
1860    fn wire_missing_type_clones_raw() {
1861        let v = serde_json::json!({"payload": 99});
1862        assert_eq!(bidi_wire_value_to_json(&v), v);
1863    }
1864
1865    // ─── remote_value_to_json ───
1866
1867    #[test]
1868    fn rv_string_value() {
1869        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1870            StringValue::new(StringValueType::String, "hi"),
1871        ));
1872        assert_eq!(remote_value_to_json(&rv), serde_json::json!("hi"));
1873    }
1874
1875    #[test]
1876    fn rv_number_value() {
1877        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
1878            NumberValue::new(NumberValueType::Number, 3.14),
1879        ));
1880        assert_eq!(remote_value_to_json(&rv), serde_json::json!(3.14));
1881    }
1882
1883    #[test]
1884    fn rv_boolean_value() {
1885        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
1886            BooleanValue::new(BooleanValueType::Boolean, true),
1887        ));
1888        assert_eq!(remote_value_to_json(&rv), serde_json::json!(true));
1889    }
1890
1891    #[test]
1892    fn rv_null_value() {
1893        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NullValue(
1894            NullValue::new(NullValueType::Null),
1895        ));
1896        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1897    }
1898
1899    #[test]
1900    fn rv_undefined_value() {
1901        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::UndefinedValue(
1902            UndefinedValue::new(UndefinedValueType::Undefined),
1903        ));
1904        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1905    }
1906
1907    #[test]
1908    fn rv_bigint_value() {
1909        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BigIntValue(
1910            BigIntValue::new(BigIntValueType::Bigint, "999n"),
1911        ));
1912        assert_eq!(remote_value_to_json(&rv), serde_json::json!("999n"));
1913    }
1914
1915    #[test]
1916    fn rv_array_value() {
1917        let inner = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1918            StringValue::new(StringValueType::String, "item"),
1919        ));
1920        let arr = ArrayRemoteValue {
1921            r#type: ArrayRemoteValueType::Array,
1922            handle: None,
1923            internal_id: None,
1924            value: Some(ListRemoteValue::new(vec![inner])),
1925        };
1926        let rv = RemoteValue::ArrayRemoteValue(arr);
1927        assert_eq!(remote_value_to_json(&rv), serde_json::json!(["item"]));
1928    }
1929
1930    #[test]
1931    fn rv_object_value() {
1932        let obj = ObjectRemoteValue {
1933            r#type: ObjectRemoteValueType::Object,
1934            handle: None,
1935            internal_id: None,
1936            value: Some(MappingRemoteValue::new(vec![vec![
1937                serde_json::json!("key"),
1938                serde_json::json!({"type": "string", "value": "val"}),
1939            ]])),
1940        };
1941        let rv = RemoteValue::ObjectRemoteValue(obj);
1942        let out = remote_value_to_json(&rv);
1943        assert_eq!(out["key"], "val");
1944    }
1945
1946    #[test]
1947    fn rv_unsupported_returns_null() {
1948        let sym = rustenium_bidi_definitions::script::types::SymbolRemoteValue::new(
1949            rustenium_bidi_definitions::script::types::SymbolRemoteValueType::Symbol,
1950        );
1951        let rv = RemoteValue::SymbolRemoteValue(sym);
1952        assert_eq!(remote_value_to_json(&rv), serde_json::Value::Null);
1953    }
1954
1955    // ─── EvaluationResult ───
1956
1957    #[test]
1958    fn eval_result_into_value_deserializes() {
1959        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::StringValue(
1960            StringValue::new(StringValueType::String, "deserialized"),
1961        ));
1962        let er = EvaluationResult::new(rv);
1963        let s: String = er.into_value().unwrap();
1964        assert_eq!(s, "deserialized");
1965    }
1966
1967    #[test]
1968    fn eval_result_into_value_number() {
1969        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::NumberValue(
1970            NumberValue::new(NumberValueType::Number, 42i32),
1971        ));
1972        let er = EvaluationResult::new(rv);
1973        let n: i32 = er.into_value().unwrap();
1974        assert_eq!(n, 42);
1975    }
1976
1977    #[test]
1978    fn eval_result_remote_value_accessor() {
1979        let rv = RemoteValue::PrimitiveProtocolValue(PrimitiveProtocolValue::BooleanValue(
1980            BooleanValue::new(BooleanValueType::Boolean, false),
1981        ));
1982        let er = EvaluationResult::new(rv.clone());
1983        assert_eq!(er.remote_value(), &rv);
1984    }
1985
1986    // ─── FoxBrowserConfig ───
1987
1988    #[test]
1989    fn fox_browser_config_default_is_headless_false() {
1990        let cfg = FoxBrowserConfig::default();
1991        assert!(!cfg.headless);
1992        assert!(cfg.executable_path.is_none());
1993        assert!(cfg.profile_dir.is_none());
1994        assert_eq!(cfg.viewport_width, 0);
1995        assert_eq!(cfg.viewport_height, 0);
1996        assert!(cfg.user_agent.is_none());
1997        assert!(cfg.user_js_content.is_none());
1998    }
1999
2000    // ─── write_user_js ───
2001
2002    #[test]
2003    fn write_user_js_creates_file() {
2004        let tmp = std::env::temp_dir().join(format!("foxdriver_test_{}", std::process::id()));
2005        let _ = std::fs::remove_dir_all(&tmp);
2006        let content = "user_pref(\"test\", true);\n";
2007        write_user_js(tmp.to_str().unwrap(), content).unwrap();
2008        let path = tmp.join("user.js");
2009        assert!(path.exists());
2010        let read = std::fs::read_to_string(&path).unwrap();
2011        assert_eq!(read, content);
2012        let _ = std::fs::remove_dir_all(&tmp);
2013    }
2014
2015    #[test]
2016    fn write_user_js_creates_nested_dirs() {
2017        let tmp = std::env::temp_dir().join(format!("foxdriver_nested_{}", std::process::id()));
2018        let _ = std::fs::remove_dir_all(&tmp);
2019        let nested = tmp.join("a").join("b");
2020        write_user_js(nested.to_str().unwrap(), "pref").unwrap();
2021        assert!(nested.join("user.js").exists());
2022        let _ = std::fs::remove_dir_all(&tmp);
2023    }
2024
2025    // ─── ProxyConfig / proxy_prefs ───
2026
2027    #[test]
2028    fn proxy_from_url_http_no_auth() {
2029        let p = ProxyConfig::from_url("http://1.2.3.4:8080").unwrap();
2030        assert_eq!(p.scheme, ProxyScheme::Http);
2031        assert_eq!(p.host, "1.2.3.4");
2032        assert_eq!(p.port, 8080);
2033        assert!(p.username.is_none() && p.password.is_none());
2034    }
2035
2036    #[test]
2037    fn proxy_from_url_socks5_with_auth() {
2038        let p = ProxyConfig::from_url("socks5://user:pass@gw.residential.net:1080").unwrap();
2039        assert_eq!(p.scheme, ProxyScheme::Socks5);
2040        assert_eq!(p.host, "gw.residential.net");
2041        assert_eq!(p.port, 1080);
2042        assert_eq!(p.username.as_deref(), Some("user"));
2043        assert_eq!(p.password.as_deref(), Some("pass"));
2044    }
2045
2046    #[test]
2047    fn proxy_from_url_bare_defaults_http() {
2048        let p = ProxyConfig::from_url("10.0.0.1:3128").unwrap();
2049        assert_eq!(p.scheme, ProxyScheme::Http);
2050        assert_eq!(p.host, "10.0.0.1");
2051        assert_eq!(p.port, 3128);
2052    }
2053
2054    #[test]
2055    fn proxy_from_url_rejects_missing_port_and_bad_scheme() {
2056        assert!(ProxyConfig::from_url("http://nohost").is_err());
2057        assert!(ProxyConfig::from_url("ftp://h:1").is_err());
2058        assert!(ProxyConfig::from_url("http://h:notaport").is_err());
2059    }
2060
2061    #[test]
2062    fn proxy_prefs_http_emits_http_ssl_and_type() {
2063        let prefs = proxy_prefs(&ProxyConfig::from_url("http://5.6.7.8:9000").unwrap());
2064        assert!(prefs.contains(r#"user_pref("network.proxy.type", 1);"#));
2065        assert!(prefs.contains(r#"user_pref("network.proxy.http", "5.6.7.8");"#));
2066        assert!(prefs.contains(r#"user_pref("network.proxy.http_port", 9000);"#));
2067        assert!(prefs.contains(r#"user_pref("network.proxy.ssl", "5.6.7.8");"#));
2068        assert!(prefs.contains(r#"user_pref("network.proxy.ssl_port", 9000);"#));
2069        // Negative twin: the HTTP form must NOT emit SOCKS prefs.
2070        assert!(!prefs.contains("network.proxy.socks"));
2071    }
2072
2073    #[test]
2074    fn proxy_prefs_socks5_emits_socks_and_version() {
2075        let prefs = proxy_prefs(&ProxyConfig::from_url("socks5://h:1080").unwrap());
2076        assert!(prefs.contains(r#"user_pref("network.proxy.socks", "h");"#));
2077        assert!(prefs.contains(r#"user_pref("network.proxy.socks_port", 1080);"#));
2078        assert!(prefs.contains(r#"user_pref("network.proxy.socks_version", 5);"#));
2079        // Negative twin: the SOCKS form must NOT emit the HTTP-proxy prefs.
2080        assert!(!prefs.contains("network.proxy.http_port"));
2081    }
2082
2083    // ─── ScrollDirection ───
2084
2085    #[test]
2086    fn scroll_direction_up_not_eq_down() {
2087        assert_ne!(ScrollDirection::Up, ScrollDirection::Down);
2088    }
2089
2090    #[test]
2091    fn scroll_direction_clone_copy() {
2092        let a = ScrollDirection::Up;
2093        let b = a;
2094        assert_eq!(a, b); // copy, not move
2095    }
2096
2097    #[test]
2098    fn scroll_direction_debug() {
2099        let s = format!("{:?}", ScrollDirection::Down);
2100        assert!(s.contains("Down"));
2101    }
2102}