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}