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