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#"(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}