use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::oneshot;
use tokio::time::Sleep;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
use crate::expect::UrlMatcher;
const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct MatchedResponse {
pub url: String,
pub status: u16,
pub status_text: String,
pub headers: HashMap<String, String>,
pub request_id: String,
pub(crate) session: SessionHandle,
}
impl std::fmt::Debug for MatchedResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MatchedResponse")
.field("url", &self.url)
.field("status", &self.status)
.field("status_text", &self.status_text)
.field("headers", &self.headers)
.field("request_id", &self.request_id)
.field("session", &"<SessionHandle>")
.finish()
}
}
impl MatchedResponse {
pub async fn body(&self) -> Result<Vec<u8>> {
let res = self
.session
.call(
"Network.getResponseBody",
json!({ "requestId": self.request_id }),
)
.await?;
let body = res
.get("body")
.and_then(Value::as_str)
.ok_or_else(|| ZendriverError::Cdp {
code: 0,
message: "Network.getResponseBody returned no body field".into(),
data: None,
})?;
let base64_encoded = res
.get("base64Encoded")
.and_then(Value::as_bool)
.unwrap_or(false);
if base64_encoded {
BASE64.decode(body).map_err(|e| ZendriverError::Cdp {
code: 0,
message: format!("Network.getResponseBody returned invalid base64: {e}"),
data: None,
})
} else {
Ok(body.as_bytes().to_vec())
}
}
}
#[derive(Debug)]
pub struct ResponseExpectation {
rx: oneshot::Receiver<MatchedResponse>,
timeout: Duration,
sleep: Option<Pin<Box<Sleep>>>,
}
impl ResponseExpectation {
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self.sleep = None;
self
}
pub async fn matched(self) -> Result<MatchedResponse> {
self.await
}
}
impl Future for ResponseExpectation {
type Output = Result<MatchedResponse>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.rx).poll(cx) {
Poll::Ready(Ok(resp)) => return Poll::Ready(Ok(resp)),
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 ResponseReceivedEvent {
#[serde(rename = "requestId")]
request_id: String,
response: ResponsePayload,
}
#[derive(Debug, Deserialize)]
struct ResponsePayload {
url: String,
status: u16,
#[serde(rename = "statusText", default)]
status_text: String,
#[serde(default)]
headers: HashMap<String, String>,
}
pub(crate) fn register(session: &SessionHandle, matcher: UrlMatcher) -> ResponseExpectation {
let (tx, rx) = oneshot::channel();
let mut stream = session.subscribe::<ResponseReceivedEvent>("Network.responseReceived");
let session_for_match = session.clone();
tokio::spawn(async move {
while let Some(ev) = stream.next().await {
if matcher.matches(&ev.response.url) {
let matched = MatchedResponse {
url: ev.response.url,
status: ev.response.status,
status_text: ev.response.status_text,
headers: ev.response.headers,
request_id: ev.request_id,
session: session_for_match,
};
let _ = tx.send(matched);
return;
}
}
});
ResponseExpectation {
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_response_resolves_on_matching_event() {
let (mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let expectation = register(&session, UrlMatcher::from("/api/"));
mock.emit_event_for_session(
"Network.responseReceived",
json!({
"requestId": "R0",
"response": {
"url": "https://example.com/static/app.js",
"status": 200,
"statusText": "OK",
"headers": {},
},
}),
"S1",
)
.await;
mock.emit_event_for_session(
"Network.responseReceived",
json!({
"requestId": "R1",
"response": {
"url": "https://example.com/api/users",
"status": 201,
"statusText": "Created",
"headers": {
"content-type": "application/json",
},
},
}),
"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.request_id, "R1");
assert_eq!(matched.url, "https://example.com/api/users");
assert_eq!(matched.status, 201);
assert_eq!(matched.status_text, "Created");
assert_eq!(
matched.headers.get("content-type").map(String::as_str),
Some("application/json"),
);
conn.shutdown();
}
#[tokio::test]
async fn body_dispatches_get_response_body_and_decodes_base64() {
let (mut mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let raw = b"\x89PNG\r\n\x1a\nfake".to_vec();
let encoded = BASE64.encode(&raw);
let matched = MatchedResponse {
url: "https://example.com/img.png".into(),
status: 200,
status_text: "OK".into(),
headers: HashMap::new(),
request_id: "REQ-42".into(),
session: session.clone(),
};
let fut = tokio::spawn(async move { matched.body().await });
let id = mock.expect_cmd("Network.getResponseBody").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["requestId"], "REQ-42");
mock.reply(
id,
json!({
"body": encoded,
"base64Encoded": true,
}),
)
.await;
let bytes = fut
.await
.expect("body task panicked")
.expect("body returned Err");
assert_eq!(bytes, raw);
conn.shutdown();
}
}