use std::sync::Arc;
use std::time::Duration;
use parking_lot::RwLock;
use tracing::{debug, info, instrument};
use viewpoint_cdp::CdpConnection;
use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
use viewpoint_cdp::protocol::runtime::EvaluateParams;
use crate::error::{NavigationError, PageError};
use crate::wait::{DocumentLoadState, LoadStateWaiter};
const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
struct FrameData {
url: String,
name: String,
detached: bool,
}
#[derive(Debug)]
pub struct Frame {
connection: Arc<CdpConnection>,
session_id: String,
id: String,
parent_id: Option<String>,
loader_id: String,
data: RwLock<FrameData>,
}
impl Frame {
pub(crate) fn new(
connection: Arc<CdpConnection>,
session_id: String,
id: String,
parent_id: Option<String>,
loader_id: String,
url: String,
name: String,
) -> Self {
Self {
connection,
session_id,
id,
parent_id,
loader_id,
data: RwLock::new(FrameData {
url,
name,
detached: false,
}),
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn parent_id(&self) -> Option<&str> {
self.parent_id.as_deref()
}
pub fn is_main(&self) -> bool {
self.parent_id.is_none()
}
pub fn loader_id(&self) -> &str {
&self.loader_id
}
pub fn url(&self) -> String {
self.data.read().url.clone()
}
pub fn name(&self) -> String {
self.data.read().name.clone()
}
pub fn is_detached(&self) -> bool {
self.data.read().detached
}
pub(crate) fn set_url(&self, url: String) {
self.data.write().url = url;
}
pub(crate) fn set_name(&self, name: String) {
self.data.write().name = name;
}
pub(crate) fn set_detached(&self) {
self.data.write().detached = true;
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
pub async fn content(&self) -> Result<String, PageError> {
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
.connection
.send_command(
"Runtime.evaluate",
Some(EvaluateParams {
expression: "document.documentElement.outerHTML".to_string(),
object_group: None,
include_command_line_api: None,
silent: Some(true),
context_id: None, return_by_value: Some(true),
await_promise: Some(false),
}),
Some(&self.session_id),
)
.await?;
result
.result
.value
.and_then(|v| v.as_str().map(ToString::to_string))
.ok_or_else(|| PageError::EvaluationFailed("Failed to get content".to_string()))
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
pub async fn title(&self) -> Result<String, PageError> {
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
.connection
.send_command(
"Runtime.evaluate",
Some(EvaluateParams {
expression: "document.title".to_string(),
object_group: None,
include_command_line_api: None,
silent: Some(true),
context_id: None, return_by_value: Some(true),
await_promise: Some(false),
}),
Some(&self.session_id),
)
.await?;
result
.result
.value
.and_then(|v| v.as_str().map(ToString::to_string))
.ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
}
#[instrument(level = "info", skip(self), fields(frame_id = %self.id, url = %url))]
pub async fn goto(&self, url: &str) -> Result<(), NavigationError> {
self.goto_with_options(url, DocumentLoadState::Load, DEFAULT_NAVIGATION_TIMEOUT)
.await
}
#[instrument(level = "info", skip(self), fields(frame_id = %self.id, url = %url, wait_until = ?wait_until))]
pub async fn goto_with_options(
&self,
url: &str,
wait_until: DocumentLoadState,
timeout: Duration,
) -> Result<(), NavigationError> {
if self.is_detached() {
return Err(NavigationError::Cancelled);
}
info!("Navigating frame to URL");
let event_rx = self.connection.subscribe_events();
let mut waiter = LoadStateWaiter::new(event_rx, self.session_id.clone(), self.id.clone());
debug!("Sending Page.navigate command for frame");
let result: NavigateResult = self
.connection
.send_command(
"Page.navigate",
Some(NavigateParams {
url: url.to_string(),
referrer: None,
transition_type: None,
frame_id: Some(self.id.clone()),
}),
Some(&self.session_id),
)
.await?;
debug!(frame_id = %result.frame_id, "Page.navigate completed for frame");
if let Some(error_text) = result.error_text {
return Err(NavigationError::NetworkError(error_text));
}
waiter.set_commit_received().await;
debug!(wait_until = ?wait_until, "Waiting for load state");
waiter
.wait_for_load_state_with_timeout(wait_until, timeout)
.await?;
self.set_url(url.to_string());
info!(frame_id = %self.id, "Frame navigation completed");
Ok(())
}
#[instrument(level = "info", skip(self, html), fields(frame_id = %self.id))]
pub async fn set_content(&self, html: &str) -> Result<(), PageError> {
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
use viewpoint_cdp::protocol::page::SetDocumentContentParams;
self.connection
.send_command::<_, serde_json::Value>(
"Page.setDocumentContent",
Some(SetDocumentContentParams {
frame_id: self.id.clone(),
html: html.to_string(),
}),
Some(&self.session_id),
)
.await?;
info!("Frame content set");
Ok(())
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id, state = ?state))]
pub async fn wait_for_load_state(
&self,
state: DocumentLoadState,
) -> Result<(), NavigationError> {
self.wait_for_load_state_with_timeout(state, DEFAULT_NAVIGATION_TIMEOUT)
.await
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id, state = ?state, timeout_ms = timeout.as_millis()))]
pub async fn wait_for_load_state_with_timeout(
&self,
state: DocumentLoadState,
timeout: Duration,
) -> Result<(), NavigationError> {
if self.is_detached() {
return Err(NavigationError::Cancelled);
}
let event_rx = self.connection.subscribe_events();
let mut waiter = LoadStateWaiter::new(event_rx, self.session_id.clone(), self.id.clone());
waiter.set_commit_received().await;
waiter
.wait_for_load_state_with_timeout(state, timeout)
.await?;
debug!("Frame reached load state {:?}", state);
Ok(())
}
pub(crate) fn session_id(&self) -> &str {
&self.session_id
}
pub(crate) fn connection(&self) -> &Arc<CdpConnection> {
&self.connection
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
pub async fn child_frames(&self) -> Result<Vec<Frame>, PageError> {
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
let result: viewpoint_cdp::protocol::page::GetFrameTreeResult = self
.connection
.send_command("Page.getFrameTree", None::<()>, Some(&self.session_id))
.await?;
let children = find_child_frames(
&result.frame_tree,
&self.id,
&self.connection,
&self.session_id,
);
Ok(children)
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
pub async fn parent_frame(&self) -> Result<Option<Frame>, PageError> {
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
if self.is_main() {
return Ok(None);
}
let result: viewpoint_cdp::protocol::page::GetFrameTreeResult = self
.connection
.send_command("Page.getFrameTree", None::<()>, Some(&self.session_id))
.await?;
let parent = find_parent_frame(
&result.frame_tree,
&self.id,
&self.connection,
&self.session_id,
);
Ok(parent)
}
#[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
pub async fn aria_snapshot(&self) -> Result<crate::page::locator::AriaSnapshot, PageError> {
use crate::page::locator::aria::aria_snapshot_js;
use viewpoint_js::js;
if self.is_detached() {
return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
}
let snapshot_fn = aria_snapshot_js();
let js_code = js! {
(function() {
const getSnapshot = @{snapshot_fn};
return getSnapshot(document.body || document.documentElement);
})()
};
let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
.connection
.send_command(
"Runtime.evaluate",
Some(EvaluateParams {
expression: js_code,
object_group: None,
include_command_line_api: None,
silent: Some(true),
context_id: None, return_by_value: Some(true),
await_promise: Some(false),
}),
Some(&self.session_id),
)
.await?;
if let Some(exception) = result.exception_details {
return Err(PageError::EvaluationFailed(exception.text));
}
let value = result.result.value.ok_or_else(|| {
PageError::EvaluationFailed("No result from aria snapshot".to_string())
})?;
let snapshot: crate::page::locator::AriaSnapshot =
serde_json::from_value(value).map_err(|e| {
PageError::EvaluationFailed(format!("Failed to parse aria snapshot: {e}"))
})?;
Ok(snapshot)
}
}
fn find_child_frames(
tree: &viewpoint_cdp::protocol::page::FrameTree,
parent_id: &str,
connection: &Arc<CdpConnection>,
session_id: &str,
) -> Vec<Frame> {
let mut children = Vec::new();
if tree.frame.id == parent_id {
if let Some(ref child_frames) = tree.child_frames {
for child in child_frames {
children.push(Frame::new(
connection.clone(),
session_id.to_string(),
child.frame.id.clone(),
Some(parent_id.to_string()),
child.frame.loader_id.clone(),
child.frame.url.clone(),
child.frame.name.clone().unwrap_or_default(),
));
}
}
} else {
if let Some(ref child_frames) = tree.child_frames {
for child in child_frames {
let found = find_child_frames(child, parent_id, connection, session_id);
children.extend(found);
}
}
}
children
}
fn find_parent_frame(
tree: &viewpoint_cdp::protocol::page::FrameTree,
frame_id: &str,
connection: &Arc<CdpConnection>,
session_id: &str,
) -> Option<Frame> {
if let Some(ref child_frames) = tree.child_frames {
for child in child_frames {
if child.frame.id == frame_id {
return Some(Frame::new(
connection.clone(),
session_id.to_string(),
tree.frame.id.clone(),
tree.frame.parent_id.clone(),
tree.frame.loader_id.clone(),
tree.frame.url.clone(),
tree.frame.name.clone().unwrap_or_default(),
));
}
}
for child in child_frames {
if let Some(parent) = find_parent_frame(child, frame_id, connection, session_id) {
return Some(parent);
}
}
}
None
}