Skip to main content

chromiumoxide/handler/
frame.rs

1use std::collections::VecDeque;
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use serde_json::map::Entry;
7
8use chromiumoxide_cdp::cdp::browser_protocol::network::LoaderId;
9use chromiumoxide_cdp::cdp::browser_protocol::page::{
10    AddScriptToEvaluateOnNewDocumentParams, CreateIsolatedWorldParams, EventFrameDetached,
11    EventFrameStartedLoading, EventFrameStoppedLoading, EventLifecycleEvent,
12    EventNavigatedWithinDocument, Frame as CdpFrame, FrameTree,
13};
14use chromiumoxide_cdp::cdp::browser_protocol::target::EventAttachedToTarget;
15use chromiumoxide_cdp::cdp::js_protocol::runtime::*;
16use chromiumoxide_cdp::cdp::{
17    browser_protocol::page::{self, FrameId},
18    // js_protocol::runtime,
19};
20use chromiumoxide_types::{Method, MethodId, Request};
21use spider_fingerprint::BASE_CHROME_VERSION;
22
23use crate::error::DeadlineExceeded;
24use crate::handler::domworld::DOMWorld;
25use crate::handler::http::HttpRequest;
26
27use crate::{cmd::CommandChain, ArcHttpRequest};
28
29lazy_static::lazy_static! {
30    /// Spoof the runtime.
31    static ref EVALUATION_SCRIPT_URL: String = format!("____{}___evaluation_script__", random_world_name(&BASE_CHROME_VERSION.to_string()));
32}
33
34/// Generate a collision-resistant world name using `id` + randomness.
35pub fn random_world_name(id: &str) -> String {
36    use rand::RngExt;
37    let mut rng = rand::rng();
38    let rand_len = rng.random_range(6..=12);
39
40    // Convert first few chars of id into base36-compatible chars
41    let id_part: String = id
42        .chars()
43        .filter(|c| c.is_ascii_alphanumeric())
44        .take(5)
45        .map(|c| {
46            let c = c.to_ascii_lowercase();
47            if c.is_ascii_alphabetic() {
48                c
49            } else {
50                // convert 0-9 into a base36 letter offset to obscure it a bit
51                (b'a' + (c as u8 - b'0') % 26) as char
52            }
53        })
54        .collect();
55
56    // Generate random base36 tail
57    let rand_part: String = (0..rand_len)
58        .filter_map(|_| std::char::from_digit(rng.random_range(0..36), 36))
59        .collect();
60
61    // Ensure first char is always a letter (10–35 => a–z)
62    let first = std::char::from_digit(rng.random_range(10..36), 36).unwrap_or('a');
63
64    format!("{first}{id_part}{rand_part}")
65}
66
67/// Represents a frame on the page
68#[derive(Debug)]
69pub struct Frame {
70    /// The parent frame ID.
71    parent_frame: Option<FrameId>,
72    /// Cdp identifier of this frame
73    id: FrameId,
74    /// The main world.
75    main_world: DOMWorld,
76    /// The secondary world.
77    secondary_world: DOMWorld,
78    loader_id: Option<LoaderId>,
79    /// Current url of this frame
80    url: Option<String>,
81    /// The http request that loaded this with this frame
82    http_request: ArcHttpRequest,
83    /// The frames contained in this frame
84    child_frames: HashSet<FrameId>,
85    name: Option<String>,
86    /// The received lifecycle events
87    lifecycle_events: HashSet<MethodId>,
88    /// The isolated world name.
89    isolated_world_name: String,
90}
91
92impl Frame {
93    pub fn new(id: FrameId) -> Self {
94        let isolated_world_name = random_world_name(id.inner());
95
96        Self {
97            parent_frame: None,
98            id,
99            main_world: Default::default(),
100            secondary_world: Default::default(),
101            loader_id: None,
102            url: None,
103            http_request: None,
104            child_frames: Default::default(),
105            name: None,
106            lifecycle_events: Default::default(),
107            isolated_world_name,
108        }
109    }
110
111    pub fn with_parent(id: FrameId, parent: &mut Frame) -> Self {
112        parent.child_frames.insert(id.clone());
113        Self {
114            parent_frame: Some(parent.id.clone()),
115            id,
116            main_world: Default::default(),
117            secondary_world: Default::default(),
118            loader_id: None,
119            url: None,
120            http_request: None,
121            child_frames: Default::default(),
122            name: None,
123            lifecycle_events: Default::default(),
124            isolated_world_name: parent.isolated_world_name.clone(),
125        }
126    }
127
128    pub fn get_isolated_world_name(&self) -> &String {
129        &self.isolated_world_name
130    }
131
132    pub fn parent_id(&self) -> Option<&FrameId> {
133        self.parent_frame.as_ref()
134    }
135
136    pub fn id(&self) -> &FrameId {
137        &self.id
138    }
139
140    pub fn url(&self) -> Option<&str> {
141        self.url.as_deref()
142    }
143
144    pub fn name(&self) -> Option<&str> {
145        self.name.as_deref()
146    }
147
148    pub fn main_world(&self) -> &DOMWorld {
149        &self.main_world
150    }
151
152    pub fn secondary_world(&self) -> &DOMWorld {
153        &self.secondary_world
154    }
155
156    pub fn lifecycle_events(&self) -> &HashSet<MethodId> {
157        &self.lifecycle_events
158    }
159
160    pub fn http_request(&self) -> Option<&Arc<HttpRequest>> {
161        self.http_request.as_ref()
162    }
163
164    fn navigated(&mut self, frame: &CdpFrame) {
165        self.name.clone_from(&frame.name);
166        let url = if let Some(ref fragment) = frame.url_fragment {
167            format!("{}{fragment}", frame.url)
168        } else {
169            frame.url.clone()
170        };
171        self.url = Some(url);
172    }
173
174    fn navigated_within_url(&mut self, url: String) {
175        self.url = Some(url)
176    }
177
178    fn on_loading_stopped(&mut self) {
179        self.lifecycle_events.insert("DOMContentLoaded".into());
180        self.lifecycle_events.insert("load".into());
181    }
182
183    fn on_loading_started(&mut self) {
184        self.lifecycle_events.clear();
185        self.http_request.take();
186    }
187
188    pub fn is_loaded(&self) -> bool {
189        self.lifecycle_events.contains("load")
190    }
191
192    /// The `DOMContentLoaded` lifecycle event has fired (HTML parsed, sync
193    /// scripts executed). This fires *before* `load` — subresources like
194    /// images and fonts may still be in-flight.
195    pub fn is_dom_content_loaded(&self) -> bool {
196        self.lifecycle_events.contains("DOMContentLoaded")
197    }
198
199    /// Main frame + child frames have fired the `networkIdle` lifecycle event.
200    pub fn is_network_idle(&self) -> bool {
201        self.lifecycle_events.contains("networkIdle")
202    }
203
204    /// Main frame + child frames have fired the `networkAlmostIdle` lifecycle event.
205    pub fn is_network_almost_idle(&self) -> bool {
206        self.lifecycle_events.contains("networkAlmostIdle")
207    }
208
209    pub fn clear_contexts(&mut self) {
210        self.main_world.take_context();
211        self.secondary_world.take_context();
212    }
213
214    pub fn destroy_context(&mut self, ctx_unique_id: &str) {
215        if self.main_world.execution_context_unique_id() == Some(ctx_unique_id) {
216            self.main_world.take_context();
217        } else if self.secondary_world.execution_context_unique_id() == Some(ctx_unique_id) {
218            self.secondary_world.take_context();
219        }
220    }
221
222    pub fn execution_context(&self) -> Option<ExecutionContextId> {
223        self.main_world.execution_context()
224    }
225
226    pub fn set_request(&mut self, request: HttpRequest) {
227        self.http_request = Some(Arc::new(request))
228    }
229}
230
231/// Maintains the state of the pages frame and listens to events produced by
232/// chromium targeting the `Target`. Also listens for events that indicate that
233/// a navigation was completed
234#[derive(Debug)]
235pub struct FrameManager {
236    main_frame: Option<FrameId>,
237    frames: HashMap<FrameId, Frame>,
238    /// The contexts mapped with their frames
239    context_ids: HashMap<String, FrameId>,
240    isolated_worlds: HashSet<String>,
241    /// Timeout after which an anticipated event (related to navigation) doesn't
242    /// arrive results in an error
243    request_timeout: Duration,
244    /// Track currently in progress navigation
245    pending_navigations: VecDeque<(FrameRequestedNavigation, NavigationWatcher)>,
246    /// The currently ongoing navigation
247    navigation: Option<(NavigationWatcher, Instant)>,
248    /// Optional cap on main-frame cross-document navigations per `goto`.
249    ///
250    /// Defends against JS/meta-refresh loops that keep issuing fresh top-level
251    /// navigations (which look like new documents, not HTTP redirects). `None`
252    /// disables the guard — preserves prior behavior. `Some(n)` aborts the
253    /// in-flight navigation with `NavigationError::TooManyNavigations` once the
254    /// main frame has navigated more than `n` times since the latest `goto`.
255    max_main_frame_navigations: Option<u32>,
256    /// Count of main-frame cross-document navigations since the last `goto`.
257    main_frame_nav_count: u32,
258}
259
260impl FrameManager {
261    pub fn new(request_timeout: Duration) -> Self {
262        FrameManager {
263            main_frame: None,
264            frames: Default::default(),
265            context_ids: Default::default(),
266            isolated_worlds: Default::default(),
267            request_timeout,
268            pending_navigations: Default::default(),
269            navigation: None,
270            max_main_frame_navigations: None,
271            main_frame_nav_count: 0,
272        }
273    }
274
275    /// Set the cap on main-frame cross-document navigations per `goto`.
276    /// `None` disables the guard.
277    pub fn set_max_main_frame_navigations(&mut self, cap: Option<u32>) {
278        self.max_main_frame_navigations = cap;
279    }
280
281    /// The commands to execute in order to initialize this frame manager
282    pub fn init_commands(timeout: Duration) -> CommandChain {
283        let enable = page::EnableParams::default();
284        let get_tree = page::GetFrameTreeParams::default();
285        let set_lifecycle = page::SetLifecycleEventsEnabledParams::new(true);
286        // let enable_runtime = EnableParams::default();
287        // let disable_runtime = DisableParams::default();
288
289        let mut commands = Vec::with_capacity(3);
290
291        let enable_id = enable.identifier();
292        let get_tree_id = get_tree.identifier();
293        let set_lifecycle_id = set_lifecycle.identifier();
294        // let enable_runtime_id = enable_runtime.identifier();
295        // let disable_runtime_id = disable_runtime.identifier();
296
297        if let Ok(value) = serde_json::to_value(enable) {
298            commands.push((enable_id, value));
299        }
300
301        if let Ok(value) = serde_json::to_value(get_tree) {
302            commands.push((get_tree_id, value));
303        }
304
305        if let Ok(value) = serde_json::to_value(set_lifecycle) {
306            commands.push((set_lifecycle_id, value));
307        }
308
309        // if let Ok(value) = serde_json::to_value(enable_runtime) {
310        //     commands.push((enable_runtime_id, value));
311        // }
312
313        // if let Ok(value) = serde_json::to_value(disable_runtime) {
314        //     commands.push((disable_runtime_id, value));
315        // }
316
317        CommandChain::new(commands, timeout)
318    }
319
320    pub fn main_frame(&self) -> Option<&Frame> {
321        self.main_frame.as_ref().and_then(|id| self.frames.get(id))
322    }
323
324    pub fn main_frame_mut(&mut self) -> Option<&mut Frame> {
325        if let Some(id) = self.main_frame.as_ref() {
326            self.frames.get_mut(id)
327        } else {
328            None
329        }
330    }
331
332    /// Get the main isolated world name.
333    pub fn get_isolated_world_name(&self) -> Option<&String> {
334        self.main_frame
335            .as_ref()
336            .and_then(|id| self.frames.get(id).map(|fid| fid.get_isolated_world_name()))
337    }
338
339    pub fn frames(&self) -> impl Iterator<Item = &Frame> + '_ {
340        self.frames.values()
341    }
342
343    pub fn frame(&self, id: &FrameId) -> Option<&Frame> {
344        self.frames.get(id)
345    }
346
347    fn check_lifecycle(&self, watcher: &NavigationWatcher, frame: &Frame) -> bool {
348        watcher.expected_lifecycle.iter().all(|ev| {
349            frame.lifecycle_events.contains(ev)
350                || (frame.url.is_none() && frame.lifecycle_events.contains("DOMContentLoaded"))
351        })
352    }
353
354    fn check_lifecycle_complete(
355        &self,
356        watcher: &NavigationWatcher,
357        frame: &Frame,
358    ) -> Option<NavigationOk> {
359        if !self.check_lifecycle(watcher, frame) {
360            return None;
361        }
362        if frame.loader_id == watcher.loader_id && !watcher.same_document_navigation {
363            return None;
364        }
365        if watcher.same_document_navigation {
366            return Some(NavigationOk::SameDocumentNavigation(watcher.id));
367        }
368        if frame.loader_id != watcher.loader_id {
369            return Some(NavigationOk::NewDocumentNavigation(watcher.id));
370        }
371        None
372    }
373
374    /// Track the request in the frame
375    pub fn on_http_request_finished(&mut self, request: HttpRequest) {
376        if let Some(id) = request.frame.as_ref() {
377            if let Some(frame) = self.frames.get_mut(id) {
378                frame.set_request(request);
379            }
380        }
381    }
382
383    /// Drop a failed navigation so it cannot time out or hold up the next goto.
384    pub fn abandon_navigation(&mut self, id: NavigationId) {
385        if self
386            .navigation
387            .as_ref()
388            .is_some_and(|(nav, _)| nav.id == id)
389        {
390            self.navigation = None;
391        }
392        // The retain is defensive rather than exercised: under `Handler::run()`
393        // the navigation is always promoted into `self.navigation` before the ack
394        // lands. A driver that submits differently would leave it queued, and a
395        // stale queued navigation would block the next goto's promotion.
396        self.pending_navigations.retain(|(req, _)| req.id != id);
397    }
398
399    pub fn poll(&mut self, now: Instant) -> Option<FrameEvent> {
400        // check if the navigation completed
401        if let Some((watcher, deadline)) = self.navigation.take() {
402            // Navigation-loop guard: abort if the main frame has navigated
403            // more than the configured cap since this `goto` started.
404            if let Some(cap) = self.max_main_frame_navigations {
405                if self.main_frame_nav_count > cap {
406                    let count = self.main_frame_nav_count;
407                    // Keep the counter positive so the next goto can still
408                    // reset it cleanly; we just clear the active navigation.
409                    return Some(FrameEvent::NavigationResult(Err(
410                        NavigationError::TooManyNavigations {
411                            id: watcher.id,
412                            count,
413                        },
414                    )));
415                }
416            }
417
418            if now > deadline {
419                // navigation request timed out
420                return Some(FrameEvent::NavigationResult(Err(
421                    NavigationError::Timeout {
422                        err: DeadlineExceeded::new(now, deadline),
423                        id: watcher.id,
424                    },
425                )));
426            }
427
428            if let Some(frame) = self.frames.get(&watcher.frame_id) {
429                if let Some(nav) = self.check_lifecycle_complete(&watcher, frame) {
430                    // request is complete if the frame's lifecycle is complete = frame received all
431                    // required events
432                    return Some(FrameEvent::NavigationResult(Ok(nav)));
433                } else {
434                    // not finished yet
435                    self.navigation = Some((watcher, deadline));
436                }
437            } else {
438                return Some(FrameEvent::NavigationResult(Err(
439                    NavigationError::FrameNotFound {
440                        frame: watcher.frame_id,
441                        id: watcher.id,
442                    },
443                )));
444            }
445        } else if let Some((req, watcher)) = self.pending_navigations.pop_front() {
446            // queue in the next navigation that is must be fulfilled until `deadline`
447            let deadline = Instant::now() + req.timeout;
448            self.navigation = Some((watcher, deadline));
449            return Some(FrameEvent::NavigationRequest(req.id, req.req));
450        }
451        None
452    }
453
454    /// Entrypoint for page navigation
455    pub fn goto(&mut self, req: FrameRequestedNavigation) {
456        if let Some(frame_id) = &self.main_frame {
457            self.navigate_frame(frame_id.clone(), req);
458        }
459    }
460
461    /// Navigate a specific frame
462    pub fn navigate_frame(&mut self, frame_id: FrameId, mut req: FrameRequestedNavigation) {
463        let loader_id = self.frames.get(&frame_id).and_then(|f| f.loader_id.clone());
464        let watcher = NavigationWatcher::until_load(req.id, frame_id.clone(), loader_id);
465
466        // insert the frame_id in the request if not present
467        req.set_frame_id(frame_id);
468
469        // Fresh goto — reset the per-navigation loop counter.
470        self.main_frame_nav_count = 0;
471
472        self.pending_navigations.push_back((req, watcher))
473    }
474
475    /// Fired when a frame moved to another session
476    pub fn on_attached_to_target(&mut self, _event: &EventAttachedToTarget) {
477        // _onFrameMoved
478    }
479
480    pub fn on_frame_tree(&mut self, frame_tree: FrameTree) {
481        self.on_frame_attached(
482            frame_tree.frame.id.clone(),
483            frame_tree.frame.parent_id.clone(),
484        );
485        self.on_frame_navigated(&frame_tree.frame);
486        if let Some(children) = frame_tree.child_frames {
487            for child_tree in children {
488                self.on_frame_tree(child_tree);
489            }
490        }
491    }
492
493    pub fn on_frame_attached(&mut self, frame_id: FrameId, parent_frame_id: Option<FrameId>) {
494        if self.frames.contains_key(&frame_id) {
495            return;
496        }
497        if let Some(parent_frame_id) = parent_frame_id {
498            if let Some(parent_frame) = self.frames.get_mut(&parent_frame_id) {
499                let frame = Frame::with_parent(frame_id.clone(), parent_frame);
500                self.frames.insert(frame_id, frame);
501            }
502        }
503    }
504
505    pub fn on_frame_detached(&mut self, event: &EventFrameDetached) {
506        self.remove_frames_recursively(&event.frame_id);
507    }
508
509    pub fn on_frame_navigated(&mut self, frame: &CdpFrame) {
510        if frame.parent_id.is_some() {
511            if let Some((id, mut f)) = self.frames.remove_entry(&frame.id) {
512                for child in f.child_frames.drain() {
513                    self.remove_frames_recursively(&child);
514                }
515                f.navigated(frame);
516                self.frames.insert(id, f);
517            }
518        } else {
519            // Track main-frame cross-document navigations since the last
520            // `goto`. Same-document (hash change) events land in
521            // `on_frame_navigated_within_document` and are not counted.
522            self.main_frame_nav_count = self.main_frame_nav_count.saturating_add(1);
523
524            let old_main = self.main_frame.take();
525            let mut f = if let Some(main) = old_main.as_ref() {
526                // update main frame
527                if let Some(mut main_frame) = self.frames.remove(main) {
528                    for child in &main_frame.child_frames {
529                        self.remove_frames_recursively(child);
530                    }
531                    // this is necessary since we can't borrow mut and then remove recursively
532                    main_frame.child_frames.clear();
533                    main_frame.id = frame.id.clone();
534                    main_frame
535                } else {
536                    Frame::new(frame.id.clone())
537                }
538            } else {
539                // initial main frame navigation
540                Frame::new(frame.id.clone())
541            };
542            f.navigated(frame);
543            let new_id = f.id.clone();
544            self.main_frame = Some(new_id.clone());
545            self.frames.insert(new_id.clone(), f);
546
547            // When the main frame ID changes (e.g. cross-origin redirect), update the
548            // active navigation watcher so it tracks the new frame instead of the stale ID.
549            if old_main.as_ref() != Some(&new_id) {
550                if let Some((watcher, _)) = self.navigation.as_mut() {
551                    if old_main.as_ref() == Some(&watcher.frame_id) {
552                        watcher.frame_id = new_id;
553                    }
554                }
555            }
556        }
557    }
558
559    pub fn on_frame_navigated_within_document(&mut self, event: &EventNavigatedWithinDocument) {
560        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
561            frame.navigated_within_url(event.url.clone());
562        }
563        if let Some((watcher, _)) = self.navigation.as_mut() {
564            watcher.on_frame_navigated_within_document(event);
565        }
566    }
567
568    pub fn on_frame_stopped_loading(&mut self, event: &EventFrameStoppedLoading) {
569        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
570            frame.on_loading_stopped();
571        }
572    }
573
574    /// Fired when frame has started loading.
575    pub fn on_frame_started_loading(&mut self, event: &EventFrameStartedLoading) {
576        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
577            frame.on_loading_started();
578        }
579    }
580
581    /// Notification is issued every time when binding is called
582    pub fn on_runtime_binding_called(&mut self, _ev: &EventBindingCalled) {}
583
584    /// Issued when new execution context is created
585    pub fn on_frame_execution_context_created(&mut self, event: &EventExecutionContextCreated) {
586        if let Some(frame_id) = event
587            .context
588            .aux_data
589            .as_ref()
590            .and_then(|v| v["frameId"].as_str())
591        {
592            if let Some(frame) = self.frames.get_mut(frame_id) {
593                if event
594                    .context
595                    .aux_data
596                    .as_ref()
597                    .and_then(|v| v["isDefault"].as_bool())
598                    .unwrap_or_default()
599                {
600                    frame
601                        .main_world
602                        .set_context(event.context.id, event.context.unique_id.clone());
603                } else if event.context.name == frame.isolated_world_name
604                    && frame.secondary_world.execution_context().is_none()
605                {
606                    frame
607                        .secondary_world
608                        .set_context(event.context.id, event.context.unique_id.clone());
609                }
610                self.context_ids
611                    .insert(event.context.unique_id.clone(), frame.id.clone());
612            }
613        }
614        if event
615            .context
616            .aux_data
617            .as_ref()
618            .filter(|v| v["type"].as_str() == Some("isolated"))
619            .is_some()
620        {
621            self.isolated_worlds.insert(event.context.name.clone());
622        }
623    }
624
625    /// Issued when execution context is destroyed
626    pub fn on_frame_execution_context_destroyed(&mut self, event: &EventExecutionContextDestroyed) {
627        if let Some(id) = self.context_ids.remove(&event.execution_context_unique_id) {
628            if let Some(frame) = self.frames.get_mut(&id) {
629                frame.destroy_context(&event.execution_context_unique_id);
630            }
631        }
632    }
633
634    /// Issued when all executionContexts were cleared
635    pub fn on_execution_contexts_cleared(&mut self) {
636        for id in self.context_ids.values() {
637            if let Some(frame) = self.frames.get_mut(id) {
638                frame.clear_contexts();
639            }
640        }
641        self.context_ids.clear();
642        // Chrome just wiped every execution context, so any isolated worlds
643        // we had ensured are gone too. Clearing the cache forces
644        // `ensure_isolated_world` to re-issue `CreateIsolatedWorldParams` for
645        // the next evaluation instead of short-circuiting on stale membership.
646        self.isolated_worlds.clear();
647    }
648
649    /// Remove `context_ids` entries that reference frames which no longer
650    /// exist.  Called periodically from the handler's eviction tick — a
651    /// single O(n) pass instead of per-frame cleanup during recursive removal.
652    pub fn evict_stale_context_ids(&mut self) {
653        if !self.context_ids.is_empty() {
654            self.context_ids
655                .retain(|_, fid| self.frames.contains_key(fid));
656        }
657    }
658
659    /// Fired for top level page lifecycle events (nav, load, paint, etc.)
660    pub fn on_page_lifecycle_event(&mut self, event: &EventLifecycleEvent) {
661        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
662            if event.name == "init" {
663                frame.loader_id = Some(event.loader_id.clone());
664                frame.lifecycle_events.clear();
665            }
666            frame.lifecycle_events.insert(event.name.clone().into());
667        }
668    }
669
670    /// Detach all child frames.
671    fn remove_frames_recursively(&mut self, id: &FrameId) -> Option<Frame> {
672        if let Some(mut frame) = self.frames.remove(id) {
673            for child in &frame.child_frames {
674                self.remove_frames_recursively(child);
675            }
676            if let Some(parent_id) = frame.parent_frame.take() {
677                if let Some(parent) = self.frames.get_mut(&parent_id) {
678                    parent.child_frames.remove(&frame.id);
679                }
680            }
681            Some(frame)
682        } else {
683            None
684        }
685    }
686
687    pub fn ensure_isolated_world(&mut self, world_name: &str) -> Option<CommandChain> {
688        if self.isolated_worlds.contains(world_name) {
689            return None;
690        }
691
692        self.isolated_worlds.insert(world_name.to_string());
693
694        if let Ok(cmd) = AddScriptToEvaluateOnNewDocumentParams::builder()
695            .source(format!("//# sourceURL={}", *EVALUATION_SCRIPT_URL))
696            .world_name(world_name)
697            .build()
698        {
699            let mut cmds = Vec::with_capacity(self.frames.len() + 1);
700            let identifier = cmd.identifier();
701
702            if let Ok(cmd) = serde_json::to_value(cmd) {
703                cmds.push((identifier, cmd));
704            }
705
706            let cm = self.frames.keys().filter_map(|id| {
707                if let Ok(cmd) = CreateIsolatedWorldParams::builder()
708                    .frame_id(id.clone())
709                    .grant_univeral_access(true)
710                    .world_name(world_name)
711                    .build()
712                {
713                    let cm = (
714                        cmd.identifier(),
715                        serde_json::to_value(cmd).unwrap_or_default(),
716                    );
717
718                    Some(cm)
719                } else {
720                    None
721                }
722            });
723
724            cmds.extend(cm);
725
726            Some(CommandChain::new(cmds, self.request_timeout))
727        } else {
728            None
729        }
730    }
731}
732
733#[derive(Debug)]
734pub enum FrameEvent {
735    /// A previously submitted navigation has finished
736    NavigationResult(Result<NavigationOk, NavigationError>),
737    /// A new navigation request needs to be submitted
738    NavigationRequest(NavigationId, Request),
739    /* /// The initial page of the target has been loaded
740     * InitialPageLoadFinished */
741}
742
743#[derive(Debug)]
744pub enum NavigationError {
745    Timeout {
746        id: NavigationId,
747        err: DeadlineExceeded,
748    },
749    FrameNotFound {
750        id: NavigationId,
751        frame: FrameId,
752    },
753    /// The main frame performed more cross-document navigations during a
754    /// single `goto` than the configured `max_main_frame_navigations` cap.
755    /// Typically triggered by meta-refresh / `location.href` loops, which
756    /// are invisible to the HTTP-layer `max_redirects` guard.
757    TooManyNavigations {
758        id: NavigationId,
759        /// Observed main-frame navigation count (always `> cap`).
760        count: u32,
761    },
762}
763
764impl NavigationError {
765    pub fn navigation_id(&self) -> &NavigationId {
766        match self {
767            NavigationError::Timeout { id, .. } => id,
768            NavigationError::FrameNotFound { id, .. } => id,
769            NavigationError::TooManyNavigations { id, .. } => id,
770        }
771    }
772}
773
774#[derive(Debug, Clone, Eq, PartialEq)]
775pub enum NavigationOk {
776    SameDocumentNavigation(NavigationId),
777    NewDocumentNavigation(NavigationId),
778}
779
780impl NavigationOk {
781    pub fn navigation_id(&self) -> &NavigationId {
782        match self {
783            NavigationOk::SameDocumentNavigation(id) => id,
784            NavigationOk::NewDocumentNavigation(id) => id,
785        }
786    }
787}
788
789/// Tracks the progress of an issued `Page.navigate` request until completion.
790#[derive(Debug)]
791pub struct NavigationWatcher {
792    id: NavigationId,
793    expected_lifecycle: HashSet<MethodId>,
794    frame_id: FrameId,
795    loader_id: Option<LoaderId>,
796    /// Once we receive the response to the issued `Page.navigate` request we
797    /// can detect whether we were navigating withing the same document or were
798    /// navigating to a new document by checking if a loader was included in the
799    /// response.
800    same_document_navigation: bool,
801}
802
803impl NavigationWatcher {
804    /// Generic ctor: wait until all given lifecycle events have fired
805    /// (including all child frames).
806    pub fn until_lifecycle(
807        id: NavigationId,
808        frame: FrameId,
809        loader_id: Option<LoaderId>,
810        events: &[LifecycleEvent],
811    ) -> Self {
812        let expected_lifecycle = events.iter().map(LifecycleEvent::to_method_id).collect();
813
814        Self {
815            id,
816            expected_lifecycle,
817            frame_id: frame,
818            loader_id,
819            same_document_navigation: false,
820        }
821    }
822
823    /// Wait for "load"
824    pub fn until_load(id: NavigationId, frame: FrameId, loader_id: Option<LoaderId>) -> Self {
825        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::Load])
826    }
827
828    /// Wait for DOMContentLoaded
829    pub fn until_domcontent_loaded(
830        id: NavigationId,
831        frame: FrameId,
832        loader_id: Option<LoaderId>,
833    ) -> Self {
834        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::DomcontentLoaded])
835    }
836
837    /// Wait for networkIdle
838    pub fn until_network_idle(
839        id: NavigationId,
840        frame: FrameId,
841        loader_id: Option<LoaderId>,
842    ) -> Self {
843        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkIdle])
844    }
845
846    /// Wait for networkAlmostIdle
847    pub fn until_network_almost_idle(
848        id: NavigationId,
849        frame: FrameId,
850        loader_id: Option<LoaderId>,
851    ) -> Self {
852        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkAlmostIdle])
853    }
854
855    /// (optional) Wait for multiple states, e.g. DOMContentLoaded + networkIdle
856    pub fn until_domcontent_and_network_idle(
857        id: NavigationId,
858        frame: FrameId,
859        loader_id: Option<LoaderId>,
860    ) -> Self {
861        Self::until_lifecycle(
862            id,
863            frame,
864            loader_id,
865            &[
866                LifecycleEvent::DomcontentLoaded,
867                LifecycleEvent::NetworkIdle,
868            ],
869        )
870    }
871
872    /// Checks whether the navigation was completed
873    pub fn is_lifecycle_complete(&self) -> bool {
874        self.expected_lifecycle.is_empty()
875    }
876
877    fn on_frame_navigated_within_document(&mut self, ev: &EventNavigatedWithinDocument) {
878        if self.frame_id == ev.frame_id {
879            self.same_document_navigation = true;
880        }
881    }
882}
883
884/// An identifier for an ongoing navigation
885#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
886pub struct NavigationId(pub usize);
887
888/// Represents a the request for a navigation
889#[derive(Debug)]
890pub struct FrameRequestedNavigation {
891    /// The internal identifier
892    pub id: NavigationId,
893    /// the cdp request that will trigger the navigation
894    pub req: Request,
895    /// The timeout after which the request will be considered timed out
896    pub timeout: Duration,
897}
898
899impl FrameRequestedNavigation {
900    pub fn new(id: NavigationId, req: Request, request_timeout: Duration) -> Self {
901        Self {
902            id,
903            req,
904            timeout: request_timeout,
905        }
906    }
907
908    /// This will set the id of the frame into the `params` `frameId` field.
909    pub fn set_frame_id(&mut self, frame_id: FrameId) {
910        if let Some(params) = self.req.params.as_object_mut() {
911            if let Entry::Vacant(entry) = params.entry("frameId") {
912                entry.insert(serde_json::Value::String(frame_id.into()));
913            }
914        }
915    }
916}
917
918#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
919pub enum LifecycleEvent {
920    #[default]
921    Load,
922    DomcontentLoaded,
923    NetworkIdle,
924    NetworkAlmostIdle,
925}
926
927impl LifecycleEvent {
928    #[inline]
929    pub fn to_method_id(&self) -> MethodId {
930        match self {
931            LifecycleEvent::Load => "load".into(),
932            LifecycleEvent::DomcontentLoaded => "DOMContentLoaded".into(),
933            LifecycleEvent::NetworkIdle => "networkIdle".into(),
934            LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle".into(),
935        }
936    }
937}
938
939impl AsRef<str> for LifecycleEvent {
940    fn as_ref(&self) -> &str {
941        match self {
942            LifecycleEvent::Load => "load",
943            LifecycleEvent::DomcontentLoaded => "DOMContentLoaded",
944            LifecycleEvent::NetworkIdle => "networkIdle",
945            LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle",
946        }
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    #[test]
955    fn frame_lifecycle_events_cleared_on_loading_started() {
956        let mut frame = Frame::new(FrameId::new("test"));
957
958        // Simulate a loaded page.
959        frame.lifecycle_events.insert("load".into());
960        frame.lifecycle_events.insert("DOMContentLoaded".into());
961        assert!(frame.is_loaded());
962
963        // Browser fires FrameStartedLoading → on_loading_started clears lifecycle.
964        frame.on_loading_started();
965        assert!(!frame.is_loaded());
966    }
967
968    #[test]
969    fn frame_loading_stopped_inserts_load_events() {
970        let mut frame = Frame::new(FrameId::new("test"));
971        assert!(!frame.is_loaded());
972
973        frame.on_loading_stopped();
974        assert!(frame.is_loaded());
975    }
976
977    #[test]
978    fn navigation_completes_when_main_frame_loaded_despite_child_frames() {
979        let timeout = Duration::from_secs(30);
980        let mut fm = FrameManager::new(timeout);
981
982        // Set up main frame with "load" lifecycle event.
983        let main_id = FrameId::new("main");
984        let mut main_frame = Frame::new(main_id.clone());
985        main_frame.loader_id = Some(LoaderId::from("loader-old".to_string()));
986        main_frame.lifecycle_events.insert("load".into());
987        fm.frames.insert(main_id.clone(), main_frame);
988        fm.main_frame = Some(main_id.clone());
989
990        // Attach a child frame that has NOT received "load" (e.g. a stuck ad iframe).
991        let child_id = FrameId::new("child-ad");
992        let child = Frame::with_parent(child_id.clone(), fm.frames.get_mut(&main_id).unwrap());
993        fm.frames.insert(child_id, child);
994
995        // Build a watcher that waits for "load" on the main frame.
996        let watcher = NavigationWatcher::until_load(
997            NavigationId(0),
998            main_id.clone(),
999            Some(LoaderId::from("loader-old".to_string())),
1000        );
1001
1002        // Simulate a new loader (navigation happened).
1003        fm.frames.get_mut(&main_id).unwrap().loader_id =
1004            Some(LoaderId::from("loader-new".to_string()));
1005
1006        // Navigation should complete because main frame has "load",
1007        // even though the child frame does not.
1008        let main_frame = fm.frames.get(&main_id).unwrap();
1009        let result = fm.check_lifecycle_complete(&watcher, main_frame);
1010        assert!(
1011            result.is_some(),
1012            "navigation should complete without waiting for child frames"
1013        );
1014    }
1015
1016    #[test]
1017    fn navigation_watcher_tracks_main_frame_id_change() {
1018        let timeout = Duration::from_secs(30);
1019        let mut fm = FrameManager::new(timeout);
1020
1021        // Set up main frame with old ID.
1022        let old_id = FrameId::new("old-main");
1023        let mut main_frame = Frame::new(old_id.clone());
1024        main_frame.loader_id = Some(LoaderId::from("loader-1".to_string()));
1025        fm.frames.insert(old_id.clone(), main_frame);
1026        fm.main_frame = Some(old_id.clone());
1027
1028        // Manually insert a navigation watcher referencing the old frame ID
1029        // (simulates what navigate_frame does after queuing a request).
1030        let watcher = NavigationWatcher::until_load(
1031            NavigationId(0),
1032            old_id.clone(),
1033            Some(LoaderId::from("loader-1".to_string())),
1034        );
1035        let deadline = Instant::now() + timeout;
1036        fm.navigation = Some((watcher, deadline));
1037
1038        // Simulate cross-origin redirect: main frame ID changes.
1039        // Directly manipulate the frame map to simulate on_frame_navigated
1040        // with a new main frame ID (avoids constructing the full CdpFrame).
1041        let new_id = FrameId::new("new-main");
1042        if let Some(mut old_frame) = fm.frames.remove(&old_id) {
1043            old_frame.child_frames.clear();
1044            old_frame.id = new_id.clone();
1045            fm.frames.insert(new_id.clone(), old_frame);
1046        }
1047        fm.main_frame = Some(new_id.clone());
1048
1049        // Update the watcher the same way on_frame_navigated does.
1050        if let Some((watcher, _)) = fm.navigation.as_mut() {
1051            if watcher.frame_id == old_id {
1052                watcher.frame_id = new_id.clone();
1053            }
1054        }
1055
1056        // The active watcher should now track the new frame ID.
1057        let (watcher, _) = fm.navigation.as_ref().unwrap();
1058        assert_eq!(
1059            watcher.frame_id, new_id,
1060            "watcher should follow the main frame ID change"
1061        );
1062
1063        // Simulate lifecycle events on the new frame so navigation completes.
1064        fm.frames.get_mut(&new_id).unwrap().loader_id =
1065            Some(LoaderId::from("loader-2".to_string()));
1066        fm.frames
1067            .get_mut(&new_id)
1068            .unwrap()
1069            .lifecycle_events
1070            .insert("load".into());
1071
1072        let event = fm.poll(Instant::now());
1073        assert!(
1074            matches!(event, Some(FrameEvent::NavigationResult(Ok(_)))),
1075            "navigation should complete on the new frame"
1076        );
1077    }
1078
1079    // ── Main-frame navigation-loop guard ─────────────────────────────
1080
1081    fn seed_main_frame(fm: &mut FrameManager, loader: &str) -> FrameId {
1082        let id = FrameId::new("main");
1083        let mut frame = Frame::new(id.clone());
1084        frame.loader_id = Some(LoaderId::from(loader.to_string()));
1085        fm.frames.insert(id.clone(), frame);
1086        fm.main_frame = Some(id.clone());
1087        id
1088    }
1089
1090    fn active_watcher(fm: &mut FrameManager, frame_id: FrameId) {
1091        let watcher = NavigationWatcher::until_load(
1092            NavigationId(0),
1093            frame_id.clone(),
1094            fm.frames.get(&frame_id).and_then(|f| f.loader_id.clone()),
1095        );
1096        let deadline = Instant::now() + Duration::from_secs(30);
1097        fm.navigation = Some((watcher, deadline));
1098    }
1099
1100    #[test]
1101    fn nav_loop_guard_none_allows_unlimited() {
1102        let mut fm = FrameManager::new(Duration::from_secs(30));
1103        // Default: max_main_frame_navigations = None.
1104        let id = seed_main_frame(&mut fm, "loader-0");
1105        active_watcher(&mut fm, id);
1106
1107        // Simulate 25 main-frame navigations — no cap → no error.
1108        for _ in 0..25 {
1109            fm.main_frame_nav_count = fm.main_frame_nav_count.saturating_add(1);
1110        }
1111
1112        // poll should not trip on count; only deadline/lifecycle drive it.
1113        // (Lifecycle is incomplete so poll returns None here, but critically
1114        // no NavigationError is emitted.)
1115        let event = fm.poll(Instant::now());
1116        assert!(
1117            !matches!(
1118                event,
1119                Some(FrameEvent::NavigationResult(Err(
1120                    NavigationError::TooManyNavigations { .. }
1121                )))
1122            ),
1123            "None cap must never emit TooManyNavigations"
1124        );
1125    }
1126
1127    #[test]
1128    fn nav_loop_guard_caps_and_reports_count() {
1129        let mut fm = FrameManager::new(Duration::from_secs(30));
1130        fm.set_max_main_frame_navigations(Some(3));
1131        let id = seed_main_frame(&mut fm, "loader-0");
1132        active_watcher(&mut fm, id);
1133
1134        // Simulate 5 main-frame navigations — cap is 3, so 4+ trips.
1135        for _ in 0..5 {
1136            fm.main_frame_nav_count = fm.main_frame_nav_count.saturating_add(1);
1137        }
1138
1139        match fm.poll(Instant::now()) {
1140            Some(FrameEvent::NavigationResult(Err(NavigationError::TooManyNavigations {
1141                count,
1142                ..
1143            }))) => {
1144                assert_eq!(count, 5, "reported count must be the observed value");
1145            }
1146            other => panic!("expected TooManyNavigations, got {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn nav_loop_guard_resets_on_goto() {
1152        let mut fm = FrameManager::new(Duration::from_secs(30));
1153        fm.set_max_main_frame_navigations(Some(3));
1154        let id = seed_main_frame(&mut fm, "loader-0");
1155        active_watcher(&mut fm, id.clone());
1156
1157        // Trip the cap on the first goto.
1158        fm.main_frame_nav_count = 10;
1159        assert!(matches!(
1160            fm.poll(Instant::now()),
1161            Some(FrameEvent::NavigationResult(Err(
1162                NavigationError::TooManyNavigations { .. }
1163            )))
1164        ));
1165
1166        // Fresh goto must reset the counter.
1167        fm.navigate_frame(
1168            id.clone(),
1169            FrameRequestedNavigation::new(
1170                NavigationId(1),
1171                Request::new("Page.navigate".into(), serde_json::json!({})),
1172                Duration::from_secs(30),
1173            ),
1174        );
1175        assert_eq!(
1176            fm.main_frame_nav_count, 0,
1177            "navigate_frame must reset the main-frame nav counter"
1178        );
1179    }
1180
1181    #[test]
1182    fn nav_loop_guard_same_document_not_counted() {
1183        // Same-document navigations (hash changes, History.pushState) land in
1184        // `on_frame_navigated_within_document`, not `on_frame_navigated`, so
1185        // they must NOT increment the cross-document counter. This test
1186        // encodes that contract — if `on_frame_navigated_within_document`
1187        // ever starts incrementing `main_frame_nav_count`, it fails.
1188        let mut fm = FrameManager::new(Duration::from_secs(30));
1189        fm.set_max_main_frame_navigations(Some(2));
1190        let id = seed_main_frame(&mut fm, "loader-0");
1191
1192        // Seed an active navigation watcher on this frame.
1193        active_watcher(&mut fm, id.clone());
1194
1195        // Dispatch 10 same-document navigations.
1196        for _ in 0..10 {
1197            fm.on_frame_navigated_within_document(
1198                &chromiumoxide_cdp::cdp::browser_protocol::page::EventNavigatedWithinDocument {
1199                    frame_id: id.clone(),
1200                    url: "https://example.com/#a".into(),
1201                    navigation_type:
1202                        chromiumoxide_cdp::cdp::browser_protocol::page::NavigatedWithinDocumentNavigationType::Fragment,
1203                },
1204            );
1205        }
1206
1207        assert_eq!(
1208            fm.main_frame_nav_count, 0,
1209            "same-document navigations must not count against the cross-document cap"
1210        );
1211    }
1212
1213    #[test]
1214    fn execution_contexts_cleared_resets_isolated_worlds() {
1215        // When Chrome fires `executionContextsCleared`, the isolated worlds
1216        // we had ensured are gone along with every execution context.
1217        // `isolated_worlds` must be cleared so the next `ensure_isolated_world`
1218        // call re-issues the creation command rather than short-circuiting on
1219        // stale membership.
1220        let mut fm = FrameManager::new(Duration::from_secs(30));
1221
1222        let frame_id = FrameId::new("main");
1223        let frame = Frame::new(frame_id.clone());
1224        let world_name = frame.get_isolated_world_name().clone();
1225        fm.frames.insert(frame_id.clone(), frame);
1226        fm.main_frame = Some(frame_id);
1227
1228        // First call: the world isn't in the set, so we should produce a
1229        // command chain AND record membership.
1230        let first = fm.ensure_isolated_world(&world_name);
1231        assert!(
1232            first.is_some(),
1233            "first ensure_isolated_world must emit a creation command chain"
1234        );
1235        assert!(
1236            fm.isolated_worlds.contains(&world_name),
1237            "isolated_worlds must record the ensured world"
1238        );
1239
1240        // Second call: short-circuits because the world is already ensured.
1241        let second = fm.ensure_isolated_world(&world_name);
1242        assert!(
1243            second.is_none(),
1244            "second ensure_isolated_world must short-circuit while membership is present"
1245        );
1246
1247        // Chrome signals that every execution context was wiped.
1248        fm.on_execution_contexts_cleared();
1249        assert!(
1250            fm.context_ids.is_empty(),
1251            "context_ids must be cleared after executionContextsCleared"
1252        );
1253        assert!(
1254            fm.isolated_worlds.is_empty(),
1255            "isolated_worlds must be cleared after executionContextsCleared"
1256        );
1257
1258        // Third call: must re-issue the creation command because the isolated
1259        // world no longer exists in Chrome.
1260        let third = fm.ensure_isolated_world(&world_name);
1261        assert!(
1262            third.is_some(),
1263            "ensure_isolated_world must re-emit a creation chain after a context wipe"
1264        );
1265        assert!(
1266            fm.isolated_worlds.contains(&world_name),
1267            "isolated_worlds must re-record the world after re-ensuring"
1268        );
1269    }
1270}