use std::collections::VecDeque;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::map::Entry;
use chromiumoxide_cdp::cdp::browser_protocol::network::LoaderId;
use chromiumoxide_cdp::cdp::browser_protocol::page::{
AddScriptToEvaluateOnNewDocumentParams, CreateIsolatedWorldParams, EventFrameDetached,
EventFrameStartedLoading, EventFrameStoppedLoading, EventLifecycleEvent,
EventNavigatedWithinDocument, Frame as CdpFrame, FrameTree,
};
use chromiumoxide_cdp::cdp::browser_protocol::target::EventAttachedToTarget;
use chromiumoxide_cdp::cdp::js_protocol::runtime::*;
use chromiumoxide_cdp::cdp::{
browser_protocol::page::{self, FrameId},
};
use chromiumoxide_types::{Method, MethodId, Request};
use spider_fingerprint::BASE_CHROME_VERSION;
use crate::error::DeadlineExceeded;
use crate::handler::domworld::DOMWorld;
use crate::handler::http::HttpRequest;
use crate::{cmd::CommandChain, ArcHttpRequest};
lazy_static::lazy_static! {
static ref EVALUATION_SCRIPT_URL: String = format!("____{}___evaluation_script__", random_world_name(&BASE_CHROME_VERSION.to_string()));
}
pub fn random_world_name(id: &str) -> String {
use rand::RngExt;
let mut rng = rand::rng();
let rand_len = rng.random_range(6..=12);
let id_part: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(5)
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphabetic() {
c
} else {
(b'a' + (c as u8 - b'0') % 26) as char
}
})
.collect();
let rand_part: String = (0..rand_len)
.filter_map(|_| std::char::from_digit(rng.random_range(0..36), 36))
.collect();
let first = std::char::from_digit(rng.random_range(10..36), 36).unwrap_or('a');
format!("{first}{id_part}{rand_part}")
}
#[derive(Debug)]
pub struct Frame {
parent_frame: Option<FrameId>,
id: FrameId,
main_world: DOMWorld,
secondary_world: DOMWorld,
loader_id: Option<LoaderId>,
url: Option<String>,
http_request: ArcHttpRequest,
child_frames: HashSet<FrameId>,
name: Option<String>,
lifecycle_events: HashSet<MethodId>,
isolated_world_name: String,
}
impl Frame {
pub fn new(id: FrameId) -> Self {
let isolated_world_name = random_world_name(id.inner());
Self {
parent_frame: None,
id,
main_world: Default::default(),
secondary_world: Default::default(),
loader_id: None,
url: None,
http_request: None,
child_frames: Default::default(),
name: None,
lifecycle_events: Default::default(),
isolated_world_name,
}
}
pub fn with_parent(id: FrameId, parent: &mut Frame) -> Self {
parent.child_frames.insert(id.clone());
Self {
parent_frame: Some(parent.id.clone()),
id,
main_world: Default::default(),
secondary_world: Default::default(),
loader_id: None,
url: None,
http_request: None,
child_frames: Default::default(),
name: None,
lifecycle_events: Default::default(),
isolated_world_name: parent.isolated_world_name.clone(),
}
}
pub fn get_isolated_world_name(&self) -> &String {
&self.isolated_world_name
}
pub fn parent_id(&self) -> Option<&FrameId> {
self.parent_frame.as_ref()
}
pub fn id(&self) -> &FrameId {
&self.id
}
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn main_world(&self) -> &DOMWorld {
&self.main_world
}
pub fn secondary_world(&self) -> &DOMWorld {
&self.secondary_world
}
pub fn lifecycle_events(&self) -> &HashSet<MethodId> {
&self.lifecycle_events
}
pub fn http_request(&self) -> Option<&Arc<HttpRequest>> {
self.http_request.as_ref()
}
fn navigated(&mut self, frame: &CdpFrame) {
self.name.clone_from(&frame.name);
let url = if let Some(ref fragment) = frame.url_fragment {
format!("{}{fragment}", frame.url)
} else {
frame.url.clone()
};
self.url = Some(url);
}
fn navigated_within_url(&mut self, url: String) {
self.url = Some(url)
}
fn on_loading_stopped(&mut self) {
self.lifecycle_events.insert("DOMContentLoaded".into());
self.lifecycle_events.insert("load".into());
}
fn on_loading_started(&mut self) {
self.lifecycle_events.clear();
self.http_request.take();
}
pub fn is_loaded(&self) -> bool {
self.lifecycle_events.contains("load")
}
pub fn is_network_idle(&self) -> bool {
self.lifecycle_events.contains("networkIdle")
}
pub fn is_network_almost_idle(&self) -> bool {
self.lifecycle_events.contains("networkAlmostIdle")
}
pub fn clear_contexts(&mut self) {
self.main_world.take_context();
self.secondary_world.take_context();
}
pub fn destroy_context(&mut self, ctx_unique_id: &str) {
if self.main_world.execution_context_unique_id() == Some(ctx_unique_id) {
self.main_world.take_context();
} else if self.secondary_world.execution_context_unique_id() == Some(ctx_unique_id) {
self.secondary_world.take_context();
}
}
pub fn execution_context(&self) -> Option<ExecutionContextId> {
self.main_world.execution_context()
}
pub fn set_request(&mut self, request: HttpRequest) {
self.http_request = Some(Arc::new(request))
}
}
#[derive(Debug)]
pub struct FrameManager {
main_frame: Option<FrameId>,
frames: HashMap<FrameId, Frame>,
context_ids: HashMap<String, FrameId>,
isolated_worlds: HashSet<String>,
request_timeout: Duration,
pending_navigations: VecDeque<(FrameRequestedNavigation, NavigationWatcher)>,
navigation: Option<(NavigationWatcher, Instant)>,
}
impl FrameManager {
pub fn new(request_timeout: Duration) -> Self {
FrameManager {
main_frame: None,
frames: Default::default(),
context_ids: Default::default(),
isolated_worlds: Default::default(),
request_timeout,
pending_navigations: Default::default(),
navigation: None,
}
}
pub fn init_commands(timeout: Duration) -> CommandChain {
let enable = page::EnableParams::default();
let get_tree = page::GetFrameTreeParams::default();
let set_lifecycle = page::SetLifecycleEventsEnabledParams::new(true);
let mut commands = Vec::with_capacity(3);
let enable_id = enable.identifier();
let get_tree_id = get_tree.identifier();
let set_lifecycle_id = set_lifecycle.identifier();
if let Ok(value) = serde_json::to_value(enable) {
commands.push((enable_id, value));
}
if let Ok(value) = serde_json::to_value(get_tree) {
commands.push((get_tree_id, value));
}
if let Ok(value) = serde_json::to_value(set_lifecycle) {
commands.push((set_lifecycle_id, value));
}
CommandChain::new(commands, timeout)
}
pub fn main_frame(&self) -> Option<&Frame> {
self.main_frame.as_ref().and_then(|id| self.frames.get(id))
}
pub fn main_frame_mut(&mut self) -> Option<&mut Frame> {
if let Some(id) = self.main_frame.as_ref() {
self.frames.get_mut(id)
} else {
None
}
}
pub fn get_isolated_world_name(&self) -> Option<&String> {
self.main_frame
.as_ref()
.and_then(|id| self.frames.get(id).map(|fid| fid.get_isolated_world_name()))
}
pub fn frames(&self) -> impl Iterator<Item = &Frame> + '_ {
self.frames.values()
}
pub fn frame(&self, id: &FrameId) -> Option<&Frame> {
self.frames.get(id)
}
fn check_lifecycle(&self, watcher: &NavigationWatcher, frame: &Frame) -> bool {
watcher.expected_lifecycle.iter().all(|ev| {
frame.lifecycle_events.contains(ev)
|| (frame.url.is_none() && frame.lifecycle_events.contains("DOMContentLoaded"))
})
}
fn check_lifecycle_complete(
&self,
watcher: &NavigationWatcher,
frame: &Frame,
) -> Option<NavigationOk> {
if !self.check_lifecycle(watcher, frame) {
return None;
}
if frame.loader_id == watcher.loader_id && !watcher.same_document_navigation {
return None;
}
if watcher.same_document_navigation {
return Some(NavigationOk::SameDocumentNavigation(watcher.id));
}
if frame.loader_id != watcher.loader_id {
return Some(NavigationOk::NewDocumentNavigation(watcher.id));
}
None
}
pub fn on_http_request_finished(&mut self, request: HttpRequest) {
if let Some(id) = request.frame.as_ref() {
if let Some(frame) = self.frames.get_mut(id) {
frame.set_request(request);
}
}
}
pub fn poll(&mut self, now: Instant) -> Option<FrameEvent> {
if let Some((watcher, deadline)) = self.navigation.take() {
if now > deadline {
return Some(FrameEvent::NavigationResult(Err(
NavigationError::Timeout {
err: DeadlineExceeded::new(now, deadline),
id: watcher.id,
},
)));
}
if let Some(frame) = self.frames.get(&watcher.frame_id) {
if let Some(nav) = self.check_lifecycle_complete(&watcher, frame) {
return Some(FrameEvent::NavigationResult(Ok(nav)));
} else {
self.navigation = Some((watcher, deadline));
}
} else {
return Some(FrameEvent::NavigationResult(Err(
NavigationError::FrameNotFound {
frame: watcher.frame_id,
id: watcher.id,
},
)));
}
} else if let Some((req, watcher)) = self.pending_navigations.pop_front() {
let deadline = Instant::now() + req.timeout;
self.navigation = Some((watcher, deadline));
return Some(FrameEvent::NavigationRequest(req.id, req.req));
}
None
}
pub fn goto(&mut self, req: FrameRequestedNavigation) {
if let Some(frame_id) = &self.main_frame {
self.navigate_frame(frame_id.clone(), req);
}
}
pub fn navigate_frame(&mut self, frame_id: FrameId, mut req: FrameRequestedNavigation) {
let loader_id = self.frames.get(&frame_id).and_then(|f| f.loader_id.clone());
let watcher = NavigationWatcher::until_load(req.id, frame_id.clone(), loader_id);
req.set_frame_id(frame_id);
self.pending_navigations.push_back((req, watcher))
}
pub fn on_attached_to_target(&mut self, _event: &EventAttachedToTarget) {
}
pub fn on_frame_tree(&mut self, frame_tree: FrameTree) {
self.on_frame_attached(
frame_tree.frame.id.clone(),
frame_tree.frame.parent_id.clone(),
);
self.on_frame_navigated(&frame_tree.frame);
if let Some(children) = frame_tree.child_frames {
for child_tree in children {
self.on_frame_tree(child_tree);
}
}
}
pub fn on_frame_attached(&mut self, frame_id: FrameId, parent_frame_id: Option<FrameId>) {
if self.frames.contains_key(&frame_id) {
return;
}
if let Some(parent_frame_id) = parent_frame_id {
if let Some(parent_frame) = self.frames.get_mut(&parent_frame_id) {
let frame = Frame::with_parent(frame_id.clone(), parent_frame);
self.frames.insert(frame_id, frame);
}
}
}
pub fn on_frame_detached(&mut self, event: &EventFrameDetached) {
self.remove_frames_recursively(&event.frame_id);
}
pub fn on_frame_navigated(&mut self, frame: &CdpFrame) {
if frame.parent_id.is_some() {
if let Some((id, mut f)) = self.frames.remove_entry(&frame.id) {
for child in f.child_frames.drain() {
self.remove_frames_recursively(&child);
}
f.navigated(frame);
self.frames.insert(id, f);
}
} else {
let old_main = self.main_frame.take();
let mut f = if let Some(main) = old_main.as_ref() {
if let Some(mut main_frame) = self.frames.remove(main) {
for child in &main_frame.child_frames {
self.remove_frames_recursively(child);
}
main_frame.child_frames.clear();
main_frame.id = frame.id.clone();
main_frame
} else {
Frame::new(frame.id.clone())
}
} else {
Frame::new(frame.id.clone())
};
f.navigated(frame);
let new_id = f.id.clone();
self.main_frame = Some(new_id.clone());
self.frames.insert(new_id.clone(), f);
if old_main.as_ref() != Some(&new_id) {
if let Some((watcher, _)) = self.navigation.as_mut() {
if old_main.as_ref() == Some(&watcher.frame_id) {
watcher.frame_id = new_id;
}
}
}
}
}
pub fn on_frame_navigated_within_document(&mut self, event: &EventNavigatedWithinDocument) {
if let Some(frame) = self.frames.get_mut(&event.frame_id) {
frame.navigated_within_url(event.url.clone());
}
if let Some((watcher, _)) = self.navigation.as_mut() {
watcher.on_frame_navigated_within_document(event);
}
}
pub fn on_frame_stopped_loading(&mut self, event: &EventFrameStoppedLoading) {
if let Some(frame) = self.frames.get_mut(&event.frame_id) {
frame.on_loading_stopped();
}
}
pub fn on_frame_started_loading(&mut self, event: &EventFrameStartedLoading) {
if let Some(frame) = self.frames.get_mut(&event.frame_id) {
frame.on_loading_started();
}
}
pub fn on_runtime_binding_called(&mut self, _ev: &EventBindingCalled) {}
pub fn on_frame_execution_context_created(&mut self, event: &EventExecutionContextCreated) {
if let Some(frame_id) = event
.context
.aux_data
.as_ref()
.and_then(|v| v["frameId"].as_str())
{
if let Some(frame) = self.frames.get_mut(frame_id) {
if event
.context
.aux_data
.as_ref()
.and_then(|v| v["isDefault"].as_bool())
.unwrap_or_default()
{
frame
.main_world
.set_context(event.context.id, event.context.unique_id.clone());
} else if event.context.name == frame.isolated_world_name
&& frame.secondary_world.execution_context().is_none()
{
frame
.secondary_world
.set_context(event.context.id, event.context.unique_id.clone());
}
self.context_ids
.insert(event.context.unique_id.clone(), frame.id.clone());
}
}
if event
.context
.aux_data
.as_ref()
.filter(|v| v["type"].as_str() == Some("isolated"))
.is_some()
{
self.isolated_worlds.insert(event.context.name.clone());
}
}
pub fn on_frame_execution_context_destroyed(&mut self, event: &EventExecutionContextDestroyed) {
if let Some(id) = self.context_ids.remove(&event.execution_context_unique_id) {
if let Some(frame) = self.frames.get_mut(&id) {
frame.destroy_context(&event.execution_context_unique_id);
}
}
}
pub fn on_execution_contexts_cleared(&mut self) {
for id in self.context_ids.values() {
if let Some(frame) = self.frames.get_mut(id) {
frame.clear_contexts();
}
}
self.context_ids.clear()
}
pub fn on_page_lifecycle_event(&mut self, event: &EventLifecycleEvent) {
if let Some(frame) = self.frames.get_mut(&event.frame_id) {
if event.name == "init" {
frame.loader_id = Some(event.loader_id.clone());
frame.lifecycle_events.clear();
}
frame.lifecycle_events.insert(event.name.clone().into());
}
}
fn remove_frames_recursively(&mut self, id: &FrameId) -> Option<Frame> {
if let Some(mut frame) = self.frames.remove(id) {
for child in &frame.child_frames {
self.remove_frames_recursively(child);
}
if let Some(parent_id) = frame.parent_frame.take() {
if let Some(parent) = self.frames.get_mut(&parent_id) {
parent.child_frames.remove(&frame.id);
}
}
Some(frame)
} else {
None
}
}
pub fn ensure_isolated_world(&mut self, world_name: &str) -> Option<CommandChain> {
if self.isolated_worlds.contains(world_name) {
return None;
}
self.isolated_worlds.insert(world_name.to_string());
if let Ok(cmd) = AddScriptToEvaluateOnNewDocumentParams::builder()
.source(format!("//# sourceURL={}", *EVALUATION_SCRIPT_URL))
.world_name(world_name)
.build()
{
let mut cmds = Vec::with_capacity(self.frames.len() + 1);
let identifier = cmd.identifier();
if let Ok(cmd) = serde_json::to_value(cmd) {
cmds.push((identifier, cmd));
}
let cm = self.frames.keys().filter_map(|id| {
if let Ok(cmd) = CreateIsolatedWorldParams::builder()
.frame_id(id.clone())
.grant_univeral_access(true)
.world_name(world_name)
.build()
{
let cm = (
cmd.identifier(),
serde_json::to_value(cmd).unwrap_or_default(),
);
Some(cm)
} else {
None
}
});
cmds.extend(cm);
Some(CommandChain::new(cmds, self.request_timeout))
} else {
None
}
}
}
#[derive(Debug)]
pub enum FrameEvent {
NavigationResult(Result<NavigationOk, NavigationError>),
NavigationRequest(NavigationId, Request),
}
#[derive(Debug)]
pub enum NavigationError {
Timeout {
id: NavigationId,
err: DeadlineExceeded,
},
FrameNotFound {
id: NavigationId,
frame: FrameId,
},
}
impl NavigationError {
pub fn navigation_id(&self) -> &NavigationId {
match self {
NavigationError::Timeout { id, .. } => id,
NavigationError::FrameNotFound { id, .. } => id,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum NavigationOk {
SameDocumentNavigation(NavigationId),
NewDocumentNavigation(NavigationId),
}
impl NavigationOk {
pub fn navigation_id(&self) -> &NavigationId {
match self {
NavigationOk::SameDocumentNavigation(id) => id,
NavigationOk::NewDocumentNavigation(id) => id,
}
}
}
#[derive(Debug)]
pub struct NavigationWatcher {
id: NavigationId,
expected_lifecycle: HashSet<MethodId>,
frame_id: FrameId,
loader_id: Option<LoaderId>,
same_document_navigation: bool,
}
impl NavigationWatcher {
pub fn until_lifecycle(
id: NavigationId,
frame: FrameId,
loader_id: Option<LoaderId>,
events: &[LifecycleEvent],
) -> Self {
let expected_lifecycle = events.iter().map(LifecycleEvent::to_method_id).collect();
Self {
id,
expected_lifecycle,
frame_id: frame,
loader_id,
same_document_navigation: false,
}
}
pub fn until_load(id: NavigationId, frame: FrameId, loader_id: Option<LoaderId>) -> Self {
Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::Load])
}
pub fn until_domcontent_loaded(
id: NavigationId,
frame: FrameId,
loader_id: Option<LoaderId>,
) -> Self {
Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::DomcontentLoaded])
}
pub fn until_network_idle(
id: NavigationId,
frame: FrameId,
loader_id: Option<LoaderId>,
) -> Self {
Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkIdle])
}
pub fn until_network_almost_idle(
id: NavigationId,
frame: FrameId,
loader_id: Option<LoaderId>,
) -> Self {
Self::until_lifecycle(id, frame, loader_id, &[LifecycleEvent::NetworkAlmostIdle])
}
pub fn until_domcontent_and_network_idle(
id: NavigationId,
frame: FrameId,
loader_id: Option<LoaderId>,
) -> Self {
Self::until_lifecycle(
id,
frame,
loader_id,
&[
LifecycleEvent::DomcontentLoaded,
LifecycleEvent::NetworkIdle,
],
)
}
pub fn is_lifecycle_complete(&self) -> bool {
self.expected_lifecycle.is_empty()
}
fn on_frame_navigated_within_document(&mut self, ev: &EventNavigatedWithinDocument) {
if self.frame_id == ev.frame_id {
self.same_document_navigation = true;
}
}
}
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
pub struct NavigationId(pub usize);
#[derive(Debug)]
pub struct FrameRequestedNavigation {
pub id: NavigationId,
pub req: Request,
pub timeout: Duration,
}
impl FrameRequestedNavigation {
pub fn new(id: NavigationId, req: Request, request_timeout: Duration) -> Self {
Self {
id,
req,
timeout: request_timeout,
}
}
pub fn set_frame_id(&mut self, frame_id: FrameId) {
if let Some(params) = self.req.params.as_object_mut() {
if let Entry::Vacant(entry) = params.entry("frameId") {
entry.insert(serde_json::Value::String(frame_id.into()));
}
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LifecycleEvent {
#[default]
Load,
DomcontentLoaded,
NetworkIdle,
NetworkAlmostIdle,
}
impl LifecycleEvent {
#[inline]
pub fn to_method_id(&self) -> MethodId {
match self {
LifecycleEvent::Load => "load".into(),
LifecycleEvent::DomcontentLoaded => "DOMContentLoaded".into(),
LifecycleEvent::NetworkIdle => "networkIdle".into(),
LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle".into(),
}
}
}
impl AsRef<str> for LifecycleEvent {
fn as_ref(&self) -> &str {
match self {
LifecycleEvent::Load => "load",
LifecycleEvent::DomcontentLoaded => "DOMContentLoaded",
LifecycleEvent::NetworkIdle => "networkIdle",
LifecycleEvent::NetworkAlmostIdle => "networkAlmostIdle",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_lifecycle_events_cleared_on_loading_started() {
let mut frame = Frame::new(FrameId::new("test"));
frame.lifecycle_events.insert("load".into());
frame.lifecycle_events.insert("DOMContentLoaded".into());
assert!(frame.is_loaded());
frame.on_loading_started();
assert!(!frame.is_loaded());
}
#[test]
fn frame_loading_stopped_inserts_load_events() {
let mut frame = Frame::new(FrameId::new("test"));
assert!(!frame.is_loaded());
frame.on_loading_stopped();
assert!(frame.is_loaded());
}
#[test]
fn navigation_completes_when_main_frame_loaded_despite_child_frames() {
let timeout = Duration::from_secs(30);
let mut fm = FrameManager::new(timeout);
let main_id = FrameId::new("main");
let mut main_frame = Frame::new(main_id.clone());
main_frame.loader_id = Some(LoaderId::from("loader-old".to_string()));
main_frame.lifecycle_events.insert("load".into());
fm.frames.insert(main_id.clone(), main_frame);
fm.main_frame = Some(main_id.clone());
let child_id = FrameId::new("child-ad");
let child = Frame::with_parent(child_id.clone(), fm.frames.get_mut(&main_id).unwrap());
fm.frames.insert(child_id, child);
let watcher = NavigationWatcher::until_load(
NavigationId(0),
main_id.clone(),
Some(LoaderId::from("loader-old".to_string())),
);
fm.frames.get_mut(&main_id).unwrap().loader_id =
Some(LoaderId::from("loader-new".to_string()));
let main_frame = fm.frames.get(&main_id).unwrap();
let result = fm.check_lifecycle_complete(&watcher, main_frame);
assert!(
result.is_some(),
"navigation should complete without waiting for child frames"
);
}
#[test]
fn navigation_watcher_tracks_main_frame_id_change() {
let timeout = Duration::from_secs(30);
let mut fm = FrameManager::new(timeout);
let old_id = FrameId::new("old-main");
let mut main_frame = Frame::new(old_id.clone());
main_frame.loader_id = Some(LoaderId::from("loader-1".to_string()));
fm.frames.insert(old_id.clone(), main_frame);
fm.main_frame = Some(old_id.clone());
let watcher = NavigationWatcher::until_load(
NavigationId(0),
old_id.clone(),
Some(LoaderId::from("loader-1".to_string())),
);
let deadline = Instant::now() + timeout;
fm.navigation = Some((watcher, deadline));
let new_id = FrameId::new("new-main");
if let Some(mut old_frame) = fm.frames.remove(&old_id) {
old_frame.child_frames.clear();
old_frame.id = new_id.clone();
fm.frames.insert(new_id.clone(), old_frame);
}
fm.main_frame = Some(new_id.clone());
if let Some((watcher, _)) = fm.navigation.as_mut() {
if watcher.frame_id == old_id {
watcher.frame_id = new_id.clone();
}
}
let (watcher, _) = fm.navigation.as_ref().unwrap();
assert_eq!(
watcher.frame_id, new_id,
"watcher should follow the main frame ID change"
);
fm.frames.get_mut(&new_id).unwrap().loader_id =
Some(LoaderId::from("loader-2".to_string()));
fm.frames
.get_mut(&new_id)
.unwrap()
.lifecycle_events
.insert("load".into());
let event = fm.poll(Instant::now());
assert!(
matches!(event, Some(FrameEvent::NavigationResult(Ok(_)))),
"navigation should complete on the new frame"
);
}
}