#![cfg(feature = "integration-tests")]
#![allow(clippy::panic, clippy::unwrap_used)]
use std::collections::HashMap;
use std::time::{Duration, Instant};
use futures::StreamExt;
use serial_test::serial;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use zendriver::{Browser, NetworkEvent};
async fn fixture_with_html(html: &str) -> MockServer {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(html.as_bytes().to_vec(), "text/html"),
)
.mount(&mock)
.await;
mock
}
#[tokio::test]
#[serial]
#[ignore]
async fn monitor_captures_fetch() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/data"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello-monitor"))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(
r#"<!doctype html><html><body>
<script>
window.fetchDone = false;
fetch('/data')
.then(r => r.text())
.then(t => { window.fetchResult = t; window.fetchDone = true; })
.catch(e => { window.fetchErr = String(e); window.fetchDone = true; });
</script>
</body></html>"#
.as_bytes()
.to_vec(),
"text/html",
),
)
.mount(&mock)
.await;
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
let mut monitor = tab.monitor().start().await.unwrap();
tab.goto(&mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
let base = mock.uri();
let data_url = format!("{base}/data");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let exchange = loop {
if std::time::Instant::now() >= deadline {
panic!("monitor did not emit a NetworkEvent::Http for /data within 10s");
}
match tokio::time::timeout(Duration::from_secs(5), monitor.next()).await {
Ok(Some(NetworkEvent::Http(ex))) if ex.request.url == data_url => break ex,
Ok(Some(_)) => continue, Ok(None) => panic!("monitor stream ended before /data exchange"),
Err(_) => panic!("timed out waiting for /data exchange"),
}
};
assert_eq!(exchange.status(), Some(200), "expected status 200");
assert!(exchange.is_success());
assert!(exchange.error.is_none());
let body = exchange.text().await.unwrap();
assert_eq!(body, "hello-monitor", "body mismatch");
monitor.stop();
browser.close().await.unwrap();
}
async fn serve_slow_body(body: Vec<u8>, chunk_size: usize, delay: Duration) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let (mut rd, mut wr) = stream.into_split();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
while matches!(rd.read(&mut buf).await, Ok(n) if n > 0) {}
});
let header = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: application/octet-stream\r\n\
Content-Length: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Connection: close\r\n\r\n",
body.len()
);
if wr.write_all(header.as_bytes()).await.is_err() {
return;
}
for piece in body.chunks(chunk_size) {
if wr.write_all(piece).await.is_err() {
return;
}
let _ = wr.flush().await;
tokio::time::sleep(delay).await;
}
let _ = wr.shutdown().await;
});
format!("http://{addr}")
}
#[tokio::test]
#[serial]
#[ignore]
async fn monitor_stream_bodies_reassembles_large_response() {
let page_mock = fixture_with_html(
r#"<!doctype html><html><body>
<script>window.pageReady = true;</script>
</body></html>"#,
)
.await;
let body: Vec<u8> = (0..256 * 1024).map(|i| (i % 256) as u8).collect();
let data_origin = serve_slow_body(body.clone(), 16 * 1024, Duration::from_millis(20)).await;
let data_url = format!("{data_origin}/big");
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
let mut monitor = tab.monitor().stream_bodies(true).start().await.unwrap();
tab.goto(&page_mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
let fetch_js = format!(
r#"(async () => {{
const r = await fetch({url});
const b = await r.arrayBuffer();
return b.byteLength;
}})()"#,
url = serde_json::json!(data_url)
);
tab.evaluate_main::<serde_json::Value>(&format!(
"({fetch_js}).then(n => {{ window.fetchLen = n; window.fetchDone = true; }})"
))
.await
.unwrap();
let mut chunks_by_id: HashMap<String, Vec<u8>> = HashMap::new();
let deadline = Instant::now() + Duration::from_secs(20);
let exchange = loop {
if Instant::now() >= deadline {
panic!("monitor did not emit a NetworkEvent::Http for /big within 20s");
}
match tokio::time::timeout(Duration::from_secs(5), monitor.next()).await {
Ok(Some(NetworkEvent::HttpData { request_id, chunk })) => {
chunks_by_id.entry(request_id).or_default().extend(chunk);
}
Ok(Some(NetworkEvent::Http(ex))) if ex.request.url == data_url => break ex,
Ok(Some(_)) => continue, Ok(None) => panic!("monitor stream ended before /big exchange"),
Err(_) => panic!("timed out waiting for /big exchange"),
}
};
assert_eq!(exchange.status(), Some(200), "expected status 200");
let streamed = chunks_by_id
.remove(exchange.request_id())
.unwrap_or_default();
assert!(
!streamed.is_empty(),
"expected at least one HttpData chunk for {data_url} (request_id {})",
exchange.request_id()
);
let full_body = exchange.body().await.unwrap();
assert_eq!(
full_body.len(),
body.len(),
"getResponseBody length mismatch"
);
assert_eq!(
streamed, full_body,
"concatenated HttpData chunks must reassemble to the exact response body"
);
assert_eq!(
streamed, body,
"reassembled body must match what the slow-body server sent"
);
let fetch_len: i64 = tab
.evaluate_main("window.fetchDone ? window.fetchLen : -1")
.await
.unwrap();
assert_eq!(fetch_len as usize, body.len());
monitor.stop();
browser.close().await.unwrap();
}
#[tokio::test]
#[serial]
#[ignore]
async fn request_get_inherits_cookies() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(
r#"<!doctype html><html><body><div id="d">x</div></body></html>"#
.as_bytes()
.to_vec(),
"text/html",
),
)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/echo-cookie"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&mock)
.await;
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
tab.goto(&mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
tab.evaluate_main::<serde_json::Value>("document.cookie = 'session=abc123; path=/'; null")
.await
.unwrap();
let cookie_val: String = tab.evaluate_main("document.cookie").await.unwrap();
assert!(
cookie_val.contains("session=abc123"),
"cookie not set on page; got: {cookie_val:?}"
);
let echo_url = format!("{}/echo-cookie", mock.uri());
let resp = tab.request().get(&echo_url).send().await.unwrap();
assert_eq!(resp.status(), 200, "echo-cookie endpoint should return 200");
browser.close().await.unwrap();
}
#[tokio::test]
#[serial]
#[ignore]
async fn request_post_json_round_trips() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"<!doctype html><html><body></body></html>"#.as_bytes().to_vec(),
"text/html",
))
.mount(&mock)
.await;
let echo_body = serde_json::json!({"key": "value", "num": 42});
Mock::given(method("POST"))
.and(path("/echo-json"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(echo_body.to_string().into_bytes())
.insert_header("content-type", "application/json"),
)
.mount(&mock)
.await;
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
tab.goto(&mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct Payload {
key: String,
num: i32,
}
let payload = Payload {
key: "value".into(),
num: 42,
};
let echo_url = format!("{}/echo-json", mock.uri());
let resp = tab
.request()
.post(&echo_url)
.json(&payload)
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let echoed: Payload = resp.json().unwrap();
assert_eq!(echoed, payload, "echoed JSON should match sent payload");
browser.close().await.unwrap();
}
#[tokio::test]
#[serial]
#[ignore]
async fn bypass_cors_reaches_cross_origin() {
let page_mock = fixture_with_html(r#"<!doctype html><html><body></body></html>"#).await;
let cross_origin_mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/resource"))
.respond_with(
ResponseTemplate::new(200).set_body_string("cross-origin-data"),
)
.mount(&cross_origin_mock)
.await;
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
tab.goto(&page_mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
let cross_url = format!("{}/resource", cross_origin_mock.uri());
let resp = tab
.request()
.get(&cross_url)
.bypass_cors()
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"bypass_cors should reach the cross-origin endpoint"
);
let body = resp.text().unwrap();
assert_eq!(body, "cross-origin-data");
browser.close().await.unwrap();
}
#[tokio::test]
#[serial]
#[ignore]
async fn monitor_captures_websocket_frames() {
let ws_url = match std::env::var("WS_ECHO_URL") {
Ok(u) => u,
Err(_) => {
eprintln!("SKIP: WS_ECHO_URL not set; skipping monitor_captures_websocket_frames");
return;
}
};
let page_mock = fixture_with_html(r#"<!doctype html><html><body></body></html>"#).await;
let browser = Browser::builder().headless(true).launch().await.unwrap();
let tab = browser.main_tab();
let mut monitor = tab.monitor().start().await.unwrap();
tab.goto(&page_mock.uri()).await.unwrap();
tab.wait_for_load().await.unwrap();
let js = format!(
r#"(async () => {{
return await new Promise((resolve, reject) => {{
const ws = new WebSocket({url});
ws.onopen = () => ws.send('hello-ws');
ws.onmessage = (e) => {{ ws.close(); resolve(e.data); }};
ws.onerror = (e) => reject(String(e));
setTimeout(() => reject('timeout'), 5000);
}});
}})()"#,
url = serde_json::json!(ws_url)
);
let echo: Option<String> = tab.evaluate_main(&js).await.unwrap_or(None);
assert_eq!(echo.as_deref(), Some("hello-ws"), "WS echo mismatch");
let mut got_sent = false;
let mut got_received = false;
for _ in 0..10 {
match tokio::time::timeout(Duration::from_secs(3), monitor.next()).await {
Ok(Some(NetworkEvent::WebSocketFrame {
direction: zendriver::FrameDirection::Sent,
payload,
..
})) if payload.contains("hello-ws") => got_sent = true,
Ok(Some(NetworkEvent::WebSocketFrame {
direction: zendriver::FrameDirection::Received,
payload,
..
})) if payload.contains("hello-ws") => got_received = true,
Ok(Some(_)) => {}
_ => break,
}
if got_sent && got_received {
break;
}
}
assert!(got_sent, "monitor should have seen the Sent WS frame");
assert!(
got_received,
"monitor should have seen the Received WS frame"
);
monitor.stop();
browser.close().await.unwrap();
}