use std::sync::{Arc, Weak};
use std::time::Duration;
use futures::StreamExt;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use tokio::sync::{Mutex, RwLock};
use tokio::time::timeout;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
use crate::isolated_world::IsolatedWorldCache;
use crate::tab::{Tab, TabInner};
pub mod lifecycle;
pub mod oopif;
const DEFAULT_LOAD_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct Frame {
inner: Arc<FrameInner>,
}
#[derive(Debug)]
pub(crate) struct FrameInner {
pub(crate) frame_id: String,
pub(crate) parent_frame_id: Option<String>,
pub(crate) url: RwLock<String>,
pub(crate) name: Option<String>,
pub(crate) session: SessionHandle,
pub(crate) isolated_world: Mutex<IsolatedWorldCache>,
pub(crate) tab: Weak<TabInner>,
}
impl Frame {
pub(crate) fn new(
frame_id: String,
parent_frame_id: Option<String>,
url: String,
name: Option<String>,
session: SessionHandle,
tab: Weak<TabInner>,
) -> Self {
Self {
inner: Arc::new(FrameInner {
frame_id,
parent_frame_id,
url: RwLock::new(url),
name,
session,
isolated_world: Mutex::new(IsolatedWorldCache::default()),
tab,
}),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.inner.frame_id
}
pub(crate) fn session(&self) -> &SessionHandle {
&self.inner.session
}
pub(crate) fn tab_for_synthesize(&self) -> Option<Tab> {
self.inner.tab.upgrade().map(|inner| Tab { inner })
}
#[must_use]
pub fn parent_id(&self) -> Option<&str> {
self.inner.parent_frame_id.as_deref()
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.inner.name.as_deref()
}
#[must_use]
pub fn is_main(&self) -> bool {
self.inner.parent_frame_id.is_none()
}
pub async fn url(&self) -> String {
self.inner.url.read().await.clone()
}
pub async fn evaluate<T: DeserializeOwned>(&self, js: impl AsRef<str>) -> Result<T> {
let ctx_id = self.ensure_isolated_world().await?;
let res = self
.inner
.session
.call(
"Runtime.evaluate",
json!({
"expression": js.as_ref(),
"contextId": ctx_id,
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
Self::extract_value(&res)
}
pub async fn evaluate_main<T: DeserializeOwned>(&self, js: impl AsRef<str>) -> Result<T> {
let res = self
.inner
.session
.call(
"Runtime.evaluate",
json!({
"expression": js.as_ref(),
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
Self::extract_value(&res)
}
pub async fn content(&self) -> Result<String> {
self.evaluate_main("document.documentElement.outerHTML")
.await
}
pub async fn goto(&self, url: impl AsRef<str>) -> Result<()> {
if !self.is_main() {
return Err(ZendriverError::Navigation(
"sub-frame goto not supported; set iframe.src via parent evaluate_main".into(),
));
}
self.inner.session.call("Page.enable", json!({})).await?;
let url_s = url.as_ref().to_string();
let res = self
.inner
.session
.call("Page.navigate", json!({ "url": url_s }))
.await?;
if let Some(err) = res.get("errorText").and_then(|v| v.as_str()) {
if !err.is_empty() {
return Err(ZendriverError::Navigation(err.to_string()));
}
}
Ok(())
}
pub async fn wait_for_load(&self) -> Result<()> {
let mut stream = self
.inner
.session
.subscribe::<Value>("Page.frameStoppedLoading");
let deadline = tokio::time::Instant::now() + DEFAULT_LOAD_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(ZendriverError::Timeout(DEFAULT_LOAD_TIMEOUT));
}
let evt = timeout(remaining, stream.next())
.await
.map_err(|_| ZendriverError::Timeout(DEFAULT_LOAD_TIMEOUT))?
.ok_or_else(|| ZendriverError::Navigation("page event stream closed".into()))?;
if evt
.get("frameId")
.and_then(|v| v.as_str())
.is_some_and(|fid| fid == self.inner.frame_id)
{
return Ok(());
}
}
}
pub fn find(&self) -> crate::query::FindBuilder<'_> {
crate::query::FindBuilder::new_for_frame(self)
}
pub fn find_all(&self) -> crate::query::FindAllBuilder<'_> {
crate::query::FindAllBuilder::new_for_frame(self)
}
pub async fn select(&self, css: &str) -> crate::error::Result<crate::Element> {
self.find().css(css).one().await
}
pub async fn select_all(&self, css: &str) -> crate::error::Result<Vec<crate::Element>> {
self.find_all().css(css).many().await
}
pub(crate) async fn ensure_isolated_world(&self) -> Result<i64> {
let mut cache = self.inner.isolated_world.lock().await;
if let Some(ctx) = cache.context_id {
return Ok(ctx);
}
let frame_id_for_call = match self.create_isolated_world(&self.inner.frame_id).await {
Ok(ctx) => {
cache.main_frame_id = Some(self.inner.frame_id.clone());
cache.context_id = Some(ctx);
return Ok(ctx);
}
Err(ZendriverError::Cdp {
code: -32602,
ref message,
..
}) if message.contains("No frame for given id") => {
self.discover_current_frame_id().await?
}
Err(e) => return Err(e),
};
let ctx = self.create_isolated_world(&frame_id_for_call).await?;
cache.main_frame_id = Some(frame_id_for_call);
cache.context_id = Some(ctx);
Ok(ctx)
}
async fn create_isolated_world(&self, frame_id: &str) -> Result<i64> {
let res = self
.inner
.session
.call(
"Page.createIsolatedWorld",
json!({
"frameId": frame_id,
"worldName": "zendriver-eval",
"grantUniversalAccess": false,
}),
)
.await?;
res["executionContextId"].as_i64().ok_or_else(|| {
ZendriverError::Navigation(
"Page.createIsolatedWorld did not return executionContextId".into(),
)
})
}
async fn discover_current_frame_id(&self) -> Result<String> {
let parent = self.inner.parent_frame_id.as_deref().ok_or_else(|| {
ZendriverError::Navigation(
"frame_id rejected and no parent_frame_id recorded to recover from".into(),
)
})?;
let tree = self
.inner
.session
.call("Page.getFrameTree", json!({}))
.await?;
let main = tree["frameTree"].clone();
fn walk_for_child(node: &serde_json::Value, parent: &str) -> Option<String> {
if let Some(children) = node["childFrames"].as_array() {
for c in children {
if c["frame"]["parentId"].as_str() == Some(parent) {
if let Some(id) = c["frame"]["id"].as_str() {
return Some(id.to_string());
}
}
if let Some(found) = walk_for_child(c, parent) {
return Some(found);
}
}
}
None
}
walk_for_child(&main, parent).ok_or_else(|| {
ZendriverError::FrameNotFound(format!(
"no child frame found under parent {parent} during recovery"
))
})
}
#[allow(clippy::result_large_err)] fn extract_value<T: DeserializeOwned>(res: &Value) -> Result<T> {
if let Some(details) = res.get("exceptionDetails") {
let msg = details
.get("exception")
.and_then(|e| e.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("unknown")
.to_string();
return Err(ZendriverError::JsException(msg));
}
let value = res
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(Value::Null);
serde_json::from_value(value).map_err(ZendriverError::Serde)
}
}
impl crate::traits::Queryable for Frame {
fn find(&self) -> crate::query::FindBuilder<'_> {
Frame::find(self)
}
fn find_all(&self) -> crate::query::FindAllBuilder<'_> {
Frame::find_all(self)
}
}
#[async_trait::async_trait]
impl crate::traits::Evaluable for Frame {
async fn evaluate<T>(&self, js: &str) -> Result<T>
where
T: DeserializeOwned + Send + 'static,
{
Frame::evaluate(self, js).await
}
async fn evaluate_main<T>(&self, js: &str) -> Result<T>
where
T: DeserializeOwned + Send + 'static,
{
Frame::evaluate_main(self, js).await
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use zendriver_transport::testing::MockConnection;
fn frame_on(session: SessionHandle, frame_id: &str) -> Frame {
Frame::new(
frame_id.to_string(),
None,
String::new(),
None,
session,
Weak::new(),
)
}
#[tokio::test]
async fn evaluate_caches_context_id_across_calls() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let frame = frame_on(sess, "FRAME_A");
let fut1 = tokio::spawn({
let f = frame.clone();
async move { f.evaluate::<i32>("1").await }
});
let id_world = mock.expect_cmd("Page.createIsolatedWorld").await;
assert_eq!(mock.last_sent()["params"]["frameId"], "FRAME_A");
assert_eq!(mock.last_sent()["params"]["worldName"], "zendriver-eval");
mock.reply(id_world, json!({ "executionContextId": 7 }))
.await;
let id_eval1 = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(mock.last_sent()["params"]["contextId"], 7);
assert_eq!(mock.last_sent()["params"]["expression"], "1");
mock.reply(
id_eval1,
json!({ "result": { "value": 1, "type": "number" } }),
)
.await;
assert_eq!(fut1.await.unwrap().unwrap(), 1);
let fut2 = tokio::spawn({
let f = frame.clone();
async move { f.evaluate::<i32>("2").await }
});
let id_eval2 = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(mock.last_sent()["params"]["contextId"], 7);
assert_eq!(mock.last_sent()["params"]["expression"], "2");
mock.reply(
id_eval2,
json!({ "result": { "value": 2, "type": "number" } }),
)
.await;
assert_eq!(fut2.await.unwrap().unwrap(), 2);
conn.shutdown();
}
#[tokio::test]
async fn evaluate_main_dispatches_runtime_evaluate_without_context_id() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let frame = frame_on(sess, "FRAME_B");
let fut = tokio::spawn({
let f = frame.clone();
async move { f.evaluate_main::<i32>("1+1").await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(mock.last_sent()["params"]["expression"], "1+1");
assert!(mock.last_sent()["params"].get("contextId").is_none());
mock.reply(id, json!({ "result": { "value": 2, "type": "number" } }))
.await;
assert_eq!(fut.await.unwrap().unwrap(), 2);
conn.shutdown();
}
#[tokio::test]
async fn find_dispatches_runtime_evaluate_on_frames_session() {
use crate::tab::Tab;
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess.clone());
let frame = Frame::new(
"FRAME_FIND".to_string(),
None,
String::new(),
None,
sess,
std::sync::Arc::downgrade(&tab.inner),
);
let fut = tokio::spawn({
let f = frame.clone();
async move { f.find().css("button").one().await }
});
let id_iso = mock.expect_cmd("Page.createIsolatedWorld").await;
assert_eq!(mock.last_sent()["params"]["frameId"], "FRAME_FIND");
mock.reply(id_iso, json!({ "executionContextId": 4242 }))
.await;
let id_q = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(mock.last_sent()["params"]["contextId"], 4242);
let sent = mock.last_sent()["params"]["expression"]
.as_str()
.expect("expression should be a string")
.to_string();
assert!(
sent.contains("document.querySelectorAll") && sent.contains("button"),
"frame.find().css('button').one() must dispatch document.querySelectorAll, got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "objectId": "RArrF", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "RArrF");
mock.reply(
id_p,
json!({
"result": [
{
"name": "0",
"value": { "objectId": "RFN0", "type": "object", "subtype": "node" }
},
{
"name": "length",
"value": { "value": 1, "type": "number" }
}
]
}),
)
.await;
let id_d = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "RFN0");
mock.reply(id_d, json!({ "node": { "backendNodeId": 77 } }))
.await;
let el = fut.await.expect("task should not panic").expect("one() ok");
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("RFN0")
);
assert_eq!(*el.inner.backend_node_id.lock().await, Some(77));
conn.shutdown();
}
#[tokio::test]
async fn main_frame_goto_dispatches_page_navigate() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let frame = frame_on(sess, "FRAME_MAIN");
let fut = tokio::spawn({
let f = frame.clone();
async move { f.goto("https://example.com").await }
});
let id_enable = mock.expect_cmd("Page.enable").await;
mock.reply(id_enable, json!({})).await;
let id_nav = mock.expect_cmd("Page.navigate").await;
assert_eq!(mock.last_sent()["params"]["url"], "https://example.com");
mock.reply(id_nav, json!({ "frameId": "FRAME_MAIN" })).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn sub_frame_goto_returns_navigation_error() {
let (_mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let frame = Frame::new(
"FRAME_CHILD".to_string(),
Some("FRAME_PARENT".to_string()),
String::new(),
None,
sess,
Weak::new(),
);
let res = frame.goto("https://example.com").await;
match res {
Err(ZendriverError::Navigation(m)) => {
assert!(
m.contains("sub-frame goto not supported"),
"unexpected message: {m}"
);
}
other => panic!("expected Navigation error, got: {other:?}"),
}
conn.shutdown();
}
}