use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use tokio::sync::oneshot;
use tokio::time::Sleep;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialogType {
Alert,
Beforeunload,
Confirm,
Prompt,
}
impl DialogType {
fn from_cdp(s: &str) -> Self {
match s {
"alert" => Self::Alert,
"beforeunload" => Self::Beforeunload,
"confirm" => Self::Confirm,
"prompt" => Self::Prompt,
_ => Self::Alert,
}
}
}
#[derive(Clone)]
pub struct MatchedDialog {
pub dialog_type: DialogType,
pub message: String,
pub default_prompt: Option<String>,
pub url: String,
pub session: SessionHandle,
}
impl std::fmt::Debug for MatchedDialog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MatchedDialog")
.field("dialog_type", &self.dialog_type)
.field("message", &self.message)
.field("default_prompt", &self.default_prompt)
.field("url", &self.url)
.field("session", &"<SessionHandle>")
.finish()
}
}
impl MatchedDialog {
pub async fn accept(self, prompt_text: Option<String>) -> Result<()> {
let _ = self
.session
.call(
"Page.handleJavaScriptDialog",
json!({
"accept": true,
"promptText": prompt_text.unwrap_or_default(),
}),
)
.await?;
Ok(())
}
pub async fn dismiss(self) -> Result<()> {
let _ = self
.session
.call("Page.handleJavaScriptDialog", json!({ "accept": false }))
.await?;
Ok(())
}
}
#[derive(Debug)]
pub struct DialogExpectation {
rx: oneshot::Receiver<MatchedDialog>,
timeout: Duration,
sleep: Option<Pin<Box<Sleep>>>,
}
impl DialogExpectation {
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self.sleep = None;
self
}
pub async fn matched(self) -> Result<MatchedDialog> {
self.await
}
}
impl Future for DialogExpectation {
type Output = Result<MatchedDialog>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.rx).poll(cx) {
Poll::Ready(Ok(dialog)) => return Poll::Ready(Ok(dialog)),
Poll::Ready(Err(_)) => {
return Poll::Ready(Err(ZendriverError::Timeout(self.timeout)));
}
Poll::Pending => {}
}
let timeout = self.timeout;
let sleep = self
.sleep
.get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout)));
match sleep.as_mut().poll(cx) {
Poll::Ready(()) => Poll::Ready(Err(ZendriverError::Timeout(timeout))),
Poll::Pending => Poll::Pending,
}
}
}
#[derive(Debug, Deserialize)]
struct JavascriptDialogOpenedEvent {
url: String,
message: String,
#[serde(rename = "type")]
dialog_type: String,
#[serde(rename = "defaultPrompt", default)]
default_prompt: Option<String>,
}
pub(crate) fn register(session: &SessionHandle) -> DialogExpectation {
let (tx, rx) = oneshot::channel();
let mut stream =
session.subscribe::<JavascriptDialogOpenedEvent>("Page.javascriptDialogOpened");
let session_for_dialog = session.clone();
tokio::spawn(async move {
if let Some(ev) = stream.next().await {
let matched = MatchedDialog {
dialog_type: DialogType::from_cdp(&ev.dialog_type),
message: ev.message,
default_prompt: ev.default_prompt.filter(|s| !s.is_empty()),
url: ev.url,
session: session_for_dialog,
};
let _ = tx.send(matched);
}
});
DialogExpectation {
rx,
timeout: DEFAULT_EXPECT_TIMEOUT,
sleep: None,
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn expect_dialog_resolves_on_javascript_dialog_opened() {
let (mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let expectation = register(&session);
mock.emit_event_for_session(
"Page.javascriptDialogOpened",
json!({
"url": "https://example.com/form",
"message": "What is your name?",
"type": "prompt",
"defaultPrompt": "Anonymous",
"hasBrowserHandler": false,
}),
"S1",
)
.await;
let matched = tokio::time::timeout(Duration::from_secs(2), expectation)
.await
.expect("expectation did not resolve within 2s")
.expect("expectation returned Err");
assert_eq!(matched.dialog_type, DialogType::Prompt);
assert_eq!(matched.message, "What is your name?");
assert_eq!(matched.default_prompt.as_deref(), Some("Anonymous"));
assert_eq!(matched.url, "https://example.com/form");
conn.shutdown();
}
#[tokio::test]
async fn accept_dispatches_handle_javascript_dialog() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let dialog = MatchedDialog {
dialog_type: DialogType::Prompt,
message: "What is your name?".into(),
default_prompt: Some("Anonymous".into()),
url: "https://example.com/form".into(),
session: session.clone(),
};
let fut = tokio::spawn(async move { dialog.accept(Some("hello".into())).await });
let id = mock.expect_cmd("Page.handleJavaScriptDialog").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["accept"], true);
assert_eq!(sent["params"]["promptText"], "hello");
mock.reply(id, json!({})).await;
fut.await
.expect("accept task panicked")
.expect("accept returned Err");
conn.shutdown();
}
}