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 tokio_util::sync::CancellationToken;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChooserMode {
SelectSingle,
SelectMultiple,
}
impl FileChooserMode {
fn from_cdp(s: &str) -> Self {
match s {
"selectMultiple" => Self::SelectMultiple,
_ => Self::SelectSingle,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MatchedFileChooser {
pub mode: FileChooserMode,
}
#[derive(Debug)]
pub struct FileChooserExpectation {
rx: oneshot::Receiver<Result<MatchedFileChooser>>,
timeout: Duration,
sleep: Option<Pin<Box<Sleep>>>,
cancel: CancellationToken,
}
impl FileChooserExpectation {
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self.sleep = None;
self
}
pub async fn matched(self) -> Result<MatchedFileChooser> {
self.await
}
}
impl Future for FileChooserExpectation {
type Output = Result<MatchedFileChooser>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.rx).poll(cx) {
Poll::Ready(Ok(Ok(matched))) => return Poll::Ready(Ok(matched)),
Poll::Ready(Ok(Err(e))) => return Poll::Ready(Err(e)),
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,
}
}
}
impl Drop for FileChooserExpectation {
fn drop(&mut self) {
self.cancel.cancel();
}
}
#[derive(Debug, Deserialize)]
struct FileChooserOpenedEvent {
mode: String,
#[serde(rename = "backendNodeId", default)]
backend_node_id: Option<i64>,
}
async fn handle_event(
session: &SessionHandle,
files: Vec<String>,
res: Result<FileChooserOpenedEvent>,
) -> Result<MatchedFileChooser> {
let ev = res?;
let Some(backend_node_id) = ev.backend_node_id else {
let _ = session
.call(
"Page.setInterceptFileChooserDialog",
json!({ "enabled": false }),
)
.await;
return Err(ZendriverError::FileChooser(
"Page.fileChooserOpened reported no backendNodeId (only choosers opened via an \
<input type=\"file\"> element carry one)"
.to_string(),
));
};
let mode = FileChooserMode::from_cdp(&ev.mode);
let set_result = session
.call(
"DOM.setFileInputFiles",
json!({ "files": files, "backendNodeId": backend_node_id }),
)
.await;
let _ = session
.call(
"Page.setInterceptFileChooserDialog",
json!({ "enabled": false }),
)
.await;
set_result?;
Ok(MatchedFileChooser { mode })
}
pub(crate) async fn register(
session: &SessionHandle,
files: Vec<String>,
) -> Result<FileChooserExpectation> {
let mut stream =
crate::expect::watch::<FileChooserOpenedEvent>(session, "Page.fileChooserOpened");
session
.call(
"Page.setInterceptFileChooserDialog",
json!({ "enabled": true }),
)
.await?;
let (tx, rx) = oneshot::channel();
let cancel = CancellationToken::new();
let cancel_for_task = cancel.clone();
let session_for_task = session.clone();
tokio::spawn(async move {
tokio::select! {
() = cancel_for_task.cancelled() => {
let _ = session_for_task
.call(
"Page.setInterceptFileChooserDialog",
json!({ "enabled": false }),
)
.await;
}
item = stream.next() => {
if let Some(res) = item {
let outcome = handle_event(&session_for_task, files, res).await;
let _ = tx.send(outcome);
}
}
}
});
Ok(FileChooserExpectation {
rx,
timeout: DEFAULT_EXPECT_TIMEOUT,
sleep: None,
cancel,
})
}
#[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_file_chooser_enables_sets_files_then_disables() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let files = vec!["/tmp/a.txt".to_string(), "/tmp/b.pdf".to_string()];
let register_fut = {
let session = session.clone();
let files = files.clone();
tokio::spawn(async move { register(&session, files).await })
};
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
assert_eq!(mock.last_sent()["params"]["enabled"], true);
mock.reply(id, json!({})).await;
let expectation = register_fut
.await
.expect("register task panicked")
.expect("register returned Err");
mock.emit_event_for_session(
"Page.fileChooserOpened",
json!({
"frameId": "F0",
"mode": "selectMultiple",
"backendNodeId": 42,
}),
"S1",
)
.await;
let id = mock.expect_cmd("DOM.setFileInputFiles").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["backendNodeId"], 42);
let sent_files = sent["params"]["files"].as_array().unwrap();
assert_eq!(sent_files.len(), 2);
assert_eq!(sent_files[0], "/tmp/a.txt");
assert_eq!(sent_files[1], "/tmp/b.pdf");
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
assert_eq!(mock.last_sent()["params"]["enabled"], false);
mock.reply(id, json!({})).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.mode, FileChooserMode::SelectMultiple);
conn.shutdown();
}
#[tokio::test]
async fn expect_file_chooser_disables_on_drop_without_matching() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let register_fut = {
let session = session.clone();
tokio::spawn(async move { register(&session, vec!["/tmp/a.txt".to_string()]).await })
};
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
assert_eq!(mock.last_sent()["params"]["enabled"], true);
mock.reply(id, json!({})).await;
let expectation = register_fut
.await
.expect("register task panicked")
.expect("register returned Err");
drop(expectation);
let id = tokio::time::timeout(
Duration::from_secs(2),
mock.expect_cmd("Page.setInterceptFileChooserDialog"),
)
.await
.expect("disable-on-drop did not fire within 2s");
assert_eq!(mock.last_sent()["params"]["enabled"], false);
mock.reply(id, json!({})).await;
conn.shutdown();
}
#[tokio::test]
async fn expect_file_chooser_times_out() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let register_fut = {
let session = session.clone();
tokio::spawn(async move { register(&session, vec!["/tmp/a.txt".to_string()]).await })
};
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
mock.reply(id, json!({})).await;
let expectation = register_fut
.await
.expect("register task panicked")
.expect("register returned Err")
.timeout(Duration::from_millis(50));
let res = expectation.await;
match res {
Err(ZendriverError::Timeout(d)) => {
assert_eq!(d, Duration::from_millis(50));
}
other => panic!("expected Timeout(50ms), got {other:?}"),
}
conn.shutdown();
}
#[tokio::test]
async fn expect_file_chooser_without_backend_node_id_disables_and_errors() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let register_fut = {
let session = session.clone();
tokio::spawn(async move { register(&session, vec!["/tmp/a.txt".to_string()]).await })
};
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
mock.reply(id, json!({})).await;
let expectation = register_fut
.await
.expect("register task panicked")
.expect("register returned Err");
mock.emit_event_for_session(
"Page.fileChooserOpened",
json!({ "frameId": "F0", "mode": "selectSingle" }),
"S1",
)
.await;
let id = mock.expect_cmd("Page.setInterceptFileChooserDialog").await;
assert_eq!(mock.last_sent()["params"]["enabled"], false);
mock.reply(id, json!({})).await;
let res = tokio::time::timeout(Duration::from_secs(2), expectation)
.await
.expect("expectation did not resolve within 2s");
assert!(
matches!(res, Err(ZendriverError::FileChooser(_))),
"expected FileChooser error, got {res:?}",
);
conn.shutdown();
}
}