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    /// Main frame + child frames have fired the `networkIdle` lifecycle event.
193    pub fn is_network_idle(&self) -> bool {
194        self.lifecycle_events.contains("networkIdle")
195    }
196
197    /// Main frame + child frames have fired the `networkAlmostIdle` lifecycle event.
198    pub fn is_network_almost_idle(&self) -> bool {
199        self.lifecycle_events.contains("networkAlmostIdle")
200    }
201
202    pub fn clear_contexts(&mut self) {
203        self.main_world.take_context();
204        self.secondary_world.take_context();
205    }
206
207    pub fn destroy_context(&mut self, ctx_unique_id: &str) {
208        if self.main_world.execution_context_unique_id() == Some(ctx_unique_id) {
209            self.main_world.take_context();
210        } else if self.secondary_world.execution_context_unique_id() == Some(ctx_unique_id) {
211            self.secondary_world.take_context();
212        }
213    }
214
215    pub fn execution_context(&self) -> Option<ExecutionContextId> {
216        self.main_world.execution_context()
217    }
218
219    pub fn set_request(&mut self, request: HttpRequest) {
220        self.http_request = Some(Arc::new(request))
221    }
222}
223
224/// Maintains the state of the pages frame and listens to events produced by
225/// chromium targeting the `Target`. Also listens for events that indicate that
226/// a navigation was completed
227#[derive(Debug)]
228pub struct FrameManager {
229    main_frame: Option<FrameId>,
230    frames: HashMap<FrameId, Frame>,
231    /// The contexts mapped with their frames
232    context_ids: HashMap<String, FrameId>,
233    isolated_worlds: HashSet<String>,
234    /// Timeout after which an anticipated event (related to navigation) doesn't
235    /// arrive results in an error
236    request_timeout: Duration,
237    /// Track currently in progress navigation
238    pending_navigations: VecDeque<(FrameRequestedNavigation, NavigationWatcher)>,
239    /// The currently ongoing navigation
240    navigation: Option<(NavigationWatcher, Instant)>,
241}
242
243impl FrameManager {
244    pub fn new(request_timeout: Duration) -> Self {
245        FrameManager {
246            main_frame: None,
247            frames: Default::default(),
248            context_ids: Default::default(),
249            isolated_worlds: Default::default(),
250            request_timeout,
251            pending_navigations: Default::default(),
252            navigation: None,
253        }
254    }
255
256    /// The commands to execute in order to initialize this frame manager
257    pub fn init_commands(timeout: Duration) -> CommandChain {
258        let enable = page::EnableParams::default();
259        let get_tree = page::GetFrameTreeParams::default();
260        let set_lifecycle = page::SetLifecycleEventsEnabledParams::new(true);
261        // let enable_runtime = EnableParams::default();
262        // let disable_runtime = DisableParams::default();
263
264        let mut commands = Vec::with_capacity(3);
265
266        let enable_id = enable.identifier();
267        let get_tree_id = get_tree.identifier();
268        let set_lifecycle_id = set_lifecycle.identifier();
269        // let enable_runtime_id = enable_runtime.identifier();
270        // let disable_runtime_id = disable_runtime.identifier();
271
272        if let Ok(value) = serde_json::to_value(enable) {
273            commands.push((enable_id, value));
274        }
275
276        if let Ok(value) = serde_json::to_value(get_tree) {
277            commands.push((get_tree_id, value));
278        }
279
280        if let Ok(value) = serde_json::to_value(set_lifecycle) {
281            commands.push((set_lifecycle_id, value));
282        }
283
284        // if let Ok(value) = serde_json::to_value(enable_runtime) {
285        //     commands.push((enable_runtime_id, value));
286        // }
287
288        // if let Ok(value) = serde_json::to_value(disable_runtime) {
289        //     commands.push((disable_runtime_id, value));
290        // }
291
292        CommandChain::new(commands, timeout)
293    }
294
295    pub fn main_frame(&self) -> Option<&Frame> {
296        self.main_frame.as_ref().and_then(|id| self.frames.get(id))
297    }
298
299    pub fn main_frame_mut(&mut self) -> Option<&mut Frame> {
300        if let Some(id) = self.main_frame.as_ref() {
301            self.frames.get_mut(id)
302        } else {
303            None
304        }
305    }
306
307    /// Get the main isolated world name.
308    pub fn get_isolated_world_name(&self) -> Option<&String> {
309        self.main_frame
310            .as_ref()
311            .and_then(|id| self.frames.get(id).map(|fid| fid.get_isolated_world_name()))
312    }
313
314    pub fn frames(&self) -> impl Iterator<Item = &Frame> + '_ {
315        self.frames.values()
316    }
317
318    pub fn frame(&self, id: &FrameId) -> Option<&Frame> {
319        self.frames.get(id)
320    }
321
322    fn check_lifecycle(&self, watcher: &NavigationWatcher, frame: &Frame) -> bool {
323        watcher.expected_lifecycle.iter().all(|ev| {
324            frame.lifecycle_events.contains(ev)
325                || (frame.url.is_none() && frame.lifecycle_events.contains("DOMContentLoaded"))
326        })
327    }
328
329    fn check_lifecycle_complete(
330        &self,
331        watcher: &NavigationWatcher,
332        frame: &Frame,
333    ) -> Option<NavigationOk> {
334        if !self.check_lifecycle(watcher, frame) {
335            return None;
336        }
337        if frame.loader_id == watcher.loader_id && !watcher.same_document_navigation {
338            return None;
339        }
340        if watcher.same_document_navigation {
341            return Some(NavigationOk::SameDocumentNavigation(watcher.id));
342        }
343        if frame.loader_id != watcher.loader_id {
344            return Some(NavigationOk::NewDocumentNavigation(watcher.id));
345        }
346        None
347    }
348
349    /// Track the request in the frame
350    pub fn on_http_request_finished(&mut self, request: HttpRequest) {
351        if let Some(id) = request.frame.as_ref() {
352            if let Some(frame) = self.frames.get_mut(id) {
353                frame.set_request(request);
354            }
355        }
356    }
357
358    pub fn poll(&mut self, now: Instant) -> Option<FrameEvent> {
359        // check if the navigation completed
360        if let Some((watcher, deadline)) = self.navigation.take() {
361            if now > deadline {
362                // navigation request timed out
363                return Some(FrameEvent::NavigationResult(Err(
364                    NavigationError::Timeout {
365                        err: DeadlineExceeded::new(now, deadline),
366                        id: watcher.id,
367                    },
368                )));
369            }
370
371            if let Some(frame) = self.frames.get(&watcher.frame_id) {
372                if let Some(nav) = self.check_lifecycle_complete(&watcher, frame) {
373                    // request is complete if the frame's lifecycle is complete = frame received all
374                    // required events
375                    return Some(FrameEvent::NavigationResult(Ok(nav)));
376                } else {
377                    // not finished yet
378                    self.navigation = Some((watcher, deadline));
379                }
380            } else {
381                return Some(FrameEvent::NavigationResult(Err(
382                    NavigationError::FrameNotFound {
383                        frame: watcher.frame_id,
384                        id: watcher.id,
385                    },
386                )));
387            }
388        } else if let Some((req, watcher)) = self.pending_navigations.pop_front() {
389            // queue in the next navigation that is must be fulfilled until `deadline`
390            let deadline = Instant::now() + req.timeout;
391            self.navigation = Some((watcher, deadline));
392            return Some(FrameEvent::NavigationRequest(req.id, req.req));
393        }
394        None
395    }
396
397    /// Entrypoint for page navigation
398    pub fn goto(&mut self, req: FrameRequestedNavigation) {
399        if let Some(frame_id) = &self.main_frame {
400            self.navigate_frame(frame_id.clone(), req);
401        }
402    }
403
404    /// Navigate a specific frame
405    pub fn navigate_frame(&mut self, frame_id: FrameId, mut req: FrameRequestedNavigation) {
406        let loader_id = self.frames.get(&frame_id).and_then(|f| f.loader_id.clone());
407        let watcher = NavigationWatcher::until_load(req.id, frame_id.clone(), loader_id);
408
409        // insert the frame_id in the request if not present
410        req.set_frame_id(frame_id);
411
412        self.pending_navigations.push_back((req, watcher))
413    }
414
415    /// Fired when a frame moved to another session
416    pub fn on_attached_to_target(&mut self, _event: &EventAttachedToTarget) {
417        // _onFrameMoved
418    }
419
420    pub fn on_frame_tree(&mut self, frame_tree: FrameTree) {
421        self.on_frame_attached(
422            frame_tree.frame.id.clone(),
423            frame_tree.frame.parent_id.clone(),
424        );
425        self.on_frame_navigated(&frame_tree.frame);
426        if let Some(children) = frame_tree.child_frames {
427            for child_tree in children {
428                self.on_frame_tree(child_tree);
429            }
430        }
431    }
432
433    pub fn on_frame_attached(&mut self, frame_id: FrameId, parent_frame_id: Option<FrameId>) {
434        if self.frames.contains_key(&frame_id) {
435            return;
436        }
437        if let Some(parent_frame_id) = parent_frame_id {
438            if let Some(parent_frame) = self.frames.get_mut(&parent_frame_id) {
439                let frame = Frame::with_parent(frame_id.clone(), parent_frame);
440                self.frames.insert(frame_id, frame);
441            }
442        }
443    }
444
445    pub fn on_frame_detached(&mut self, event: &EventFrameDetached) {
446        self.remove_frames_recursively(&event.frame_id);
447    }
448
449    pub fn on_frame_navigated(&mut self, frame: &CdpFrame) {
450        if frame.parent_id.is_some() {
451            if let Some((id, mut f)) = self.frames.remove_entry(&frame.id) {
452                for child in f.child_frames.drain() {
453                    self.remove_frames_recursively(&child);
454                }
455                f.navigated(frame);
456                self.frames.insert(id, f);
457            }
458        } else {
459            let old_main = self.main_frame.take();
460            let mut f = if let Some(main) = old_main.as_ref() {
461                // update main frame
462                if let Some(mut main_frame) = self.frames.remove(main) {
463                    for child in &main_frame.child_frames {
464                        self.remove_frames_recursively(child);
465                    }
466                    // this is necessary since we can't borrow mut and then remove recursively
467                    main_frame.child_frames.clear();
468                    main_frame.id = frame.id.clone();
469                    main_frame
470                } else {
471                    Frame::new(frame.id.clone())
472                }
473            } else {
474                // initial main frame navigation
475                Frame::new(frame.id.clone())
476            };
477            f.navigated(frame);
478            let new_id = f.id.clone();
479            self.main_frame = Some(new_id.clone());
480            self.frames.insert(new_id.clone(), f);
481
482            // When the main frame ID changes (e.g. cross-origin redirect), update the
483            // active navigation watcher so it tracks the new frame instead of the stale ID.
484            if old_main.as_ref() != Some(&new_id) {
485                if let Some((watcher, _)) = self.navigation.as_mut() {
486                    if old_main.as_ref() == Some(&watcher.frame_id) {
487                        watcher.frame_id = new_id;
488                    }
489                }
490            }
491        }
492    }
493
494    pub fn on_frame_navigated_within_document(&mut self, event: &EventNavigatedWithinDocument) {
495        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
496            frame.navigated_within_url(event.url.clone());
497        }
498        if let Some((watcher, _)) = self.navigation.as_mut() {
499            watcher.on_frame_navigated_within_document(event);
500        }
501    }
502
503    pub fn on_frame_stopped_loading(&mut self, event: &EventFrameStoppedLoading) {
504        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
505            frame.on_loading_stopped();
506        }
507    }
508
509    /// Fired when frame has started loading.
510    pub fn on_frame_started_loading(&mut self, event: &EventFrameStartedLoading) {
511        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
512            frame.on_loading_started();
513        }
514    }
515
516    /// Notification is issued every time when binding is called
517    pub fn on_runtime_binding_called(&mut self, _ev: &EventBindingCalled) {}
518
519    /// Issued when new execution context is created
520    pub fn on_frame_execution_context_created(&mut self, event: &EventExecutionContextCreated) {
521        if let Some(frame_id) = event
522            .context
523            .aux_data
524            .as_ref()
525            .and_then(|v| v["frameId"].as_str())
526        {
527            if let Some(frame) = self.frames.get_mut(frame_id) {
528                if event
529                    .context
530                    .aux_data
531                    .as_ref()
532                    .and_then(|v| v["isDefault"].as_bool())
533                    .unwrap_or_default()
534                {
535                    frame
536                        .main_world
537                        .set_context(event.context.id, event.context.unique_id.clone());
538                } else if event.context.name == frame.isolated_world_name
539                    && frame.secondary_world.execution_context().is_none()
540                {
541                    frame
542                        .secondary_world
543                        .set_context(event.context.id, event.context.unique_id.clone());
544                }
545                self.context_ids
546                    .insert(event.context.unique_id.clone(), frame.id.clone());
547            }
548        }
549        if event
550            .context
551            .aux_data
552            .as_ref()
553            .filter(|v| v["type"].as_str() == Some("isolated"))
554            .is_some()
555        {
556            self.isolated_worlds.insert(event.context.name.clone());
557        }
558    }
559
560    /// Issued when execution context is destroyed
561    pub fn on_frame_execution_context_destroyed(&mut self, event: &EventExecutionContextDestroyed) {
562        if let Some(id) = self.context_ids.remove(&event.execution_context_unique_id) {
563            if let Some(frame) = self.frames.get_mut(&id) {
564                frame.destroy_context(&event.execution_context_unique_id);
565            }
566        }
567    }
568
569    /// Issued when all executionContexts were cleared
570    pub fn on_execution_contexts_cleared(&mut self) {
571        for id in self.context_ids.values() {
572            if let Some(frame) = self.frames.get_mut(id) {
573                frame.clear_contexts();
574            }
575        }
576        self.context_ids.clear()
577    }
578
579    /// Remove `context_ids` entries that reference frames which no longer
580    /// exist.  Called periodically from the handler's eviction tick — a
581    /// single O(n) pass instead of per-frame cleanup during recursive removal.
582    pub fn evict_stale_context_ids(&mut self) {
583        if !self.context_ids.is_empty() {
584            self.context_ids
585                .retain(|_, fid| self.frames.contains_key(fid));
586        }
587    }
588
589    /// Fired for top level page lifecycle events (nav, load, paint, etc.)
590    pub fn on_page_lifecycle_event(&mut self, event: &EventLifecycleEvent) {
591        if let Some(frame) = self.frames.get_mut(&event.frame_id) {
592            if event.name == "init" {
593                frame.loader_id = Some(event.loader_id.clone());
594                frame.lifecycle_events.clear();
595            }
596            frame.lifecycle_events.insert(event.name.clone().into());
597        }
598    }
599
600    /// Detach all child frames.
601    fn remove_frames_recursively(&mut self, id: &FrameId) -> Option<Frame> {
602        if let Some(mut frame) = self.frames.remove(id) {
603            for child in &frame.child_frames {
604                self.remove_frames_recursively(child);
605            }
606            if let Some(parent_id) = frame.parent_frame.take() {
607                if let Some(parent) = self.frames.get_mut(&parent_id) {
608                    parent.child_frames.remove(&frame.id);
609                }
610            }
611            Some(frame)
612        } else {
613            None
614        }
615    }
616
617    pub fn ensure_isolated_world(&mut self, world_name: &str) -> Option<CommandChain> {
618        if self.isolated_worlds.contains(world_name) {
619            return None;
620        }
621
622        self.isolated_worlds.insert(world_name.to_string());
623
624        if let Ok(cmd) = AddScriptToEvaluateOnNewDocumentParams::builder()
625            .source(format!("//# sourceURL={}", *EVALUATION_SCRIPT_URL))
626            .world_name(world_name)
627            .build()
628        {
629            let mut cmds = Vec::with_capacity(self.frames.len() + 1);
630            let identifier = cmd.identifier();
631
632            if let Ok(cmd) = serde_json::to_value(cmd) {
633                cmds.push((identifier, cmd));
634            }
635
636            let cm = self.frames.keys().filter_map(|id| {
637                if let Ok(cmd) = CreateIsolatedWorldParams::builder()
638                    .frame_id(id.clone())
639                    .grant_univeral_access(true)
640                    .world_name(world_name)
641                    .build()
642                {
643                    let cm = (
644                        cmd.identifier(),
645                        serde_json::to_value(cmd).unwrap_or_default(),
646                    );
647
648                    Some(cm)
649                } else {
650                    None
651                }
652            });
653
654            cmds.extend(cm);
655
656            Some(CommandChain::new(cmds, self.request_timeout))
657        } else {
658            None
659        }
660    }
661}
662
663#[derive(Debug)]
664pub enum FrameEvent {
665    /// A previously submitted navigation has finished
666    NavigationResult(Result<NavigationOk, NavigationError>),
667    /// A new navigation request needs to be submitted
668    NavigationRequest(NavigationId, Request),
669    /* /// The initial page of the target has been loaded
670     * InitialPageLoadFinished */
671}
672
673#[derive(Debug)]
674pub enum NavigationError {
675    Timeout {
676        id: NavigationId,
677        err: DeadlineExceeded,
678    },
679    FrameNotFound {
680        id: NavigationId,
681        frame: FrameId,
682    },
683}
684
685impl NavigationError {
686    pub fn navigation_id(&self) -> &NavigationId {
687        match self {
688            NavigationError::Timeout { id, .. } => id,
689            NavigationError::FrameNotFound { id, .. } => id,
690        }
691    }
692}
693
694#[derive(Debug, Clone, Eq, PartialEq)]
695pub enum NavigationOk {
696    SameDocumentNavigation(NavigationId),
697    NewDocumentNavigation(NavigationId),
698}
699
700impl NavigationOk {
701    pub fn navigation_id(&self) -> &NavigationId {
702        match self {
703            NavigationOk::SameDocumentNavigation(id) => id,
704            NavigationOk::NewDocumentNavigation(id) => id,
705        }
706    }
707}
708
709/// Tracks the progress of an issued `Page.navigate` request until completion.
710#[derive(Debug)]
711pub struct NavigationWatcher {
712    id: NavigationId,
713    expected_lifecycle: HashSet<MethodId>,
714    frame_id: FrameId,
715    loader_id: Option<LoaderId>,
716    /// Once we receive the response to the issued `Page.navigate` request we
717    /// can detect whether we were navigating withing the same document or were
718    /// navigating to a new document by checking if a loader was included in the
719    /// response.
720    same_document_navigation: bool,
721}
722
723impl NavigationWatcher {
724    /// Generic ctor: wait until all given lifecycle events have fired
725    /// (including all child frames).
726    pub fn until_lifecycle(
727        id: NavigationId,
728        frame: FrameId,
729        loader_id: Option<LoaderId>,
730        events: &[LifecycleEvent],
731    ) -> Self {
732        let expected_lifecycle = events.iter().map(LifecycleEvent::to_method_id).collect();
733
734        Self {
735            id,
736            expected_lifecycle,
737            frame_id: frame,
738            loader_id,
739            same_document_navigation: false,
740        }
741    }
742
743    /// Wait for "load"
744    pub fn until_load(id: NavigationId, frame: FrameId, loader_id: Option<LoaderId>) -> Self {
745        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::Load])
746    }
747
748    /// Wait for DOMContentLoaded
749    pub fn until_domcontent_loaded(
750        id: NavigationId,
751        frame: FrameId,
752        loader_id: Option<LoaderId>,
753    ) -> Self {
754        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::DomcontentLoaded])
755    }
756
757    /// Wait for networkIdle
758    pub fn until_network_idle(
759        id: NavigationId,
760        frame: FrameId,
761        loader_id: Option<LoaderId>,
762    ) -> Self {
763        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkIdle])
764    }
765
766    /// Wait for networkAlmostIdle
767    pub fn until_network_almost_idle(
768        id: NavigationId,
769        frame: FrameId,
770        loader_id: Option<LoaderId>,
771    ) -> Self {
772        Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkAlmostIdle])
773    }
774
775    /// (optional) Wait for multiple states, e.g. DOMContentLoaded + networkIdle
776    pub fn until_domcontent_and_network_idle(
777        id: NavigationId,
778        frame: FrameId,
779        loader_id: Option<LoaderId>,
780    ) -> Self {
781        Self::until_lifecycle(
782            id,
783            frame,
784            loader_id,
785            &[
786                LifecycleEvent::DomcontentLoaded,
787                LifecycleEvent::NetworkIdle,
788            ],
789        )
790    }
791
792    /// Checks whether the navigation was completed
793    pub fn is_lifecycle_complete(&self) -> bool {
794        self.expected_lifecycle.is_empty()
795    }
796
797    fn on_frame_navigated_within_document(&mut self, ev: &EventNavigatedWithinDocument) {
798        if self.frame_id == ev.frame_id {
799            self.same_document_navigation = true;
800        }
801    }
802}
803
804/// An identifier for an ongoing navigation
805#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
806pub struct NavigationId(pub usize);
807
808/// Represents a the request for a navigation
809#[derive(Debug)]
810pub struct FrameRequestedNavigation {
811    /// The internal identifier
812    pub id: NavigationId,
813    /// the cdp request that will trigger the navigation
814    pub req: Request,
815    /// The timeout after which the request will be considered timed out
816    pub timeout: Duration,
817}
818
819impl FrameRequestedNavigation {
820    pub fn new(id: NavigationId, req: Request, request_timeout: Duration) -> Self {
821        Self {
822            id,
823            req,
824            timeout: request_timeout,
825        }
826    }
827
828    /// This will set the id of the frame into the `params` `frameId` field.
829    pub fn set_frame_id(&mut self, frame_id: FrameId) {
830        if let Some(params) = self.req.params.as_object_mut() {
831            if let Entry::Vacant(entry) = params.entry("frameId") {
832                entry.insert(serde_json::Value::String(frame_id.into()));
833            }
834        }
835    }
836}
837
838#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
839pub enum LifecycleEvent {
840    #[default]
841    Load,
842    DomcontentLoaded,
843    NetworkIdle,
844    NetworkAlmostIdle,
845}
846
847impl LifecycleEvent {
848    #[inline]
849    pub fn to_method_id(&self) -> MethodId {
850        match self {
851            LifecycleEvent::Load => "load".into(),
852            LifecycleEvent::DomcontentLoaded => "DOMContentLoaded".into(),
853            LifecycleEvent::NetworkIdle => "networkIdle".into(),
854            LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle".into(),
855        }
856    }
857}
858
859impl AsRef<str> for LifecycleEvent {
860    fn as_ref(&self) -> &str {
861        match self {
862            LifecycleEvent::Load => "load",
863            LifecycleEvent::DomcontentLoaded => "DOMContentLoaded",
864            LifecycleEvent::NetworkIdle => "networkIdle",
865            LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle",
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn frame_lifecycle_events_cleared_on_loading_started() {
876        let mut frame = Frame::new(FrameId::new("test"));
877
878        // Simulate a loaded page.
879        frame.lifecycle_events.insert("load".into());
880        frame.lifecycle_events.insert("DOMContentLoaded".into());
881        assert!(frame.is_loaded());
882
883        // Browser fires FrameStartedLoading → on_loading_started clears lifecycle.
884        frame.on_loading_started();
885        assert!(!frame.is_loaded());
886    }
887
888    #[test]
889    fn frame_loading_stopped_inserts_load_events() {
890        let mut frame = Frame::new(FrameId::new("test"));
891        assert!(!frame.is_loaded());
892
893        frame.on_loading_stopped();
894        assert!(frame.is_loaded());
895    }
896
897    #[test]
898    fn navigation_completes_when_main_frame_loaded_despite_child_frames() {
899        let timeout = Duration::from_secs(30);
900        let mut fm = FrameManager::new(timeout);
901
902        // Set up main frame with "load" lifecycle event.
903        let main_id = FrameId::new("main");
904        let mut main_frame = Frame::new(main_id.clone());
905        main_frame.loader_id = Some(LoaderId::from("loader-old".to_string()));
906        main_frame.lifecycle_events.insert("load".into());
907        fm.frames.insert(main_id.clone(), main_frame);
908        fm.main_frame = Some(main_id.clone());
909
910        // Attach a child frame that has NOT received "load" (e.g. a stuck ad iframe).
911        let child_id = FrameId::new("child-ad");
912        let child = Frame::with_parent(child_id.clone(), fm.frames.get_mut(&main_id).unwrap());
913        fm.frames.insert(child_id, child);
914
915        // Build a watcher that waits for "load" on the main frame.
916        let watcher = NavigationWatcher::until_load(
917            NavigationId(0),
918            main_id.clone(),
919            Some(LoaderId::from("loader-old".to_string())),
920        );
921
922        // Simulate a new loader (navigation happened).
923        fm.frames.get_mut(&main_id).unwrap().loader_id =
924            Some(LoaderId::from("loader-new".to_string()));
925
926        // Navigation should complete because main frame has "load",
927        // even though the child frame does not.
928        let main_frame = fm.frames.get(&main_id).unwrap();
929        let result = fm.check_lifecycle_complete(&watcher, main_frame);
930        assert!(
931            result.is_some(),
932            "navigation should complete without waiting for child frames"
933        );
934    }
935
936    #[test]
937    fn navigation_watcher_tracks_main_frame_id_change() {
938        let timeout = Duration::from_secs(30);
939        let mut fm = FrameManager::new(timeout);
940
941        // Set up main frame with old ID.
942        let old_id = FrameId::new("old-main");
943        let mut main_frame = Frame::new(old_id.clone());
944        main_frame.loader_id = Some(LoaderId::from("loader-1".to_string()));
945        fm.frames.insert(old_id.clone(), main_frame);
946        fm.main_frame = Some(old_id.clone());
947
948        // Manually insert a navigation watcher referencing the old frame ID
949        // (simulates what navigate_frame does after queuing a request).
950        let watcher = NavigationWatcher::until_load(
951            NavigationId(0),
952            old_id.clone(),
953            Some(LoaderId::from("loader-1".to_string())),
954        );
955        let deadline = Instant::now() + timeout;
956        fm.navigation = Some((watcher, deadline));
957
958        // Simulate cross-origin redirect: main frame ID changes.
959        // Directly manipulate the frame map to simulate on_frame_navigated
960        // with a new main frame ID (avoids constructing the full CdpFrame).
961        let new_id = FrameId::new("new-main");
962        if let Some(mut old_frame) = fm.frames.remove(&old_id) {
963            old_frame.child_frames.clear();
964            old_frame.id = new_id.clone();
965            fm.frames.insert(new_id.clone(), old_frame);
966        }
967        fm.main_frame = Some(new_id.clone());
968
969        // Update the watcher the same way on_frame_navigated does.
970        if let Some((watcher, _)) = fm.navigation.as_mut() {
971            if watcher.frame_id == old_id {
972                watcher.frame_id = new_id.clone();
973            }
974        }
975
976        // The active watcher should now track the new frame ID.
977        let (watcher, _) = fm.navigation.as_ref().unwrap();
978        assert_eq!(
979            watcher.frame_id, new_id,
980            "watcher should follow the main frame ID change"
981        );
982
983        // Simulate lifecycle events on the new frame so navigation completes.
984        fm.frames.get_mut(&new_id).unwrap().loader_id =
985            Some(LoaderId::from("loader-2".to_string()));
986        fm.frames
987            .get_mut(&new_id)
988            .unwrap()
989            .lifecycle_events
990            .insert("load".into());
991
992        let event = fm.poll(Instant::now());
993        assert!(
994            matches!(event, Some(FrameEvent::NavigationResult(Ok(_)))),
995            "navigation should complete on the new frame"
996        );
997    }
998}