#![cfg(feature = "browser")]
use crate::brain::tools::browser::{
BrowserClickTool, BrowserCloseTool, BrowserContentTool, BrowserEvalTool, BrowserFindTool,
BrowserManager, BrowserNavigateTool, BrowserScreenshotTool, BrowserTypeTool,
};
use crate::brain::tools::{Tool, ToolExecutionContext};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
const TEST_URL: &str = "https://example.com";
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn navigate_then_screenshot_is_not_blank() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let res = nav
.execute(serde_json::json!({ "url": TEST_URL }), &ctx)
.await
.expect("navigate tool must not panic");
assert!(
res.success,
"navigate to example.com must succeed: {}",
res.output
);
assert!(
!res.images.is_empty(),
"navigate must attach an auto-screenshot to the result"
);
let (_mime, b64) = &res.images[0];
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.as_bytes())
.expect("auto-screenshot must be valid base64");
assert!(
bytes.len() > 4096,
"auto-screenshot is suspiciously small ({} bytes) — \
likely captured a blank page (regression of the 2d09065e fix)",
bytes.len()
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn concurrent_screenshots_do_not_deadlock() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let shot = Arc::new(BrowserScreenshotTool::new(mgr.clone()));
let ctx = ToolExecutionContext::new(Uuid::new_v4());
nav.execute(serde_json::json!({ "url": TEST_URL }), &ctx)
.await
.expect("seed navigate must succeed");
let shot_a = shot.clone();
let ctx_a = ctx.clone();
let shot_b = shot.clone();
let ctx_b = ctx.clone();
let combined = tokio::time::timeout(
Duration::from_secs(15),
futures::future::join(
async move { shot_a.execute(serde_json::json!({}), &ctx_a).await },
async move { shot_b.execute(serde_json::json!({}), &ctx_b).await },
),
)
.await;
let (a, b) = combined.expect(
"concurrent screenshots took >15s — likely deadlocked behind \
the manager mutex (regression of the 7f58c6f9 fix)",
);
assert!(a.unwrap().success);
assert!(b.unwrap().success);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn close_actually_removes_session_page() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let close = BrowserCloseTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
nav.execute(serde_json::json!({ "url": TEST_URL }), &ctx)
.await
.expect("navigate must succeed");
let key = BrowserManager::page_name_for_session(ctx.session_id);
assert!(
mgr.list_pages().await.contains(&key),
"after navigate, session must have an open page"
);
let close_res = close.execute(serde_json::json!({}), &ctx).await.unwrap();
assert!(
close_res.success,
"browser_close must succeed on an open session"
);
assert!(
!mgr.list_pages().await.contains(&key),
"after browser_close, the session's page must be gone from the manager"
);
let second = close.execute(serde_json::json!({}), &ctx).await.unwrap();
assert!(
second.success,
"browser_close must be idempotent — second call should not error"
);
}
#[tokio::test]
#[ignore = "requires external Chromium with CDP enabled on port 9222"]
async fn cdp_endpoint_connects_to_existing_browser() {
use crate::config::BrowserConfig;
let config = BrowserConfig {
cdp_endpoint: Some("ws://localhost:9222".to_string()),
};
let mgr = Arc::new(BrowserManager::new(config));
let nav = BrowserNavigateTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let res = nav
.execute(serde_json::json!({ "url": TEST_URL }), &ctx)
.await
.expect("navigate tool must not panic");
assert!(
res.success,
"navigate must succeed when connecting to existing CDP endpoint: {}",
res.output
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
const SHADOW_FIXTURE: &str = r#"<!doctype html>
<html><head><meta charset="utf-8"><title>shadow-fixture</title>
<style>
open-host, nested-host, closed-host, overlay-host { display:block; height:60px; }
</style></head>
<body>
<div id="light"><button id="light-btn">Light Button</button></div>
<open-host></open-host>
<nested-host></nested-host>
<closed-host></closed-host>
<overlay-host></overlay-host>
<script>
customElements.define('open-host', class extends HTMLElement {
connectedCallback() {
const r = this.attachShadow({mode:'open'});
r.innerHTML = '<button id="shadow-btn">Shadow Button</button>'
+ '<input id="shadow-input" type="text">';
r.getElementById('shadow-btn').addEventListener('click',
() => { window.__clicked = 'shadow-btn'; });
}
});
customElements.define('inner-host', class extends HTMLElement {
connectedCallback() {
const r = this.attachShadow({mode:'open'});
r.innerHTML = '<button id="deep-btn">Deep Button</button>';
}
});
customElements.define('nested-host', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode:'open'}).innerHTML = '<div><inner-host></inner-host></div>';
}
});
customElements.define('closed-host', class extends HTMLElement {
connectedCallback() {
const r = this.attachShadow({mode:'closed'});
r.innerHTML = '<button id="closed-btn">Closed Button</button>';
r.getElementById('closed-btn').addEventListener('click',
() => { window.__clicked = 'closed-btn'; });
}
});
customElements.define('overlay-host', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode:'open'}).innerHTML =
'<div style="position:relative;width:160px;height:40px">'
+ '<button id="under-btn" style="position:absolute;inset:0">Under Overlay</button>'
+ '<div style="position:absolute;inset:0;background:rgba(0,0,0,.2);'
+ 'pointer-events:none"></div></div>';
}
});
</script></body></html>"#;
fn shadow_fixture_url() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().expect("tempdir for shadow fixture");
let path = dir.path().join("shadow.html");
std::fs::write(&path, SHADOW_FIXTURE).expect("write shadow fixture");
let url = format!("file://{}", path.display());
(dir, url)
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn inventory_surfaces_open_shadow_roots() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let find = BrowserFindTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let (_dir, url) = shadow_fixture_url();
nav.execute(serde_json::json!({ "url": url }), &ctx)
.await
.expect("navigate to the fixture must not panic");
let res = find
.execute(serde_json::json!({}), &ctx)
.await
.expect("browser_find must not panic");
assert!(res.success, "inventory must succeed: {}", res.output);
let out = res.output;
assert!(out.contains("Light Button"), "light DOM regression: {out}");
assert!(
out.contains("Shadow Button"),
"open shadow root must be enumerated: {out}"
);
assert!(
out.contains("Deep Button"),
"shadow root nested two levels deep must be enumerated: {out}"
);
assert!(
out.contains("[shadow]"),
"shadow-resident elements must be marked: {out}"
);
assert!(
out.contains("Under Overlay"),
"a shadow element under a pointer-events:none overlay must NOT be \
reported occluded — that is the elementFromPoint retarget fix: {out}"
);
assert!(
!out.contains("Closed Button"),
"closed shadow roots are documented as NOT enumerable: {out}"
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn found_shadow_selector_clicks_and_types() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let find = BrowserFindTool::new(mgr.clone());
let click = BrowserClickTool::new(mgr.clone());
let typer = BrowserTypeTool::new(mgr.clone());
let eval = BrowserEvalTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let (_dir, url) = shadow_fixture_url();
nav.execute(serde_json::json!({ "url": url }), &ctx)
.await
.expect("navigate must not panic");
let found = find
.execute(serde_json::json!({ "pattern": "#shadow-btn" }), &ctx)
.await
.expect("find must not panic");
assert!(
found.success && found.output.contains("Shadow Button"),
"css search must pierce open roots: {}",
found.output
);
let selector = extract_match_selector(&found.output)
.expect("find must hand back an indexed selector for the shadow button");
let clicked = click
.execute(serde_json::json!({ "selector": selector }), &ctx)
.await
.expect("click must not panic");
assert!(
clicked.success,
"a selector from browser_find must resolve in browser_click: {}",
clicked.output
);
let seen = eval
.execute(
serde_json::json!({ "script": "window.__clicked || ''" }),
&ctx,
)
.await
.expect("eval must not panic");
assert!(
seen.output.contains("shadow-btn"),
"the click must have reached the shadow button: {}",
seen.output
);
let typed = typer
.execute(
serde_json::json!({ "selector": "#shadow-input", "text": "hello-shadow" }),
&ctx,
)
.await
.expect("type must not panic");
assert!(typed.success, "type into shadow input: {}", typed.output);
let value = eval
.execute(
serde_json::json!({
"script": "document.querySelector('open-host').shadowRoot\
.getElementById('shadow-input').value"
}),
&ctx,
)
.await
.expect("eval must not panic");
assert!(
value.output.contains("hello-shadow"),
"browser_type must have filled the shadow input: {}",
value.output
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn closed_shadow_root_resolves_over_cdp() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let click = BrowserClickTool::new(mgr.clone());
let eval = BrowserEvalTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let (_dir, url) = shadow_fixture_url();
nav.execute(serde_json::json!({ "url": url }), &ctx)
.await
.expect("navigate must not panic");
let clicked = click
.execute(serde_json::json!({ "selector": "#closed-btn" }), &ctx)
.await
.expect("click must not panic");
assert!(
clicked.success,
"closed shadow root must resolve via find_elements_pierced: {}",
clicked.output
);
let seen = eval
.execute(
serde_json::json!({ "script": "window.__clicked || ''" }),
&ctx,
)
.await
.expect("eval must not panic");
assert!(
seen.output.contains("closed-btn"),
"the click must have reached the closed-root button: {}",
seen.output
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
#[tokio::test]
#[ignore = "launches real Chrome — opt-in via `cargo test -- --ignored browser_e2e`"]
async fn body_inner_text_includes_shadow_text() {
let mgr = Arc::new(BrowserManager::new(Default::default()));
let nav = BrowserNavigateTool::new(mgr.clone());
let eval = BrowserEvalTool::new(mgr.clone());
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let (_dir, url) = shadow_fixture_url();
nav.execute(serde_json::json!({ "url": url }), &ctx)
.await
.expect("navigate must not panic");
let res = eval
.execute(
serde_json::json!({
"script": "String(document.body.innerText.includes('Shadow Button'))"
}),
&ctx,
)
.await
.expect("eval must not panic");
assert!(
res.output.contains("false"),
"document.body.innerText was measured NOT to include shadow-rendered \
text; a `true` here means Chrome changed and browser_content's \
full-page text path can be simplified. got: {}",
res.output
);
let content = BrowserContentTool::new(mgr.clone());
let text = content
.execute(serde_json::json!({ "text_only": true }), &ctx)
.await
.expect("content must not panic");
assert!(text.success, "content must succeed: {}", text.output);
assert!(
text.output.contains("Light Button"),
"light DOM text regression: {}",
text.output
);
assert!(
text.output.contains("Shadow Button") && text.output.contains("Deep Button"),
"full-page text_only must span the composed tree, including a root \
nested two levels deep: {}",
text.output
);
let close = BrowserCloseTool::new(mgr);
let _ = close.execute(serde_json::json!({}), &ctx).await;
}
fn extract_match_selector(output: &str) -> Option<String> {
let start = output.find("[data-opencrabs-match=\"")?;
let rest = &output[start..];
let end = rest.find("\"]")? + 2;
Some(rest[..end].to_string())
}