#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, reason = "test harness")]
use std::{collections::HashMap, sync::Arc};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use rmcp::model::RawContent;
use tokio::sync::Mutex;
use void_crawl_core::BrowserSession;
use voidcrawl_mcp::{
AppState, VoidCrawlServer,
sessions::{DedicatedSession, SessionRegistry},
tools::{
screenshot::{self, SessionScreenshotArgs},
selector::{SelectorArg, SelectorKindArg},
session::{self, SessionIdArgs},
viewport::BboxArg,
},
};
const SID: &str = "selector-bbox-session";
fn data_url(html: &str) -> String {
let encoded = html
.replace('%', "%25")
.replace('"', "%22")
.replace('#', "%23")
.replace('<', "%3C")
.replace('>', "%3E")
.replace(' ', "%20")
.replace('\n', "%0A");
format!("data:text/html,{encoded}")
}
const FIXTURE: &str = r#"
<!doctype html>
<html>
<head><title>Selector Bbox MCP Fixture</title></head>
<body style="margin:0">
<h1 style="position:absolute; left:10px; top:10px; width:200px; height:30px; margin:0;">
Unique Heading
</h1>
<div class="dup" style="position:absolute; left:300px; top:10px; width:40px; height:40px;">A</div>
<div class="dup" style="position:absolute; left:300px; top:60px; width:40px; height:40px;">B</div>
</body>
</html>
"#;
fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
let width = u32::from_be_bytes(bytes[16..20].try_into().expect("IHDR width bytes"));
let height = u32::from_be_bytes(bytes[20..24].try_into().expect("IHDR height bytes"));
(width, height)
}
fn selector(kind: SelectorKindArg, value: &str) -> SelectorArg {
SelectorArg {
kind,
value: Some(value.to_string()),
regex: None,
name: None,
nth: None,
x: None,
y: None,
}
}
async fn server_with_page(html: &str) -> VoidCrawlServer {
let session =
BrowserSession::builder().headless().no_sandbox().launch().await.expect("launch chromium");
let page = session.new_page(&data_url(html)).await.expect("navigate fixture");
let handle = Arc::new(DedicatedSession {
session: Arc::new(session),
page: Mutex::new(page),
profile_lease: None,
last_navigation: Mutex::new(None),
challenge: Mutex::new(None),
pending_download: Mutex::new(None),
pending_network_capture: Mutex::new(None),
pending_recording: Mutex::new(None),
cookie_leases: Mutex::new(HashMap::new()),
});
let sessions = Arc::new(SessionRegistry::default());
sessions.insert(SID.to_string(), handle).await;
VoidCrawlServer::new(Arc::new(AppState::new(sessions)))
}
async fn teardown(server: &VoidCrawlServer) {
session::close(server, SessionIdArgs { session_id: SID.to_string() }).await.ok();
}
#[tokio::test]
async fn session_screenshot_selector_crops_the_resolved_css_element() {
let server = server_with_page(FIXTURE).await;
let result = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(selector(SelectorKindArg::Css, "h1")),
..Default::default()
},
)
.await
.expect("session_screenshot ok");
let image = result
.content
.iter()
.find_map(|c| match &c.raw {
RawContent::Image(i) => Some(i.data.clone()),
_ => None,
})
.expect("image content block");
let bytes = B64.decode(image).expect("valid base64");
assert_eq!(png_dimensions(&bytes), (200, 30));
teardown(&server).await;
}
#[tokio::test]
async fn session_screenshot_selector_and_bbox_together_is_rejected() {
let server = server_with_page(FIXTURE).await;
let err = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(selector(SelectorKindArg::Css, "h1")),
bbox: Some(BboxArg { x: 0, y: 0, width: 10, height: 10 }),
..Default::default()
},
)
.await
.expect_err("should be rejected");
assert!(err.message.contains("mutually exclusive"), "got: {}", err.message);
teardown(&server).await;
}
#[tokio::test]
async fn session_screenshot_ambiguous_selector_fails_with_invalid_params() {
let server = server_with_page(FIXTURE).await;
let err = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(selector(SelectorKindArg::Css, ".dup")),
..Default::default()
},
)
.await
.expect_err("ambiguous selector should error");
assert!(err.message.contains("visible matches"), "got: {}", err.message);
teardown(&server).await;
}
#[tokio::test]
async fn session_screenshot_no_match_selector_fails_with_invalid_params() {
let server = server_with_page(FIXTURE).await;
let err = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(selector(SelectorKindArg::Css, ".nope")),
..Default::default()
},
)
.await
.expect_err("no match should error");
assert!(err.message.contains("no elements"), "got: {}", err.message);
teardown(&server).await;
}
#[tokio::test]
async fn session_screenshot_jsonld_selector_fails_as_non_visual() {
let server = server_with_page(FIXTURE).await;
let err = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(selector(SelectorKindArg::Jsonld, "$.name")),
..Default::default()
},
)
.await
.expect_err("jsonld should error");
assert!(err.message.contains("non-visual"), "got: {}", err.message);
teardown(&server).await;
}
#[tokio::test]
async fn session_screenshot_selector_with_nth_disambiguates() {
let server = server_with_page(FIXTURE).await;
let mut sel = selector(SelectorKindArg::Css, ".dup");
sel.nth = Some(1);
let result = screenshot::session(
&server,
SessionScreenshotArgs {
session_id: SID.to_string(),
selector: Some(sel),
..Default::default()
},
)
.await
.expect("session_screenshot ok");
let image = result
.content
.iter()
.find_map(|c| match &c.raw {
RawContent::Image(i) => Some(i.data.clone()),
_ => None,
})
.expect("image content block");
let bytes = B64.decode(image).expect("valid base64");
assert_eq!(png_dimensions(&bytes), (40, 40));
teardown(&server).await;
}