Skip to main content

runtime_foxdriver/
frame.rs

1//! Cross-origin iframe evaluation helpers.
2//!
3//! CAPTCHA providers (reCAPTCHA, hCaptcha, Turnstile) render their challenges
4//! inside sandboxed cross-origin iframes.  JavaScript running in the parent
5//! page cannot pierce these iframes via `contentDocument` or
6//! `contentWindow.document` — doing so throws a `SecurityError`.
7//!
8//! This module uses WebDriver BiDi to evaluate expressions in each frame's
9//! own execution context, which works regardless of origin.
10
11use crate::browser::Page;
12use anyhow::Result;
13use std::time::{Duration, Instant};
14
15/// Default poll cadence for the retry helpers. CAPTCHA iframes typically
16/// attach within 50–500ms of navigation; 100ms strikes a balance between
17/// responsiveness and CDP traffic.
18pub const DEFAULT_FRAME_RETRY_INTERVAL: Duration = Duration::from_millis(100);
19
20/// Default upper bound for the retry helpers. If a captcha widget hasn't
21/// attached after 8s the page is almost certainly broken or behind a
22/// network stall — caller should fall back rather than wait longer.
23pub const DEFAULT_FRAME_RETRY_TIMEOUT: Duration = Duration::from_secs(8);
24
25/// Compute the next sleep duration for a polling loop, clamped so we
26/// never overshoot the deadline. Pulled out as a pure function so the
27/// retry behaviour can be unit-tested without a real browser.
28///
29/// Returns `None` when the deadline has been reached or passed.
30fn next_poll_sleep(now: Instant, deadline: Instant, interval: Duration) -> Option<Duration> {
31    if now >= deadline {
32        return None;
33    }
34    let remaining = deadline.saturating_duration_since(now);
35    Some(remaining.min(interval))
36}
37
38/// Escape a Rust string so it is safe to embed in a JavaScript string literal
39/// surrounded by either single or double quotes.
40///
41/// Handles the following escapes:
42/// - `\`  → `\\`
43/// - `'`  → `\'`
44/// - `"`  → `\"`
45/// - `\n` → `\\n`
46/// - `\r` → `\\r`
47/// - `\t` → `\\t`
48/// - `\0` → `\\0`
49fn escape_js_string(s: &str) -> String {
50    let mut out = String::with_capacity(s.len());
51    for ch in s.chars() {
52        match ch {
53            '\\' => out.push_str("\\\\"),
54            '\'' => out.push_str("\\'"),
55            '"' => out.push_str("\\\""),
56            '\n' => out.push_str("\\n"),
57            '\r' => out.push_str("\\r"),
58            '\t' => out.push_str("\\t"),
59            '\0' => out.push_str("\\0"),
60            c => out.push(c),
61        }
62    }
63    out
64}
65
66/// Look up the iframe offset for a given URL and optional iframe index.
67///
68/// `iframe_offsets` is a Vec of `(idx, src, id, x, y)` tuples collected from
69/// the main frame in DOM order.  If `iframe_idx` is non-negative we prefer an
70/// exact index match to disambiguate duplicate URLs.
71fn lookup_iframe_offset(
72    iframe_offsets: &[(usize, String, String, f64, f64)],
73    url: &str,
74    iframe_idx: i64,
75) -> (f64, f64) {
76    if iframe_idx >= 0 {
77        iframe_offsets
78            .iter()
79            .find(|(idx, src, id, _, _)| *idx == iframe_idx as usize && (src == url || id == url))
80            .map(|(_, _, _, x, y)| (*x, *y))
81    } else {
82        iframe_offsets
83            .iter()
84            .find(|(_, src, id, _, _)| src == url || id == url)
85            .map(|(_, _, _, x, y)| (*x, *y))
86    }
87    .unwrap_or((0.0, 0.0))
88}
89
90/// Evaluate `expression` in every frame of the page (main document + all
91/// iframes) and return the deserialized results from every frame that
92/// produced a valid value.
93///
94/// This is the robust replacement for parent-page JS that tries to walk
95/// into `iframe.contentDocument`.
96///
97/// # Example
98///
99/// ```rust,no_run
100/// use runtime_foxdriver::frame::evaluate_in_all_frames;
101/// # async fn example(page: &runtime_foxdriver::Page) -> anyhow::Result<()> {
102/// let titles: Vec<String> = evaluate_in_all_frames(page, "document.title").await?;
103/// # Ok(()) }
104/// ```
105pub async fn evaluate_in_all_frames<T>(page: &Page, expression: &str) -> Result<Vec<T>>
106where
107    T: serde::de::DeserializeOwned,
108{
109    let frame_ids = page.frames().await?;
110    let mut out = Vec::with_capacity(frame_ids.len());
111    for fid in frame_ids {
112        match page.evaluate_in_context(expression, &fid).await {
113            Ok(eval) => {
114                if let Ok(v) = eval.into_value::<T>() {
115                    out.push(v);
116                }
117            }
118            Err(e) => {
119                tracing::debug!("frame {:?} disappeared during batch eval: {}", fid, e);
120            }
121        }
122    }
123    Ok(out)
124}
125
126/// Evaluate `expression` in every frame and return the **first** result that
127/// passes `filter`.  If no frame produces a matching result, `default` is
128/// returned.
129pub async fn evaluate_in_frames_first<T, F>(
130    page: &Page,
131    expression: &str,
132    filter: F,
133    default: T,
134) -> Result<T>
135where
136    T: serde::de::DeserializeOwned + Clone,
137    F: Fn(&T) -> bool,
138{
139    let all = evaluate_in_all_frames::<T>(page, expression).await?;
140    Ok(all.into_iter().find(filter).unwrap_or(default))
141}
142
143/// Collect every top-document iframe's `(dom_index, src, id, left, top)`
144/// offset, in DOM order. Shared by the cross-origin coordinate helpers so the
145/// in-frame element rect can be summed with its containing iframe's position to
146/// yield a main-viewport coordinate. A `Vec` (not a map) is used so two iframes
147/// sharing a `src` stay disambiguated by their DOM index.
148async fn collect_iframe_offsets(
149    page: &Page,
150    main_frame: Option<&crate::FrameId>,
151) -> Result<Vec<(usize, String, String, f64, f64)>> {
152    let mut iframe_offsets: Vec<(usize, String, String, f64, f64)> = Vec::new();
153    if let Some(main) = main_frame {
154        let js = r#"
155            (function() {
156                const out = [];
157                const frames = document.querySelectorAll('iframe');
158                for (let i = 0; i < frames.length; i++) {
159                    const f = frames[i];
160                    const r = f.getBoundingClientRect();
161                    out.push({ idx: i, src: f.src, id: f.id, x: r.left, y: r.top });
162                }
163                return out;
164            })()
165        "#;
166        let eval = page.evaluate_in_context(js, main).await?;
167        if let Ok(vals) = eval.into_value::<Vec<serde_json::Value>>() {
168            for v in vals {
169                if let (Some(idx), Some(x), Some(y)) =
170                    (v["idx"].as_u64(), v["x"].as_f64(), v["y"].as_f64())
171                {
172                    let src = v["src"].as_str().unwrap_or("").to_string();
173                    let id = v["id"].as_str().unwrap_or("").to_string();
174                    iframe_offsets.push((idx as usize, src, id, x, y));
175                }
176            }
177        }
178    }
179    Ok(iframe_offsets)
180}
181
182/// Search every frame for a DOM element matching `selector` and return its
183/// bounding-box centre coordinates **relative to the main viewport**.
184///
185/// For elements inside cross-origin iframes this sums the iframe's own
186/// bounding box with the element's position inside the iframe so the
187/// resulting coordinates are safe to pass to `Input.dispatchMouseEvent`.
188///
189/// # Example
190///
191/// ```rust,no_run
192/// use runtime_foxdriver::frame::find_element_centre_in_frames;
193/// # async fn example(page: &runtime_foxdriver::Page) -> anyhow::Result<()> {
194/// let centre = find_element_centre_in_frames(page, "#submit-btn").await?;
195/// if let Some((x, y)) = centre {
196///     // x, y are viewport-relative coordinates
197/// }
198/// # Ok(()) }
199/// ```
200pub async fn find_element_centre_in_frames(
201    page: &Page,
202    selector: &str,
203) -> Result<Option<(f64, f64)>> {
204    let frame_ids = page.frames().await?;
205    let main_frame = page.mainframe().await?;
206    let iframe_offsets = collect_iframe_offsets(page, main_frame.as_ref()).await?;
207
208    let escaped = escape_js_string(selector);
209    let js = format!(
210        r#"(function() {{
211            const el = document.querySelector('{}');
212            if (!el) return null;
213            const r = el.getBoundingClientRect();
214            let iframeIdx = -1;
215            try {{
216                const frames = window.parent.frames;
217                for (let i = 0; i < frames.length; i++) {{
218                    if (frames[i] === window) {{
219                        iframeIdx = i;
220                        break;
221                    }}
222                }}
223            }} catch (e) {{}}
224            return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href, iframeIdx: iframeIdx }};
225        }})()"#,
226        escaped
227    );
228
229    for fid in frame_ids {
230        match page.evaluate_in_context(&js, &fid).await {
231            Ok(eval) => {
232                if let Ok(val) = eval.into_value::<serde_json::Value>() {
233                    if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
234                        let url = val["url"].as_str().unwrap_or("");
235                        let iframe_idx = val["iframeIdx"].as_i64().unwrap_or(-1);
236                        let (offset_x, offset_y) = if Some(&fid) == main_frame.as_ref() {
237                            (0.0, 0.0)
238                        } else {
239                            lookup_iframe_offset(&iframe_offsets, url, iframe_idx)
240                        };
241                        return Ok(Some((x + offset_x, y + offset_y)));
242                    }
243                }
244            }
245            Err(e) => {
246                tracing::debug!("frame {:?} disappeared during element search: {}", fid, e);
247            }
248        }
249    }
250    Ok(None)
251}
252
253/// Retrying variant of [`find_element_centre_in_frames`].
254///
255/// Captcha widgets frequently inject their iframe asynchronously a few
256/// hundred milliseconds after the host page loads (Turnstile, hCaptcha
257/// invisible, recaptcha v2 audio fallback). A single-shot
258/// [`find_element_centre_in_frames`] call against a freshly-navigated
259/// page will return `Ok(None)` for those cases — not because the widget
260/// is missing, but because the iframe hasn't attached yet.
261///
262/// This wrapper polls the frame tree on `interval` until either:
263/// - a frame returns coordinates (returns `Ok(Some((x, y)))`), or
264/// - the wall-clock deadline `timeout` elapses (returns `Ok(None)`).
265///
266/// `interval` is clamped to never overshoot the deadline, so the actual
267/// number of CDP round-trips is bounded by `timeout / interval + 1`.
268///
269/// Errors from the underlying single-shot call are propagated immediately
270/// — only the "not found" outcome triggers a retry.
271pub async fn find_element_centre_in_frames_retry(
272    page: &Page,
273    selector: &str,
274    timeout: Duration,
275    interval: Duration,
276) -> Result<Option<(f64, f64)>> {
277    let deadline = Instant::now() + timeout;
278    loop {
279        if let Some(centre) = find_element_centre_in_frames(page, selector).await? {
280            return Ok(Some(centre));
281        }
282        match next_poll_sleep(Instant::now(), deadline, interval) {
283            Some(d) => tokio::time::sleep(d).await,
284            None => return Ok(None),
285        }
286    }
287}
288
289/// A single grid cell located inside the frame tree, with its bounding box
290/// expressed in **main-viewport** coordinates (the iframe offset is already
291/// summed in). `index` is the element's 0-based position in the owning frame's
292/// `querySelectorAll(selector)` result, in document order.
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct FrameTile {
295    /// 0-based index within the owning frame's match list (document order).
296    pub index: usize,
297    /// Left edge in main-viewport CSS pixels.
298    pub left: f64,
299    /// Top edge in main-viewport CSS pixels.
300    pub top: f64,
301    /// Width in CSS pixels.
302    pub width: f64,
303    /// Height in CSS pixels.
304    pub height: f64,
305}
306
307impl FrameTile {
308    /// Centre point in main-viewport CSS pixels — safe to pass to the trusted
309    /// top-context [`Page::click_at`], which routes the event into the owning
310    /// (possibly cross-origin) frame.
311    pub fn centre(&self) -> (f64, f64) {
312        (self.left + self.width / 2.0, self.top + self.height / 2.0)
313    }
314}
315
316/// Locate **all** elements matching `selector` and return their bounding boxes
317/// in main-viewport coordinates, taking the matches from the FIRST frame that
318/// contains any.
319///
320/// This is the grid-aware sibling of [`find_element_centre_in_frames`]: image
321/// CAPTCHAs (reCAPTCHA v2 image grid, hCaptcha) render their tile table inside
322/// a cross-origin OOPIF (`google.com/recaptcha/api2/bframe`,
323/// `*.hcaptcha.com`). Parent-page JS cannot see those tiles at all, and a
324/// synthetic `el.dispatchEvent(new MouseEvent('click'))` would be
325/// `isTrusted === false` even if it could. By returning every tile's
326/// viewport-relative rect, the caller can drive a TRUSTED
327/// [`Page::click_at`]/[`Page::click_at_in`] at the exact centre of any tile —
328/// the only click a modern image CAPTCHA accepts.
329///
330/// Returns an empty `Vec` when no frame contains a match. Within the winning
331/// frame, tiles are returned in document order with stable `index` values, so
332/// `tiles[i].index == i`.
333pub async fn find_tiles_in_frames(page: &Page, selector: &str) -> Result<Vec<FrameTile>> {
334    let frame_ids = page.frames().await?;
335    let main_frame = page.mainframe().await?;
336    let iframe_offsets = collect_iframe_offsets(page, main_frame.as_ref()).await?;
337
338    let escaped = escape_js_string(selector);
339    let js = format!(
340        r#"(function() {{
341            const els = document.querySelectorAll('{}');
342            if (!els || els.length === 0) return null;
343            let iframeIdx = -1;
344            try {{
345                const frames = window.parent.frames;
346                for (let i = 0; i < frames.length; i++) {{
347                    if (frames[i] === window) {{ iframeIdx = i; break; }}
348                }}
349            }} catch (e) {{}}
350            const tiles = [];
351            for (let i = 0; i < els.length; i++) {{
352                const r = els[i].getBoundingClientRect();
353                tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
354            }}
355            return {{ url: window.location.href, iframeIdx: iframeIdx, tiles: tiles }};
356        }})()"#,
357        escaped
358    );
359
360    for fid in frame_ids {
361        let eval = match page.evaluate_in_context(&js, &fid).await {
362            Ok(e) => e,
363            Err(e) => {
364                tracing::debug!("frame {:?} disappeared during tile search: {}", fid, e);
365                continue;
366            }
367        };
368        let Ok(val) = eval.into_value::<serde_json::Value>() else {
369            continue;
370        };
371        let Some(raw_tiles) = val["tiles"].as_array() else {
372            continue;
373        };
374        if raw_tiles.is_empty() {
375            continue;
376        }
377        let url = val["url"].as_str().unwrap_or("");
378        let iframe_idx = val["iframeIdx"].as_i64().unwrap_or(-1);
379        let (offset_x, offset_y) = if Some(&fid) == main_frame.as_ref() {
380            (0.0, 0.0)
381        } else {
382            lookup_iframe_offset(&iframe_offsets, url, iframe_idx)
383        };
384        let mut out = Vec::with_capacity(raw_tiles.len());
385        for t in raw_tiles {
386            if let (Some(index), Some(left), Some(top), Some(width), Some(height)) = (
387                t["index"].as_u64(),
388                t["left"].as_f64(),
389                t["top"].as_f64(),
390                t["width"].as_f64(),
391                t["height"].as_f64(),
392            ) {
393                out.push(FrameTile {
394                    index: index as usize,
395                    left: left + offset_x,
396                    top: top + offset_y,
397                    width,
398                    height,
399                });
400            }
401        }
402        if !out.is_empty() {
403            return Ok(out);
404        }
405    }
406    Ok(Vec::new())
407}
408
409/// Retrying variant of [`harvest_token_in_frames`].
410///
411/// Mirrors [`find_element_centre_in_frames_retry`]: re-walks the frame
412/// tree on `interval` until either a populated token is harvested or
413/// `timeout` elapses. Used by the post-solve verification paths that
414/// need to wait for a vendor's `siteverify`-style response field to be
415/// written into the page after a click.
416pub async fn harvest_token_in_frames_retry(
417    page: &Page,
418    token_input_name: &str,
419    timeout: Duration,
420    interval: Duration,
421) -> Result<Option<String>> {
422    let deadline = Instant::now() + timeout;
423    loop {
424        if let Some(tok) = harvest_token_in_frames(page, token_input_name).await? {
425            return Ok(Some(tok));
426        }
427        match next_poll_sleep(Instant::now(), deadline, interval) {
428            Some(d) => tokio::time::sleep(d).await,
429            None => return Ok(None),
430        }
431    }
432}
433
434/// Find the bounding rect `(left, top, width, height)` of the first iframe
435/// whose `src` contains `pattern`.
436///
437/// Evaluates in the main document only — no cross-origin frame piercing
438/// required. Returns `None` when no matching iframe is found.
439pub async fn find_iframe_rect_by_src(
440    page: &Page,
441    pattern: &str,
442) -> Result<Option<(f64, f64, f64, f64)>> {
443    let escaped = escape_js_string(pattern);
444    let js = format!(
445        r#"(() => {{
446            const frames = document.querySelectorAll('iframe');
447            for (const f of frames) {{
448                if (f.src && f.src.includes('{}')) {{
449                    const r = f.getBoundingClientRect();
450                    return {{ left: r.left, top: r.top, width: r.width, height: r.height }};
451                }}
452            }}
453            return null;
454        }})()"#,
455        escaped
456    );
457    let v = page.evaluate(js.as_str()).await?;
458    let val = v.into_value::<serde_json::Value>().unwrap_or(serde_json::Value::Null);
459    if let (Some(l), Some(t), Some(w), Some(h)) = (
460        val["left"].as_f64(),
461        val["top"].as_f64(),
462        val["width"].as_f64(),
463        val["height"].as_f64(),
464    ) {
465        Ok(Some((l, t, w, h)))
466    } else {
467        Ok(None)
468    }
469}
470
471/// Check whether a CAPTCHA response token exists in **any** frame.
472/// Used for post-solve verification when the provider may inject the token
473/// into a hidden input in the main document or inside an iframe.
474///
475/// # Example
476///
477/// ```rust,no_run
478/// use runtime_foxdriver::frame::verify_token_in_frames;
479/// # async fn example(page: &runtime_foxdriver::Page) -> anyhow::Result<()> {
480/// let found = verify_token_in_frames(page, "g-recaptcha-response").await?;
481/// assert!(found);
482/// # Ok(()) }
483/// ```
484/// Search every frame for a populated captcha token field of any
485/// well-known shape (`cf-turnstile-response`, `g-recaptcha-response`,
486/// `h-captcha-response`, `frc-captcha-solution`, `altcha`,
487/// `mcaptcha__token`, `cap_token`, `captchaToken`).
488///
489/// Returns `Ok(true)` as soon as one frame reports a non-empty
490/// `el.value` for any of those fields. Useful as a "did anything
491/// pass?" check after a passive WAF challenge — saves the caller
492/// from running [`verify_token_in_frames`] once per vendor name.
493pub async fn verify_any_token_in_frames(page: &Page) -> Result<bool> {
494    const ANY_TOKEN_JS: &str = r#"(() => {
495        const sels = [
496            '[name="cf-turnstile-response"]',
497            '[name="g-recaptcha-response"]',
498            '#g-recaptcha-response',
499            '[name="h-captcha-response"]',
500            '[name="captchaToken"]',
501            '[name="frc-captcha-solution"]',
502            '[name="altcha"]',
503            '[name="mcaptcha__token"]',
504            '[name="cap_token"]',
505        ];
506        for (const sel of sels) {
507            try {
508                const els = document.querySelectorAll(sel);
509                for (const el of els) {
510                    const v = (el.value || el.textContent || '').trim();
511                    if (v) return true;
512                }
513            } catch (_) { /* keep going */ }
514        }
515        return false;
516    })()"#;
517    let results = evaluate_in_all_frames::<bool>(page, ANY_TOKEN_JS).await?;
518    Ok(results.into_iter().any(|v| v))
519}
520
521pub async fn verify_token_in_frames(page: &Page, token_input_name: &str) -> Result<bool> {
522    Ok(harvest_token_in_frames(page, token_input_name)
523        .await?
524        .is_some())
525}
526
527/// Like [`verify_token_in_frames`] but returns the populated token
528/// VALUE so the chain can hand a real `cf-turnstile-response` /
529/// `g-recaptcha-response` / `h-captcha-response` token back to the
530/// caller. The chain previously emitted hardcoded label strings
531/// (`"behavioral:pre-pass"`, …) which downstream code treated as a
532/// success token but couldn't actually validate against the vendor's
533/// `siteverify` endpoint.
534///
535/// Walks every frame; returns the FIRST non-empty value found.
536/// Order is BFS-stable per [`crate::frame::evaluate_in_all_frames`].
537pub async fn harvest_token_in_frames(
538    page: &Page,
539    token_input_name: &str,
540) -> Result<Option<String>> {
541    let escaped = escape_js_string(token_input_name);
542    // Same selector + .value-property contract as
543    // verify_token_in_frames; returns the value instead of a bool.
544    let js = format!(
545        r#"(() => {{
546            const els = document.querySelectorAll('input[name="{0}"], textarea[name="{0}"], #{0}');
547            for (const el of els) {{
548                const v = (el.value || el.textContent || '').trim();
549                if (v) return v;
550            }}
551            return null;
552        }})()"#,
553        escaped
554    );
555    let results = evaluate_in_all_frames::<Option<String>>(page, &js).await?;
556    Ok(results.into_iter().flatten().find(|v| !v.is_empty()))
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn escape_js_string_all_special_chars() {
565        let input = "\\'\"\n\r\t\0";
566        assert_eq!(escape_js_string(input), "\\\\\\\'\\\"\\n\\r\\t\\0");
567    }
568
569    #[test]
570    fn escape_js_string_backslash() {
571        assert_eq!(escape_js_string(r"\"), "\\\\");
572    }
573
574    #[test]
575    fn escape_js_string_single_quote() {
576        assert_eq!(escape_js_string("'"), "\\'");
577    }
578
579    #[test]
580    fn escape_js_string_double_quote() {
581        assert_eq!(escape_js_string("\""), "\\\"");
582    }
583
584    #[test]
585    fn escape_js_string_newline() {
586        assert_eq!(escape_js_string("a\nb"), "a\\nb");
587    }
588
589    #[test]
590    fn escape_js_string_carriage_return() {
591        assert_eq!(escape_js_string("a\rb"), "a\\rb");
592    }
593
594    #[test]
595    fn escape_js_string_tab() {
596        assert_eq!(escape_js_string("a\tb"), "a\\tb");
597    }
598
599    #[test]
600    fn escape_js_string_null_byte() {
601        assert_eq!(escape_js_string("a\0b"), "a\\0b");
602    }
603
604    #[test]
605    fn escape_js_string_mixed() {
606        let input = "line1\nline2\tcol\0end\\\"'";
607        assert_eq!(
608            escape_js_string(input),
609            "line1\\nline2\\tcol\\0end\\\\\\\"\\'"
610        );
611    }
612
613    #[test]
614    fn escape_js_string_no_special_chars() {
615        assert_eq!(escape_js_string("#simple-id"), "#simple-id");
616    }
617
618    #[test]
619    fn lookup_iframe_offset_by_index_and_url() {
620        let offsets = vec![
621            (0, "a.html".into(), "".into(), 10.0, 20.0),
622            (1, "b.html".into(), "".into(), 30.0, 40.0),
623        ];
624        assert_eq!(lookup_iframe_offset(&offsets, "a.html", 0), (10.0, 20.0));
625        assert_eq!(lookup_iframe_offset(&offsets, "b.html", 1), (30.0, 40.0));
626    }
627
628    #[test]
629    fn lookup_iframe_offset_fallback_when_index_missing() {
630        let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
631        assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
632    }
633
634    #[test]
635    fn lookup_iframe_offset_disambiguates_duplicate_src() {
636        let offsets = vec![
637            (0, "same.html".into(), "".into(), 10.0, 20.0),
638            (1, "same.html".into(), "".into(), 30.0, 40.0),
639        ];
640        // With index we can tell them apart.
641        assert_eq!(lookup_iframe_offset(&offsets, "same.html", 0), (10.0, 20.0));
642        assert_eq!(lookup_iframe_offset(&offsets, "same.html", 1), (30.0, 40.0));
643        // Without index, fallback to first match.
644        assert_eq!(
645            lookup_iframe_offset(&offsets, "same.html", -1),
646            (10.0, 20.0)
647        );
648    }
649
650    #[test]
651    fn lookup_iframe_offset_empty_src_and_id() {
652        let offsets = vec![
653            (0, "".into(), "".into(), 5.0, 5.0),
654            (1, "".into(), "".into(), 15.0, 15.0),
655        ];
656        assert_eq!(lookup_iframe_offset(&offsets, "", 0), (5.0, 5.0));
657        assert_eq!(lookup_iframe_offset(&offsets, "", 1), (15.0, 15.0));
658    }
659
660    #[test]
661    fn lookup_iframe_offset_no_match() {
662        let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
663        assert_eq!(
664            lookup_iframe_offset(&offsets, "missing.html", -1),
665            (0.0, 0.0)
666        );
667    }
668
669    #[test]
670    fn find_element_js_contains_query_selector() {
671        let selector = "#btn";
672        let escaped = escape_js_string(selector);
673        let js = format!(
674            r#"(function() {{ const el = document.querySelector('{}'); if (!el) return null; const r = el.getBoundingClientRect(); return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href }}; }})()"#,
675            escaped
676        );
677        assert!(js.contains("document.querySelector"));
678        assert!(js.contains("getBoundingClientRect"));
679    }
680
681    #[test]
682    fn frame_tile_centre_is_box_midpoint() {
683        let t = FrameTile {
684            index: 4,
685            left: 100.0,
686            top: 200.0,
687            width: 60.0,
688            height: 40.0,
689        };
690        assert_eq!(t.centre(), (130.0, 220.0));
691    }
692
693    #[test]
694    fn find_tiles_js_collects_all_matches_with_rects() {
695        let escaped = escape_js_string(".rc-imageselect-tile");
696        let js = format!(
697            r#"(function() {{
698            const els = document.querySelectorAll('{}');
699            if (!els || els.length === 0) return null;
700            const tiles = [];
701            for (let i = 0; i < els.length; i++) {{
702                const r = els[i].getBoundingClientRect();
703                tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
704            }}
705            return {{ tiles: tiles }};
706        }})()"#,
707            escaped
708        );
709        assert!(js.contains("querySelectorAll"));
710        assert!(js.contains("getBoundingClientRect"));
711        assert!(js.contains("width: r.width"));
712        assert!(js.contains("index: i"));
713    }
714
715    #[test]
716    fn verify_token_js_contains_input_selector() {
717        let name = "g-recaptcha-response";
718        let escaped = escape_js_string(name);
719        let js = format!(
720            r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
721            escaped
722        );
723        assert!(js.contains("input[name="));
724        assert!(js.contains("value]:not([value=\"\"])"));
725    }
726
727    #[test]
728    fn verify_token_escapes_quotes() {
729        let name = r#"token"value"#;
730        let escaped = escape_js_string(name);
731        assert!(escaped.contains("\\\""));
732        for (i, ch) in escaped.char_indices() {
733            if ch == '"' {
734                assert!(
735                    i > 0 && escaped.as_bytes()[i - 1] == b'\\',
736                    "quote at {} not escaped",
737                    i
738                );
739            }
740        }
741    }
742
743    #[test]
744    fn next_poll_sleep_returns_interval_when_deadline_far() {
745        let now = Instant::now();
746        let deadline = now + Duration::from_secs(10);
747        let interval = Duration::from_millis(100);
748        let s = next_poll_sleep(now, deadline, interval).unwrap();
749        assert_eq!(s, Duration::from_millis(100));
750    }
751
752    #[test]
753    fn next_poll_sleep_clamps_to_remaining_when_close_to_deadline() {
754        let now = Instant::now();
755        let deadline = now + Duration::from_millis(40);
756        let interval = Duration::from_millis(100);
757        let s = next_poll_sleep(now, deadline, interval).unwrap();
758        // Must not overshoot the deadline.
759        assert!(s <= Duration::from_millis(40));
760        assert!(s >= Duration::from_millis(30));
761    }
762
763    #[test]
764    fn next_poll_sleep_returns_none_at_deadline() {
765        let now = Instant::now();
766        let deadline = now;
767        assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
768    }
769
770    #[test]
771    fn next_poll_sleep_returns_none_past_deadline() {
772        let now = Instant::now();
773        let deadline = now - Duration::from_millis(1);
774        assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
775    }
776
777    #[test]
778    fn next_poll_sleep_zero_interval_still_yields_zero_sleep() {
779        // A degenerate interval=0 must not panic; it should yield Some(0)
780        // which lets the caller spin once and re-check (callers may use
781        // this as an "as fast as CDP allows" mode).
782        let now = Instant::now();
783        let deadline = now + Duration::from_millis(50);
784        let s = next_poll_sleep(now, deadline, Duration::ZERO).unwrap();
785        assert_eq!(s, Duration::ZERO);
786    }
787
788    #[test]
789    fn default_retry_constants_are_sane() {
790        // Lock the contract: interval must be smaller than timeout and
791        // both must be > 0. Catches a regression where someone swaps
792        // them or sets either to zero.
793        assert!(DEFAULT_FRAME_RETRY_INTERVAL > Duration::ZERO);
794        assert!(DEFAULT_FRAME_RETRY_TIMEOUT > DEFAULT_FRAME_RETRY_INTERVAL);
795        // Bound on CDP round-trips per retry call.
796        let max_polls =
797            DEFAULT_FRAME_RETRY_TIMEOUT.as_millis() / DEFAULT_FRAME_RETRY_INTERVAL.as_millis() + 1;
798        assert!(
799            max_polls <= 200,
800            "default retry would issue {max_polls} CDP calls per attempt — too chatty",
801        );
802    }
803
804    #[test]
805    fn verify_token_escapes_null_and_newline() {
806        let name = "token\0value\n";
807        let escaped = escape_js_string(name);
808        assert!(escaped.contains("\\0"));
809        assert!(escaped.contains("\\n"));
810        assert!(!escaped.contains('\0'));
811        assert!(!escaped.contains('\n'));
812    }
813
814    #[test]
815    fn escape_js_string_empty() {
816        assert_eq!(escape_js_string(""), "");
817    }
818
819    #[test]
820    fn escape_js_string_unicode_untouched() {
821        // Unicode outside the ASCII escape set should pass through unchanged.
822        let input = "emoji: 🎉 café ñ";
823        assert_eq!(escape_js_string(input), input);
824    }
825
826    #[test]
827    fn escape_js_string_preserves_length_hint() {
828        let input = "a".repeat(1000);
829        let out = escape_js_string(&input);
830        assert_eq!(out, input); // no special chars → same length
831    }
832
833    #[test]
834    fn lookup_iframe_offset_matches_by_id() {
835        let offsets = vec![
836            (0, "a.html".into(), "iframe-0".into(), 10.0, 20.0),
837        ];
838        assert_eq!(lookup_iframe_offset(&offsets, "iframe-0", -1), (10.0, 20.0));
839    }
840
841    #[test]
842    fn lookup_iframe_offset_index_mismatch_falls_back_to_first_match() {
843        let offsets = vec![
844            (0, "a.html".into(), "".into(), 10.0, 20.0),
845            (1, "b.html".into(), "".into(), 30.0, 40.0),
846        ];
847        // Requesting index 99 of "a.html" doesn't exist, but with
848        // iframe_idx=-1 it falls back to first src match.
849        assert_eq!(lookup_iframe_offset(&offsets, "a.html", 99), (0.0, 0.0));
850        assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
851    }
852
853    #[test]
854    fn lookup_iframe_offset_negative_beyond_minus_one_treated_as_fallback() {
855        // Any negative value other than the specific -1 path still goes
856        // through the `else` branch (find by src/id).
857        let offsets = vec![(0, "x".into(), "".into(), 5.0, 6.0)];
858        assert_eq!(lookup_iframe_offset(&offsets, "x", -5), (5.0, 6.0));
859    }
860
861    #[test]
862    fn next_poll_sleep_interval_larger_than_remaining() {
863        let now = Instant::now();
864        let deadline = now + Duration::from_millis(30);
865        let interval = Duration::from_millis(100);
866        let s = next_poll_sleep(now, deadline, interval).unwrap();
867        assert_eq!(s, Duration::from_millis(30));
868    }
869
870    #[test]
871    fn next_poll_sleep_very_small_remaining() {
872        let now = Instant::now();
873        let deadline = now + Duration::from_nanos(1);
874        let s = next_poll_sleep(now, deadline, Duration::from_millis(100)).unwrap();
875        assert_eq!(s, Duration::from_nanos(1));
876    }
877}