runtime_foxdriver/frame_graph.rs
1//! Frame + shadow-root graph for the current page.
2//!
3//! Every captcha-solving operation that has to walk frames today
4//! does this:
5//!
6//! ```text
7//! for fid in page.frames().await? {
8//! if let Some(ctx) = page.frame_execution_context(fid).await? {
9//! page.evaluate_expression(...with ctx...).await?;
10//! }
11//! }
12//! ```
13//!
14//! Three problems with the bare-loop pattern:
15//!
16//! 1. **No structure.** `page.frames()` returns a flat list, you
17//! can't ask "which frame is this iframe's parent?", "which
18//! frames live inside the captcha container?", "which frame is
19//! nested deepest?" without re-running an extraction pass each
20//! time. Solvers re-derive the topology over and over.
21//! 2. **Shadow roots are invisible.** `page.frames()` only sees
22//! cross-document boundaries; same-document shadow roots are
23//! missed. The existing in-DOM `walkAllRoots` JS pass handles
24//! them but lives in every solver as a copy-paste blob.
25//! 3. **No reasoning.** With a graph you can BFS from "the deepest
26//! frame containing a captcha widget" outward to find the
27//! nearest token field, or topo-sort frames so the deepest
28//! challenge runs first. With a flat list you can't.
29//!
30//! [`FrameGraph`] is the substrate: snapshot once, query many
31//! times. [`FrameNode`] is the per-node shape (frame_id +
32//! parent + URL + title + presence of captcha markers).
33//!
34//! Pure data type, no IO ourselves; [`FrameGraph::snapshot`]
35//! recovers the real topology with one WebDriver BiDi
36//! `browsingContext.getTree` call (via [`crate::browser::Page::frame_tree`])
37//! plus a per-frame eval for title/marker, then returns a built
38//! graph. Tests can construct synthetic graphs without a browser.
39
40use crate::browser::Page;
41use anyhow::Result;
42use std::collections::{HashMap, VecDeque};
43
44/// One node in the frame graph.
45///
46/// `frame_id` is the CDP frame identifier, opaque string we hand
47/// back to `page.frame_execution_context(frame_id)` when running
48/// JS in this frame's context.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FrameNode {
51 /// CDP frame ID. `None` for the synthetic root that ties the
52 /// main frame plus all shadow roots together; nodes representing
53 /// real frames always carry one.
54 pub frame_id: Option<String>,
55 /// Index of the parent node in [`FrameGraph::nodes`]. `None`
56 /// only for the root.
57 pub parent: Option<usize>,
58 /// URL of the document this frame is rendering. `about:blank`
59 /// or `about:srcdoc` for synthetic / written iframes.
60 pub url: String,
61 /// `document.title` at snapshot time.
62 pub title: String,
63 /// True when the frame's body or any descendant matched a
64 /// captcha-shaped selector at snapshot time. Drives reasoning
65 /// like "BFS to the nearest captcha-bearing frame".
66 pub has_captcha_marker: bool,
67 /// Depth from the root (root = 0, top-level frame = 1, …).
68 pub depth: usize,
69}
70
71/// The full graph for a page snapshot.
72///
73/// Children are indexed via [`FrameGraph::children`] which scans
74/// the `nodes` Vec, fine for the typical <50-node frame trees we
75/// see in the wild; would warrant a parent→children index for
76/// 1000+-frame pages (which don't exist in practice).
77#[derive(Debug, Clone, Default)]
78pub struct FrameGraph {
79 pub nodes: Vec<FrameNode>,
80}
81
82impl FrameGraph {
83 /// Build a graph from the current state of `page`.
84 ///
85 /// Structure (parent links + depth + every frame's URL) comes from a
86 /// single `browsingContext.getTree` round-trip via [`Page::frame_tree`],
87 /// so the real cross-origin nesting is preserved, the old
88 /// `page.frames()` path returned a flat id list and forced every node to
89 /// `parent: root, depth: 1`, collapsing reCAPTCHA's `bframe`-inside-`anchor`
90 /// (and every other nested challenge) into siblings and defeating the
91 /// graph's whole purpose.
92 ///
93 /// Each node is then enriched with `title` + `has_captcha_marker` by
94 /// evaluating [`PROBE_JS`] inside that frame's own realm, the same
95 /// selector list `oracle::take_snapshot` uses, so the two passes stay in
96 /// sync. A frame whose probe eval fails (raced destruction, restricted
97 /// realm) is **not dropped**: it keeps its structural place with the URL
98 /// `getTree` reported, and the failure is logged at `warn`, never silently
99 /// swallowed (which the old path did, making cross-origin captcha frames
100 /// vanish from the graph entirely).
101 pub async fn snapshot(page: &Page) -> Result<Self> {
102 let tree = page.frame_tree().await?;
103
104 // IO half: probe each frame's realm for title + captcha marker. A
105 // frame whose probe fails keeps its structural place (URL from
106 // getTree) and the failure is logged (never silently dropped).
107 let mut enriched: Vec<EnrichedFrame> = Vec::with_capacity(tree.len());
108 for entry in &tree {
109 let (title, has_captcha_marker) =
110 match page.evaluate_in_context(PROBE_JS, &entry.id).await {
111 Ok(eval) => match eval.into_value::<FrameProbe>() {
112 Ok(v) => (v.title, v.has_captcha_marker),
113 Err(e) => {
114 tracing::warn!("frame {} probe decode failed: {e}", entry.url);
115 (String::new(), false)
116 }
117 },
118 Err(e) => {
119 tracing::warn!("frame {} probe eval failed: {e}", entry.url);
120 (String::new(), false)
121 }
122 };
123 enriched.push(EnrichedFrame {
124 // Raw context id (not its Debug form) so the graph's `frame_id`
125 // is directly usable as a `frame` target.
126 id: entry.id.inner().to_string(),
127 url: entry.url.clone(),
128 parent: entry.parent.as_ref().map(|p| p.inner().to_string()),
129 title,
130 has_captcha_marker,
131 });
132 }
133
134 // Pure half: reconstruct parent links + depth. Split out so it is
135 // unit-testable without a browser.
136 Ok(Self::assemble(&enriched))
137 }
138
139 /// Assemble the node Vec from a **pre-order** (parent-before-child) list
140 /// of probed frames, recovering each node's parent index and root-relative
141 /// depth from the BiDi parentage. Pure, no IO, so the linkage logic is
142 /// provable on synthetic nested trees without launching a browser.
143 fn assemble(entries: &[EnrichedFrame]) -> Self {
144 let mut nodes: Vec<FrameNode> = Vec::with_capacity(entries.len() + 1);
145
146 // Node 0 = synthetic root. Always present even when the page has no
147 // frames; ties all top-level contexts together under one entry point.
148 nodes.push(FrameNode {
149 frame_id: None,
150 parent: None,
151 url: "(root)".into(),
152 title: String::new(),
153 has_captcha_marker: false,
154 depth: 0,
155 });
156
157 // Context id → node index, so a child resolves its parent's index.
158 // Pre-order guarantees the parent is inserted before any of its
159 // children are processed.
160 let mut id_to_idx: HashMap<String, usize> = HashMap::new();
161
162 for e in entries {
163 let parent_idx = match &e.parent {
164 None => 0, // top-level context hangs off the synthetic root
165 Some(pid) => match id_to_idx.get(pid) {
166 Some(&idx) => idx,
167 None => {
168 // Pre-order should make this impossible; if a tree ever
169 // arrives out of order, say so loudly rather than
170 // silently reparenting to root.
171 tracing::warn!(
172 "frame parent {pid} not seen before child {}, attaching to root",
173 e.id
174 );
175 0
176 }
177 },
178 };
179 let depth = nodes[parent_idx].depth + 1;
180 id_to_idx.insert(e.id.clone(), nodes.len());
181 nodes.push(FrameNode {
182 frame_id: Some(e.id.clone()),
183 parent: Some(parent_idx),
184 url: e.url.clone(),
185 title: e.title.clone(),
186 has_captcha_marker: e.has_captcha_marker,
187 depth,
188 });
189 }
190
191 Self { nodes }
192 }
193
194 /// True iff the graph has any node with `has_captcha_marker`.
195 /// Cheap pre-check before more expensive walks.
196 pub fn any_captcha_marker(&self) -> bool {
197 self.nodes.iter().any(|n| n.has_captcha_marker)
198 }
199
200 /// Indices of all child nodes of `parent_idx`.
201 ///
202 /// Linear scan; acceptable for typical tree sizes (<50 nodes).
203 /// If we ever ship a graph with hundreds of nodes, replace
204 /// with a precomputed parent→children index.
205 pub fn children(&self, parent_idx: usize) -> Vec<usize> {
206 self.nodes
207 .iter()
208 .enumerate()
209 .filter_map(|(i, n)| {
210 if n.parent == Some(parent_idx) {
211 Some(i)
212 } else {
213 None
214 }
215 })
216 .collect()
217 }
218
219 /// BFS from the root, returning node indices in visit order.
220 ///
221 /// Handy for "do this thing in every frame, top-down" without
222 /// open-coding the queue management at every call site.
223 pub fn bfs(&self) -> Vec<usize> {
224 if self.nodes.is_empty() {
225 return Vec::new();
226 }
227 let mut order = Vec::with_capacity(self.nodes.len());
228 let mut queue: VecDeque<usize> = VecDeque::new();
229 queue.push_back(0);
230 while let Some(idx) = queue.pop_front() {
231 order.push(idx);
232 for child in self.children(idx) {
233 queue.push_back(child);
234 }
235 }
236 order
237 }
238
239 /// Find the deepest node that has a captcha marker. Returns
240 /// `None` when no node carries one.
241 ///
242 /// Useful for "walk OUTWARD from the captcha to find the
243 /// nearest enclosing token field", once you have the captcha
244 /// node, traverse parent links upward until the token shows up.
245 pub fn deepest_captcha(&self) -> Option<usize> {
246 self.nodes
247 .iter()
248 .enumerate()
249 .filter(|(_, n)| n.has_captcha_marker)
250 .max_by_key(|(_, n)| n.depth)
251 .map(|(i, _)| i)
252 }
253
254 /// All node indices on the path from `node_idx` up to the root,
255 /// inclusive of both endpoints. Empty when `node_idx` is OOB.
256 ///
257 /// Use this when a captcha solve produces a token in a deeply-
258 /// nested iframe and you need to relay it up the tree via
259 /// `postMessage`: the path is the relay route.
260 pub fn ancestors_inclusive(&self, mut node_idx: usize) -> Vec<usize> {
261 let mut out = Vec::new();
262 let mut visited = std::collections::HashSet::new();
263 while let Some(node) = self.nodes.get(node_idx) {
264 if !visited.insert(node_idx) {
265 // Cycle guard. Snapshot graphs are trees by
266 // construction but defensive coding wins.
267 break;
268 }
269 out.push(node_idx);
270 match node.parent {
271 Some(p) => node_idx = p,
272 None => break,
273 }
274 }
275 out
276 }
277
278 /// Group nodes by URL host, returning a map host → indices.
279 ///
280 /// Lets a solver target "all cross-origin frames hosted by
281 /// `challenges.cloudflare.com`" in one query, e.g. to pierce
282 /// the CF Turnstile sandbox.
283 pub fn frames_by_host(&self) -> HashMap<String, Vec<usize>> {
284 let mut out: HashMap<String, Vec<usize>> = HashMap::new();
285 for (i, n) in self.nodes.iter().enumerate() {
286 if let Some(host) = url::Url::parse(&n.url)
287 .ok()
288 .and_then(|u| u.host_str().map(String::from))
289 {
290 out.entry(host).or_default().push(i);
291 }
292 }
293 out
294 }
295}
296
297#[derive(serde::Deserialize)]
298struct FrameProbe {
299 title: String,
300 has_captcha_marker: bool,
301}
302
303/// A frame with its structure (id/url/parent-id) plus probed
304/// title/marker, before [`FrameGraph::assemble`] turns parent ids into
305/// node indices. `parent` is the parent frame's context id, `None` for a
306/// top-level context.
307struct EnrichedFrame {
308 id: String,
309 url: String,
310 parent: Option<String>,
311 title: String,
312 has_captcha_marker: bool,
313}
314
315/// JS payload run inside every frame's execution context to
316/// populate a [`FrameNode`]'s `title` + `has_captcha_marker` (the
317/// URL comes from `browsingContext.getTree`, authoritative even for
318/// cross-origin frames). Selector list mirrors `oracle::take_snapshot`
319/// so the two passes stay consistent.
320const PROBE_JS: &str = r#"(function() {
321 try {
322 const title = (document && document.title) ? document.title : '';
323 const el = (document && document.querySelector) ? document.querySelector(
324 'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
325 + 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
326 + 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
327 + '.cf-turnstile, .h-captcha, .g-recaptcha, '
328 + '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
329 + '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
330 ) : null;
331 return { title: title, has_captcha_marker: !!el };
332 } catch(e) {
333 return { title: '', has_captcha_marker: false };
334 }
335})()"#;
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 /// Build a synthetic graph for testing without a browser:
342 ///
343 /// ```text
344 /// root
345 /// ├── main (no captcha)
346 /// │ ├── frame_a (captcha)
347 /// │ │ └── frame_aa (captcha, deepest)
348 /// │ └── frame_b
349 /// └── isolated (no parent linkage)
350 /// ```
351 fn fixture_graph() -> FrameGraph {
352 FrameGraph {
353 nodes: vec![
354 // 0: root
355 FrameNode {
356 frame_id: None,
357 parent: None,
358 url: "(root)".into(),
359 title: String::new(),
360 has_captcha_marker: false,
361 depth: 0,
362 },
363 // 1: main
364 FrameNode {
365 frame_id: Some("F1".into()),
366 parent: Some(0),
367 url: "https://example.com".into(),
368 title: "Main".into(),
369 has_captcha_marker: false,
370 depth: 1,
371 },
372 // 2: frame_a (captcha)
373 FrameNode {
374 frame_id: Some("F2".into()),
375 parent: Some(1),
376 url: "https://challenges.cloudflare.com/turnstile".into(),
377 title: String::new(),
378 has_captcha_marker: true,
379 depth: 2,
380 },
381 // 3: frame_aa (captcha, deepest)
382 FrameNode {
383 frame_id: Some("F3".into()),
384 parent: Some(2),
385 url: "https://challenges.cloudflare.com/turnstile/inner".into(),
386 title: String::new(),
387 has_captcha_marker: true,
388 depth: 3,
389 },
390 // 4: frame_b
391 FrameNode {
392 frame_id: Some("F4".into()),
393 parent: Some(1),
394 url: "https://example.com/sidebar".into(),
395 title: String::new(),
396 has_captcha_marker: false,
397 depth: 2,
398 },
399 ],
400 }
401 }
402
403 #[test]
404 fn empty_graph_has_no_captcha_markers() {
405 let g = FrameGraph::default();
406 assert!(!g.any_captcha_marker());
407 assert!(g.bfs().is_empty());
408 assert!(g.deepest_captcha().is_none());
409 }
410
411 #[test]
412 fn any_captcha_marker_short_circuits_on_first_match() {
413 let g = fixture_graph();
414 assert!(g.any_captcha_marker());
415 }
416
417 #[test]
418 fn children_returns_all_direct_children_of_root() {
419 let g = fixture_graph();
420 let kids = g.children(0);
421 assert_eq!(kids, vec![1]);
422 }
423
424 #[test]
425 fn children_returns_all_direct_children_of_internal_node() {
426 let g = fixture_graph();
427 // Main (idx=1) has frame_a (2) and frame_b (4).
428 let kids = g.children(1);
429 assert_eq!(kids, vec![2, 4]);
430 }
431
432 #[test]
433 fn bfs_visits_root_first_then_each_level() {
434 let g = fixture_graph();
435 let order = g.bfs();
436 // Expected: 0 (root) → 1 (main) → 2,4 (children of main)
437 // → 3 (child of frame_a). Order within a level matches
438 // insertion order in `nodes`.
439 assert_eq!(order, vec![0, 1, 2, 4, 3]);
440 }
441
442 #[test]
443 fn deepest_captcha_finds_innermost_marker() {
444 let g = fixture_graph();
445 let deepest = g.deepest_captcha().expect("fixture has captcha markers");
446 assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
447 }
448
449 #[test]
450 fn ancestors_inclusive_walks_to_root_in_order() {
451 let g = fixture_graph();
452 // From frame_aa (3) → frame_a (2) → main (1) → root (0).
453 let path = g.ancestors_inclusive(3);
454 assert_eq!(path, vec![3, 2, 1, 0]);
455 }
456
457 #[test]
458 fn ancestors_inclusive_handles_oob_index_gracefully() {
459 let g = fixture_graph();
460 assert!(g.ancestors_inclusive(999).is_empty());
461 }
462
463 #[test]
464 fn ancestors_inclusive_handles_root_node() {
465 let g = fixture_graph();
466 let path = g.ancestors_inclusive(0);
467 assert_eq!(path, vec![0]);
468 }
469
470 #[test]
471 fn frames_by_host_groups_correctly() {
472 let g = fixture_graph();
473 let hosts = g.frames_by_host();
474 assert_eq!(
475 hosts.get("example.com").map(|v| v.len()),
476 Some(2),
477 "main + sidebar both on example.com"
478 );
479 assert_eq!(
480 hosts.get("challenges.cloudflare.com").map(|v| v.len()),
481 Some(2),
482 "two CF turnstile frames"
483 );
484 }
485
486 #[test]
487 fn frames_by_host_skips_unparseable_urls() {
488 // (root) URL is not a valid http(s) URL → must be skipped.
489 let g = fixture_graph();
490 let hosts = g.frames_by_host();
491 assert!(!hosts.contains_key("(root)"));
492 }
493
494 #[test]
495 fn children_leaf_node_returns_empty() {
496 let g = fixture_graph();
497 // frame_aa (3) is a leaf (no children).
498 assert!(g.children(3).is_empty());
499 }
500
501 #[test]
502 fn children_oob_returns_empty() {
503 let g = fixture_graph();
504 assert!(g.children(999).is_empty());
505 }
506
507 #[test]
508 fn bfs_single_node() {
509 let g = FrameGraph {
510 nodes: vec![FrameNode {
511 frame_id: None,
512 parent: None,
513 url: "solo".into(),
514 title: String::new(),
515 has_captcha_marker: false,
516 depth: 0,
517 }],
518 };
519 assert_eq!(g.bfs(), vec![0]);
520 }
521
522 #[test]
523 fn bfs_linear_chain() {
524 let g = FrameGraph {
525 nodes: vec![
526 FrameNode {
527 frame_id: Some("A".into()),
528 parent: None,
529 url: "a".into(),
530 title: String::new(),
531 has_captcha_marker: false,
532 depth: 0,
533 },
534 FrameNode {
535 frame_id: Some("B".into()),
536 parent: Some(0),
537 url: "b".into(),
538 title: String::new(),
539 has_captcha_marker: false,
540 depth: 1,
541 },
542 FrameNode {
543 frame_id: Some("C".into()),
544 parent: Some(1),
545 url: "c".into(),
546 title: String::new(),
547 has_captcha_marker: false,
548 depth: 2,
549 },
550 ],
551 };
552 assert_eq!(g.bfs(), vec![0, 1, 2]);
553 }
554
555 #[test]
556 fn deepest_captcha_none_when_no_markers() {
557 let g = FrameGraph {
558 nodes: vec![
559 FrameNode {
560 frame_id: None,
561 parent: None,
562 url: "root".into(),
563 title: String::new(),
564 has_captcha_marker: false,
565 depth: 0,
566 },
567 FrameNode {
568 frame_id: Some("A".into()),
569 parent: Some(0),
570 url: "a".into(),
571 title: String::new(),
572 has_captcha_marker: false,
573 depth: 1,
574 },
575 ],
576 };
577 assert!(g.deepest_captcha().is_none());
578 }
579
580 #[test]
581 fn deepest_captcha_prefers_last_at_same_depth() {
582 let g = FrameGraph {
583 nodes: vec![
584 FrameNode {
585 frame_id: None,
586 parent: None,
587 url: "root".into(),
588 title: String::new(),
589 has_captcha_marker: false,
590 depth: 0,
591 },
592 FrameNode {
593 frame_id: Some("A".into()),
594 parent: Some(0),
595 url: "a".into(),
596 title: String::new(),
597 has_captcha_marker: true,
598 depth: 1,
599 },
600 FrameNode {
601 frame_id: Some("B".into()),
602 parent: Some(0),
603 url: "b".into(),
604 title: String::new(),
605 has_captcha_marker: true,
606 depth: 1,
607 },
608 ],
609 };
610 // Both at depth 1; iteration order means B (index 2) wins.
611 assert_eq!(g.deepest_captcha(), Some(2));
612 }
613
614 #[test]
615 fn ancestors_inclusive_orphaned_node_stops_at_root() {
616 // A node whose parent index doesn't exist should still be included
617 // and then stop because the parent lookup fails.
618 let g = FrameGraph {
619 nodes: vec![
620 FrameNode {
621 frame_id: None,
622 parent: None,
623 url: "root".into(),
624 title: String::new(),
625 has_captcha_marker: false,
626 depth: 0,
627 },
628 FrameNode {
629 frame_id: Some("orphan".into()),
630 parent: Some(999),
631 url: "orphan".into(),
632 title: String::new(),
633 has_captcha_marker: false,
634 depth: 1,
635 },
636 ],
637 };
638 let path = g.ancestors_inclusive(1);
639 assert_eq!(path, vec![1]);
640 }
641
642 #[test]
643 fn frames_by_host_empty_graph() {
644 let g = FrameGraph::default();
645 assert!(g.frames_by_host().is_empty());
646 }
647
648 #[test]
649 fn frames_by_host_with_port() {
650 let g = FrameGraph {
651 nodes: vec![FrameNode {
652 frame_id: None,
653 parent: None,
654 url: "http://localhost:8080/path".into(),
655 title: String::new(),
656 has_captcha_marker: false,
657 depth: 0,
658 }],
659 };
660 let hosts = g.frames_by_host();
661 assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
662 }
663
664 #[test]
665 fn frames_by_host_ip_address() {
666 let g = FrameGraph {
667 nodes: vec![FrameNode {
668 frame_id: None,
669 parent: None,
670 url: "http://192.168.1.1/admin".into(),
671 title: String::new(),
672 has_captcha_marker: false,
673 depth: 0,
674 }],
675 };
676 let hosts = g.frames_by_host();
677 assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
678 }
679
680 // ── assemble(): the parent-linkage + depth reconstruction that the old
681 // flat-snapshot path never produced ──────────────────────────────────
682
683 fn enriched(id: &str, parent: Option<&str>, url: &str, captcha: bool) -> EnrichedFrame {
684 EnrichedFrame {
685 id: id.into(),
686 url: url.into(),
687 parent: parent.map(Into::into),
688 title: String::new(),
689 has_captcha_marker: captcha,
690 }
691 }
692
693 /// The reCAPTCHA topology that defeats a flat snapshot: the cross-origin
694 /// `bframe` (the challenge) is nested INSIDE the `anchor` (the checkbox),
695 /// which is itself nested inside the main document. A flat snapshot would
696 /// make all three siblings at depth 1; `assemble` must recover the chain.
697 #[test]
698 fn assemble_recovers_nested_recaptcha_depth_not_a_flat_tree() {
699 // Pre-order BiDi walk: main → anchor → bframe.
700 let entries = vec![
701 enriched("MAIN", None, "https://victim.example/login", false),
702 enriched(
703 "ANCHOR",
704 Some("MAIN"),
705 "https://www.google.com/recaptcha/api2/anchor",
706 false,
707 ),
708 enriched(
709 "BFRAME",
710 Some("ANCHOR"),
711 "https://www.google.com/recaptcha/api2/bframe",
712 true,
713 ),
714 ];
715 let g = FrameGraph::assemble(&entries);
716
717 // root + 3 frames.
718 assert_eq!(g.nodes.len(), 4);
719
720 // Depths are NOT all 1 (the old-bug signature) (they form a chain).
721 assert_eq!(g.nodes[0].depth, 0, "synthetic root");
722 assert_eq!(g.nodes[1].depth, 1, "main document under root");
723 assert_eq!(g.nodes[2].depth, 2, "anchor nested in main");
724 assert_eq!(g.nodes[3].depth, 3, "bframe nested in anchor");
725
726 // Parent indices point up the real chain, not all at root.
727 assert_eq!(g.nodes[1].parent, Some(0));
728 assert_eq!(g.nodes[2].parent, Some(1));
729 assert_eq!(g.nodes[3].parent, Some(2));
730
731 // The challenge bframe is the deepest captcha-bearing node, and walking
732 // its ancestors yields the full pierce path the solver needs.
733 let deepest = g.deepest_captcha().expect("bframe carries the marker");
734 assert_eq!(deepest, 3);
735 assert_eq!(g.ancestors_inclusive(deepest), vec![3, 2, 1, 0]);
736
737 // frame_id is the raw context id (directly usable as a frame target),
738 // not a Debug-formatted blob.
739 assert_eq!(g.nodes[3].frame_id.as_deref(), Some("BFRAME"));
740 }
741
742 /// Two sibling iframes under the main document must stay siblings (same
743 /// parent + depth), distinct from the nesting case above.
744 #[test]
745 fn assemble_keeps_true_siblings_at_the_same_depth() {
746 let entries = vec![
747 enriched("MAIN", None, "https://site.example/", false),
748 enriched("ADS", Some("MAIN"), "https://ads.example/slot", false),
749 enriched("CHAT", Some("MAIN"), "https://chat.example/widget", false),
750 ];
751 let g = FrameGraph::assemble(&entries);
752
753 assert_eq!(
754 g.children(1),
755 vec![2, 3],
756 "both iframes are children of main"
757 );
758 assert_eq!(g.nodes[2].depth, 2);
759 assert_eq!(g.nodes[3].depth, 2);
760 }
761
762 /// Multiple top-level contexts (e.g. several tabs) all hang off the
763 /// synthetic root at depth 1.
764 #[test]
765 fn assemble_attaches_each_top_level_context_to_the_root() {
766 let entries = vec![
767 enriched("TAB1", None, "https://a.example/", false),
768 enriched("TAB2", None, "https://b.example/", false),
769 ];
770 let g = FrameGraph::assemble(&entries);
771
772 assert_eq!(g.children(0), vec![1, 2]);
773 assert_eq!(g.nodes[1].depth, 1);
774 assert_eq!(g.nodes[2].depth, 1);
775 }
776
777 /// An empty page still yields the stable synthetic root.
778 #[test]
779 fn assemble_empty_tree_is_just_the_root() {
780 let g = FrameGraph::assemble(&[]);
781 assert_eq!(g.nodes.len(), 1);
782 assert_eq!(g.nodes[0].frame_id, None);
783 assert_eq!(g.nodes[0].depth, 0);
784 }
785}