Skip to main content

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#"({
321    title: document.title || '',
322    has_captcha_marker: !!document.querySelector(
323        'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
324        + 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
325        + 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
326        + '.cf-turnstile, .h-captcha, .g-recaptcha, '
327        + '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
328        + '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
329    )
330})"#;
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    /// Build a synthetic graph for testing without a browser:
337    ///
338    /// ```text
339    /// root
340    /// ├── main (no captcha)
341    /// │   ├── frame_a (captcha)
342    /// │   │   └── frame_aa (captcha, deepest)
343    /// │   └── frame_b
344    /// └── isolated (no parent linkage)
345    /// ```
346    fn fixture_graph() -> FrameGraph {
347        FrameGraph {
348            nodes: vec![
349                // 0: root
350                FrameNode {
351                    frame_id: None,
352                    parent: None,
353                    url: "(root)".into(),
354                    title: String::new(),
355                    has_captcha_marker: false,
356                    depth: 0,
357                },
358                // 1: main
359                FrameNode {
360                    frame_id: Some("F1".into()),
361                    parent: Some(0),
362                    url: "https://example.com".into(),
363                    title: "Main".into(),
364                    has_captcha_marker: false,
365                    depth: 1,
366                },
367                // 2: frame_a (captcha)
368                FrameNode {
369                    frame_id: Some("F2".into()),
370                    parent: Some(1),
371                    url: "https://challenges.cloudflare.com/turnstile".into(),
372                    title: String::new(),
373                    has_captcha_marker: true,
374                    depth: 2,
375                },
376                // 3: frame_aa (captcha, deepest)
377                FrameNode {
378                    frame_id: Some("F3".into()),
379                    parent: Some(2),
380                    url: "https://challenges.cloudflare.com/turnstile/inner".into(),
381                    title: String::new(),
382                    has_captcha_marker: true,
383                    depth: 3,
384                },
385                // 4: frame_b
386                FrameNode {
387                    frame_id: Some("F4".into()),
388                    parent: Some(1),
389                    url: "https://example.com/sidebar".into(),
390                    title: String::new(),
391                    has_captcha_marker: false,
392                    depth: 2,
393                },
394            ],
395        }
396    }
397
398    #[test]
399    fn empty_graph_has_no_captcha_markers() {
400        let g = FrameGraph::default();
401        assert!(!g.any_captcha_marker());
402        assert!(g.bfs().is_empty());
403        assert!(g.deepest_captcha().is_none());
404    }
405
406    #[test]
407    fn any_captcha_marker_short_circuits_on_first_match() {
408        let g = fixture_graph();
409        assert!(g.any_captcha_marker());
410    }
411
412    #[test]
413    fn children_returns_all_direct_children_of_root() {
414        let g = fixture_graph();
415        let kids = g.children(0);
416        assert_eq!(kids, vec![1]);
417    }
418
419    #[test]
420    fn children_returns_all_direct_children_of_internal_node() {
421        let g = fixture_graph();
422        // Main (idx=1) has frame_a (2) and frame_b (4).
423        let kids = g.children(1);
424        assert_eq!(kids, vec![2, 4]);
425    }
426
427    #[test]
428    fn bfs_visits_root_first_then_each_level() {
429        let g = fixture_graph();
430        let order = g.bfs();
431        // Expected: 0 (root) → 1 (main) → 2,4 (children of main)
432        // → 3 (child of frame_a). Order within a level matches
433        // insertion order in `nodes`.
434        assert_eq!(order, vec![0, 1, 2, 4, 3]);
435    }
436
437    #[test]
438    fn deepest_captcha_finds_innermost_marker() {
439        let g = fixture_graph();
440        let deepest = g.deepest_captcha().expect("fixture has captcha markers");
441        assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
442    }
443
444    #[test]
445    fn ancestors_inclusive_walks_to_root_in_order() {
446        let g = fixture_graph();
447        // From frame_aa (3) → frame_a (2) → main (1) → root (0).
448        let path = g.ancestors_inclusive(3);
449        assert_eq!(path, vec![3, 2, 1, 0]);
450    }
451
452    #[test]
453    fn ancestors_inclusive_handles_oob_index_gracefully() {
454        let g = fixture_graph();
455        assert!(g.ancestors_inclusive(999).is_empty());
456    }
457
458    #[test]
459    fn ancestors_inclusive_handles_root_node() {
460        let g = fixture_graph();
461        let path = g.ancestors_inclusive(0);
462        assert_eq!(path, vec![0]);
463    }
464
465    #[test]
466    fn frames_by_host_groups_correctly() {
467        let g = fixture_graph();
468        let hosts = g.frames_by_host();
469        assert_eq!(
470            hosts.get("example.com").map(|v| v.len()),
471            Some(2),
472            "main + sidebar both on example.com"
473        );
474        assert_eq!(
475            hosts.get("challenges.cloudflare.com").map(|v| v.len()),
476            Some(2),
477            "two CF turnstile frames"
478        );
479    }
480
481    #[test]
482    fn frames_by_host_skips_unparseable_urls() {
483        // (root) URL is not a valid http(s) URL → must be skipped.
484        let g = fixture_graph();
485        let hosts = g.frames_by_host();
486        assert!(!hosts.contains_key("(root)"));
487    }
488
489    #[test]
490    fn children_leaf_node_returns_empty() {
491        let g = fixture_graph();
492        // frame_aa (3) is a leaf (no children).
493        assert!(g.children(3).is_empty());
494    }
495
496    #[test]
497    fn children_oob_returns_empty() {
498        let g = fixture_graph();
499        assert!(g.children(999).is_empty());
500    }
501
502    #[test]
503    fn bfs_single_node() {
504        let g = FrameGraph {
505            nodes: vec![FrameNode {
506                frame_id: None,
507                parent: None,
508                url: "solo".into(),
509                title: String::new(),
510                has_captcha_marker: false,
511                depth: 0,
512            }],
513        };
514        assert_eq!(g.bfs(), vec![0]);
515    }
516
517    #[test]
518    fn bfs_linear_chain() {
519        let g = FrameGraph {
520            nodes: vec![
521                FrameNode {
522                    frame_id: Some("A".into()),
523                    parent: None,
524                    url: "a".into(),
525                    title: String::new(),
526                    has_captcha_marker: false,
527                    depth: 0,
528                },
529                FrameNode {
530                    frame_id: Some("B".into()),
531                    parent: Some(0),
532                    url: "b".into(),
533                    title: String::new(),
534                    has_captcha_marker: false,
535                    depth: 1,
536                },
537                FrameNode {
538                    frame_id: Some("C".into()),
539                    parent: Some(1),
540                    url: "c".into(),
541                    title: String::new(),
542                    has_captcha_marker: false,
543                    depth: 2,
544                },
545            ],
546        };
547        assert_eq!(g.bfs(), vec![0, 1, 2]);
548    }
549
550    #[test]
551    fn deepest_captcha_none_when_no_markers() {
552        let g = FrameGraph {
553            nodes: vec![
554                FrameNode {
555                    frame_id: None,
556                    parent: None,
557                    url: "root".into(),
558                    title: String::new(),
559                    has_captcha_marker: false,
560                    depth: 0,
561                },
562                FrameNode {
563                    frame_id: Some("A".into()),
564                    parent: Some(0),
565                    url: "a".into(),
566                    title: String::new(),
567                    has_captcha_marker: false,
568                    depth: 1,
569                },
570            ],
571        };
572        assert!(g.deepest_captcha().is_none());
573    }
574
575    #[test]
576    fn deepest_captcha_prefers_last_at_same_depth() {
577        let g = FrameGraph {
578            nodes: vec![
579                FrameNode {
580                    frame_id: None,
581                    parent: None,
582                    url: "root".into(),
583                    title: String::new(),
584                    has_captcha_marker: false,
585                    depth: 0,
586                },
587                FrameNode {
588                    frame_id: Some("A".into()),
589                    parent: Some(0),
590                    url: "a".into(),
591                    title: String::new(),
592                    has_captcha_marker: true,
593                    depth: 1,
594                },
595                FrameNode {
596                    frame_id: Some("B".into()),
597                    parent: Some(0),
598                    url: "b".into(),
599                    title: String::new(),
600                    has_captcha_marker: true,
601                    depth: 1,
602                },
603            ],
604        };
605        // Both at depth 1; iteration order means B (index 2) wins.
606        assert_eq!(g.deepest_captcha(), Some(2));
607    }
608
609    #[test]
610    fn ancestors_inclusive_orphaned_node_stops_at_root() {
611        // A node whose parent index doesn't exist should still be included
612        // and then stop because the parent lookup fails.
613        let g = FrameGraph {
614            nodes: vec![
615                FrameNode {
616                    frame_id: None,
617                    parent: None,
618                    url: "root".into(),
619                    title: String::new(),
620                    has_captcha_marker: false,
621                    depth: 0,
622                },
623                FrameNode {
624                    frame_id: Some("orphan".into()),
625                    parent: Some(999),
626                    url: "orphan".into(),
627                    title: String::new(),
628                    has_captcha_marker: false,
629                    depth: 1,
630                },
631            ],
632        };
633        let path = g.ancestors_inclusive(1);
634        assert_eq!(path, vec![1]);
635    }
636
637    #[test]
638    fn frames_by_host_empty_graph() {
639        let g = FrameGraph::default();
640        assert!(g.frames_by_host().is_empty());
641    }
642
643    #[test]
644    fn frames_by_host_with_port() {
645        let g = FrameGraph {
646            nodes: vec![FrameNode {
647                frame_id: None,
648                parent: None,
649                url: "http://localhost:8080/path".into(),
650                title: String::new(),
651                has_captcha_marker: false,
652                depth: 0,
653            }],
654        };
655        let hosts = g.frames_by_host();
656        assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
657    }
658
659    #[test]
660    fn frames_by_host_ip_address() {
661        let g = FrameGraph {
662            nodes: vec![FrameNode {
663                frame_id: None,
664                parent: None,
665                url: "http://192.168.1.1/admin".into(),
666                title: String::new(),
667                has_captcha_marker: false,
668                depth: 0,
669            }],
670        };
671        let hosts = g.frames_by_host();
672        assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
673    }
674
675    // ── assemble(): the parent-linkage + depth reconstruction that the old
676    //    flat-snapshot path never produced ──────────────────────────────────
677
678    fn enriched(id: &str, parent: Option<&str>, url: &str, captcha: bool) -> EnrichedFrame {
679        EnrichedFrame {
680            id: id.into(),
681            url: url.into(),
682            parent: parent.map(Into::into),
683            title: String::new(),
684            has_captcha_marker: captcha,
685        }
686    }
687
688    /// The reCAPTCHA topology that defeats a flat snapshot: the cross-origin
689    /// `bframe` (the challenge) is nested INSIDE the `anchor` (the checkbox),
690    /// which is itself nested inside the main document. A flat snapshot would
691    /// make all three siblings at depth 1; `assemble` must recover the chain.
692    #[test]
693    fn assemble_recovers_nested_recaptcha_depth_not_a_flat_tree() {
694        // Pre-order BiDi walk: main → anchor → bframe.
695        let entries = vec![
696            enriched("MAIN", None, "https://victim.example/login", false),
697            enriched(
698                "ANCHOR",
699                Some("MAIN"),
700                "https://www.google.com/recaptcha/api2/anchor",
701                false,
702            ),
703            enriched(
704                "BFRAME",
705                Some("ANCHOR"),
706                "https://www.google.com/recaptcha/api2/bframe",
707                true,
708            ),
709        ];
710        let g = FrameGraph::assemble(&entries);
711
712        // root + 3 frames.
713        assert_eq!(g.nodes.len(), 4);
714
715        // Depths are NOT all 1 (the old-bug signature) (they form a chain).
716        assert_eq!(g.nodes[0].depth, 0, "synthetic root");
717        assert_eq!(g.nodes[1].depth, 1, "main document under root");
718        assert_eq!(g.nodes[2].depth, 2, "anchor nested in main");
719        assert_eq!(g.nodes[3].depth, 3, "bframe nested in anchor");
720
721        // Parent indices point up the real chain, not all at root.
722        assert_eq!(g.nodes[1].parent, Some(0));
723        assert_eq!(g.nodes[2].parent, Some(1));
724        assert_eq!(g.nodes[3].parent, Some(2));
725
726        // The challenge bframe is the deepest captcha-bearing node, and walking
727        // its ancestors yields the full pierce path the solver needs.
728        let deepest = g.deepest_captcha().expect("bframe carries the marker");
729        assert_eq!(deepest, 3);
730        assert_eq!(g.ancestors_inclusive(deepest), vec![3, 2, 1, 0]);
731
732        // frame_id is the raw context id (directly usable as a frame target),
733        // not a Debug-formatted blob.
734        assert_eq!(g.nodes[3].frame_id.as_deref(), Some("BFRAME"));
735    }
736
737    /// Two sibling iframes under the main document must stay siblings (same
738    /// parent + depth), distinct from the nesting case above.
739    #[test]
740    fn assemble_keeps_true_siblings_at_the_same_depth() {
741        let entries = vec![
742            enriched("MAIN", None, "https://site.example/", false),
743            enriched("ADS", Some("MAIN"), "https://ads.example/slot", false),
744            enriched("CHAT", Some("MAIN"), "https://chat.example/widget", false),
745        ];
746        let g = FrameGraph::assemble(&entries);
747
748        assert_eq!(
749            g.children(1),
750            vec![2, 3],
751            "both iframes are children of main"
752        );
753        assert_eq!(g.nodes[2].depth, 2);
754        assert_eq!(g.nodes[3].depth, 2);
755    }
756
757    /// Multiple top-level contexts (e.g. several tabs) all hang off the
758    /// synthetic root at depth 1.
759    #[test]
760    fn assemble_attaches_each_top_level_context_to_the_root() {
761        let entries = vec![
762            enriched("TAB1", None, "https://a.example/", false),
763            enriched("TAB2", None, "https://b.example/", false),
764        ];
765        let g = FrameGraph::assemble(&entries);
766
767        assert_eq!(g.children(0), vec![1, 2]);
768        assert_eq!(g.nodes[1].depth, 1);
769        assert_eq!(g.nodes[2].depth, 1);
770    }
771
772    /// An empty page still yields the stable synthetic root.
773    #[test]
774    fn assemble_empty_tree_is_just_the_root() {
775        let g = FrameGraph::assemble(&[]);
776        assert_eq!(g.nodes.len(), 1);
777        assert_eq!(g.nodes[0].frame_id, None);
778        assert_eq!(g.nodes[0].depth, 0);
779    }
780}