1use crate::browser::Page;
12use anyhow::Result;
13use std::time::{Duration, Instant};
14
15pub const DEFAULT_FRAME_RETRY_INTERVAL: Duration = Duration::from_millis(100);
19
20pub const DEFAULT_FRAME_RETRY_TIMEOUT: Duration = Duration::from_secs(8);
24
25fn 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
38pub(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
70fn 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
119pub 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
155pub 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
172async 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
209async 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
229async 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 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
292pub 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 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
348pub 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#[derive(Debug, Clone, Copy, PartialEq)]
389pub struct FrameTile {
390 pub index: usize,
392 pub left: f64,
394 pub top: f64,
396 pub width: f64,
398 pub height: f64,
400}
401
402impl FrameTile {
403 pub fn centre(&self) -> (f64, f64) {
407 (self.left + self.width / 2.0, self.top + self.height / 2.0)
408 }
409}
410
411pub 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 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
492pub 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
517pub 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
556pub 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
612pub 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 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 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 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 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 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 assert!(DEFAULT_FRAME_RETRY_INTERVAL > Duration::ZERO);
879 assert!(DEFAULT_FRAME_RETRY_TIMEOUT > DEFAULT_FRAME_RETRY_INTERVAL);
880 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 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); }
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 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 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}