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