#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, reason = "test harness")]
use std::{sync::Arc, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use tokio::{sync::Mutex, time::sleep};
use void_crawl_core::BrowserSession;
use voidcrawl_mcp::{
AppState, VoidCrawlServer,
sessions::{DedicatedSession, SessionRegistry},
tools::{
actions::{self, EvalJsArgs, EvalJsInFrameArgs},
session::{self, SessionIdArgs},
},
};
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 SID: &str = "test-session";
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),
pending_download: Mutex::new(None),
});
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();
}
fn fixture() -> String {
let child = "<p>CHILDFRAME</p><div id=secret>42</div>";
let child_url = data_url(child);
let b64 = STANDARD.encode(child_url);
format!(
"<h1>parent</h1><iframe id=f></iframe>\
<script>document.getElementById('f').src = atob('{b64}');</script>"
)
}
#[tokio::test]
async fn eval_js_in_frame_reads_cross_origin_iframe_the_parent_cannot() {
let server = server_with_page(&fixture()).await;
let from_parent = actions::eval_js(
&server,
EvalJsArgs {
session_id: SID.to_string(),
expression: "(() => { const f = document.querySelector('iframe'); \
try { return f.contentDocument \
? f.contentDocument.getElementById('secret').textContent \
: 'CROSS_ORIGIN_NULL'; } \
catch (e) { return 'CROSS_ORIGIN_THROW'; } })()"
.to_string(),
},
)
.await
.expect("eval_js ok");
assert!(
matches!(from_parent.value.as_str(), Some("CROSS_ORIGIN_NULL" | "CROSS_ORIGIN_THROW")),
"parent must NOT read the cross-origin child, got: {:?}",
from_parent.value
);
let mut from_frame = None;
for _ in 0..30 {
match actions::eval_js_in_frame(
&server,
EvalJsInFrameArgs {
session_id: SID.to_string(),
frame_url_pattern: "CHILDFRAME".to_string(),
expression: "document.getElementById('secret').textContent".to_string(),
},
)
.await
{
Ok(r) if r.value.as_str() == Some("42") => {
from_frame = Some(r);
break;
}
_ => sleep(Duration::from_millis(100)).await,
}
}
let from_frame = from_frame.expect("eval_js_in_frame should read the child's secret");
assert_eq!(from_frame.value.as_str(), Some("42"), "frame-scoped read should see the secret");
let driven = actions::eval_js_in_frame(
&server,
EvalJsInFrameArgs {
session_id: SID.to_string(),
frame_url_pattern: "CHILDFRAME".to_string(),
expression: "(() => { document.getElementById('secret').textContent = '99'; \
return document.getElementById('secret').textContent; })()"
.to_string(),
},
)
.await
.expect("eval_js_in_frame drive ok");
assert_eq!(driven.value.as_str(), Some("99"), "frame-scoped eval should mutate the child");
teardown(&server).await;
}
#[tokio::test]
async fn eval_js_in_frame_errors_when_no_frame_matches() {
let server = server_with_page(&fixture()).await;
let err = actions::eval_js_in_frame(
&server,
EvalJsInFrameArgs {
session_id: SID.to_string(),
frame_url_pattern: "no-such-frame-xyz".to_string(),
expression: "1 + 1".to_string(),
},
)
.await
.expect_err("a non-matching pattern must error, not silently run in the top frame");
assert!(
err.message.contains("no-such-frame-xyz"),
"error should name the missing frame, got: {}",
err.message
);
teardown(&server).await;
}
fn ambiguous_fixture() -> String {
let enc = |html: &str| STANDARD.encode(data_url(html));
let a = enc("<p>SHARED-A</p>");
let b = enc("<p>SHARED-B</p>");
format!(
"<iframe id=a></iframe><iframe id=b></iframe><script>\
document.getElementById('a').src = atob('{a}');\
document.getElementById('b').src = atob('{b}');</script>"
)
}
#[tokio::test]
async fn eval_js_in_frame_fails_closed_when_pattern_is_ambiguous() {
let server = server_with_page(&ambiguous_fixture()).await;
let mut err = None;
for _ in 0..30 {
match actions::eval_js_in_frame(
&server,
EvalJsInFrameArgs {
session_id: SID.to_string(),
frame_url_pattern: "SHARED".to_string(),
expression: "1".to_string(),
},
)
.await
{
Ok(_) => panic!("an ambiguous pattern must not resolve to a single frame"),
Err(e) if e.message.contains("matched 2 frames") => {
err = Some(e);
break;
}
Err(_) => sleep(Duration::from_millis(100)).await,
}
}
assert!(
err.is_some(),
"ambiguous pattern should surface AmbiguousFrame once both frames exist"
);
teardown(&server).await;
}