use bao_cdp::servo_bridge::{BridgeCommand, BridgeResponse};
use bao_cdp_client::bridge::ServoEvent;
use base64::Engine;
use serde_json::Value;
use servo::{CookieSource, StorageType};
use std::collections::HashSet;
use crate::config::PageConfig;
use crate::delegate::{
ServiceWorkerHandle, ServiceWorkerRegistrationId, ServiceWorkerRegistrationState,
};
use crate::error::BrowserError;
use crate::page::PageHandle;
use crate::page_pool::PagePool;
use crate::screenshot::ScreenshotFormat;
pub fn handle_bridge_command(cmd: BridgeCommand, pool: &PagePool) -> BridgeResponse {
let result = match cmd {
BridgeCommand::CreateTarget { url } => cmd_create_target(pool, &url),
BridgeCommand::ListTargets => cmd_list_targets(pool),
BridgeCommand::Navigate { target_id, url } => {
with_page(pool, &target_id, |page| cmd_navigate(page, &url))
}
BridgeCommand::EvaluateJs {
target_id,
expression,
return_by_value,
} => with_page(pool, &target_id, |page| {
cmd_evaluate(page, &expression, return_by_value)
}),
BridgeCommand::TakeScreenshot {
target_id,
format,
quality: _,
} => with_page(pool, &target_id, |page| cmd_screenshot(page, &format)),
BridgeCommand::GetTitle { target_id } => with_page(pool, &target_id, cmd_get_title),
BridgeCommand::GetUrl { target_id } => with_page(pool, &target_id, cmd_get_url),
BridgeCommand::GetDocument { target_id } => with_page(pool, &target_id, cmd_get_document),
BridgeCommand::QuerySelector {
target_id,
selector,
} => with_page(pool, &target_id, |page| cmd_query_selector(page, &selector)),
BridgeCommand::QuerySelectorAll {
target_id,
selector,
} => with_page(pool, &target_id, |page| {
cmd_query_selector_all(page, &selector)
}),
BridgeCommand::GetOuterHtml { target_id, .. } => {
with_page(pool, &target_id, cmd_get_outer_html)
}
BridgeCommand::SetAttributeValue {
target_id,
node_id: _,
name,
value,
} => with_page(pool, &target_id, |page| {
cmd_set_attribute(page, &name, &value)
}),
BridgeCommand::DispatchMouseEvent {
target_id,
event_type,
x,
y,
button,
click_count,
} => with_page(pool, &target_id, |page| {
cmd_mouse_event(page, &event_type, x, y, button, click_count)
}),
BridgeCommand::DispatchKeyEvent {
target_id,
event_type,
key,
code,
text,
} => with_page(pool, &target_id, |page| {
cmd_key_event(page, &event_type, &key, &code, text.as_deref())
}),
BridgeCommand::InsertText { target_id, text } => {
with_page(pool, &target_id, |page| cmd_insert_text(page, &text))
}
BridgeCommand::SetViewport {
target_id,
width,
height,
device_scale_factor: _,
} => with_page(pool, &target_id, |page| {
cmd_set_viewport(page, width, height)
}),
BridgeCommand::SetUserAgent {
target_id,
user_agent,
} => with_page(pool, &target_id, |page| {
cmd_set_user_agent(page, &user_agent)
}),
BridgeCommand::AddScriptToEvaluateOnNewDocument { target_id, source } => {
with_page(pool, &target_id, |page| cmd_add_script(page, &source))
}
BridgeCommand::Reload {
target_id,
ignore_cache: _,
} => with_page(pool, &target_id, cmd_reload),
BridgeCommand::GoBack { target_id } => with_page(pool, &target_id, cmd_go_back),
BridgeCommand::GoForward { target_id } => with_page(pool, &target_id, cmd_go_forward),
BridgeCommand::StopLoading { .. } => Err(
"Page.stopLoading not supported: servo WebView has no stop-loading API".into(),
),
BridgeCommand::ClosePage { target_id } => {
let id = parse_target_id(&target_id);
match id {
Some(id) => {
let _ = pool.close_page(id);
Ok(serde_json::json!({}))
}
None => Err(format!("invalid target_id: {target_id}")),
}
}
BridgeCommand::GetCookies { target_id, urls } => {
with_page(pool, &target_id, |page| cmd_get_cookies(page, &urls))
}
BridgeCommand::GetAllCookies { target_id } => {
with_page(pool, &target_id, cmd_get_all_cookies)
}
BridgeCommand::DeleteCookie {
target_id,
name,
url,
} => with_page(pool, &target_id, |page| {
cmd_delete_cookie(page, &name, url.as_deref())
}),
BridgeCommand::SetCookie {
target_id,
name,
value,
url,
domain,
} => with_page(pool, &target_id, |page| {
cmd_set_cookie(page, &name, &value, url.as_deref(), domain.as_deref())
}),
BridgeCommand::GetResponseBody { .. } => Err(
"Network.getResponseBody not supported: servo does not expose stored response bodies to the embedder".into(),
),
BridgeCommand::NetworkEnable { target_id } => {
with_page(pool, &target_id, cmd_network_enable)
}
BridgeCommand::NetworkDisable { .. } => ok_empty(),
BridgeCommand::NetworkSetCacheDisabled {
target_id,
cache_disabled,
} => with_page(pool, &target_id, |page| {
cmd_network_set_cache_disabled(page, cache_disabled)
}),
BridgeCommand::NetworkSetExtraHTTPHeaders { .. } => Err(
"Network.setExtraHTTPHeaders not supported: servo WebView has no extra-headers injection API".into(),
),
BridgeCommand::NetworkClearBrowserCache { target_id } => {
with_page(pool, &target_id, cmd_network_clear_browser_cache)
}
BridgeCommand::NetworkClearBrowserCookies { target_id } => {
with_page(pool, &target_id, cmd_network_clear_browser_cookies)
}
BridgeCommand::StorageGetStorageItemsForOrigin {
target_id,
origin,
storage_type,
} => with_page(pool, &target_id, |page| {
cmd_storage_get_items(page, origin, storage_type)
}),
BridgeCommand::StorageClearDataForOrigin {
target_id,
origin,
storage_type,
} => with_page(pool, &target_id, |page| {
cmd_storage_clear_data(page, origin, storage_type)
}),
BridgeCommand::SecurityEnable { .. } => ok_empty(),
BridgeCommand::SecurityDisable { .. } => ok_empty(),
BridgeCommand::SecuritySetOverrideCertificateErrors { .. } => Err(
"Security.setOverrideCertificateErrors not supported at runtime: certificate-error override is startup-only (BaoConfig.ignore_certificate_errors)".into(),
),
BridgeCommand::DebuggerEnable { target_id } => {
with_page(pool, &target_id, |page| cmd_debugger_enable(page))
}
BridgeCommand::DebuggerDisable { target_id } => {
with_page(pool, &target_id, |page| cmd_debugger_disable(page))
}
BridgeCommand::DebuggerSetBreakpoint {
target_id,
url,
url_regex,
line,
column,
} => with_page(pool, &target_id, |page| {
cmd_debugger_set_breakpoint(page, url.as_deref(), url_regex.as_deref(), line, column)
}),
BridgeCommand::DebuggerRemoveBreakpoint {
target_id,
breakpoint_id,
} => with_page(pool, &target_id, |page| {
cmd_debugger_remove_breakpoint(page, &breakpoint_id)
}),
BridgeCommand::DebuggerInterrupt { target_id } => {
with_page(pool, &target_id, |page| cmd_debugger_interrupt(page))
}
BridgeCommand::DebuggerResume {
target_id,
step_type,
} => with_page(pool, &target_id, |page| {
cmd_debugger_resume(page, step_type.as_deref())
}),
BridgeCommand::DebuggerListFrames { target_id } => {
with_page(pool, &target_id, |page| cmd_debugger_list_frames(page))
}
BridgeCommand::DebuggerGetEnvironment { target_id, .. } => {
with_page(pool, &target_id, |page| cmd_debugger_get_environment(page))
}
BridgeCommand::DebuggerEval {
target_id,
expression,
frame_actor_id: _,
} => with_page(pool, &target_id, |page| {
cmd_evaluate(page, &expression, true)
}),
BridgeCommand::DebuggerGetPossibleBreakpoints {
target_id,
start_script_id,
} => with_page(pool, &target_id, |page| {
cmd_debugger_get_possible_breakpoints(page, &start_script_id)
}),
BridgeCommand::DebuggerGetScriptSource {
target_id,
script_id,
} => with_page(pool, &target_id, |page| {
cmd_debugger_get_script_source(page, script_id)
}),
BridgeCommand::DebuggerBlackbox { target_id, .. } => {
with_page(pool, &target_id, |page| cmd_debugger_blackbox(page))
}
BridgeCommand::DebuggerUnblackbox { target_id, .. } => {
with_page(pool, &target_id, |page| cmd_debugger_unblackbox(page))
}
BridgeCommand::ProfilerStart { .. }
| BridgeCommand::ProfilerStop { .. }
| BridgeCommand::ProfilerSetSamplingInterval { .. } => Err(
"Profiler not supported: SpiderMonkey sampling profiler is not exposed through the mozjs FFI surface".into(),
),
BridgeCommand::HeapProfilerTakeSnapshot { .. }
| BridgeCommand::HeapProfilerStartTracking { .. }
| BridgeCommand::HeapProfilerStopTracking { .. } => Err(
"HeapProfiler snapshot/tracking not supported: mozjs FFI exposes no heap-snapshot API".into(),
),
BridgeCommand::HeapProfilerCollectGarbage { target_id } => {
with_page(pool, &target_id, cmd_collect_garbage)
}
BridgeCommand::MemoryGetDOMCounters { .. } => Err(
"Memory.getDOMCounters not supported: jsEventListeners count is not introspectable in SpiderMonkey".into(),
),
BridgeCommand::MemoryPurgeJS { target_id } => {
with_page(pool, &target_id, cmd_collect_garbage)
}
BridgeCommand::PerformanceGetMetrics { target_id } => {
with_page(pool, &target_id, cmd_performance_get_metrics)
}
BridgeCommand::CssGetComputedStyleForNode { target_id, node_id } => {
with_page(pool, &target_id, |page| {
cmd_css_get_computed_style(page, node_id)
})
}
BridgeCommand::CssGetMatchedStylesForNode { target_id, node_id } => {
with_page(pool, &target_id, |page| {
cmd_css_get_matched_styles(page, node_id)
})
}
BridgeCommand::CssGetInlineStylesForNode { target_id, node_id } => {
with_page(pool, &target_id, |page| {
cmd_css_get_inline_styles(page, node_id)
})
}
BridgeCommand::RuntimeGetProperties {
target_id,
object_id,
own_properties,
} => with_page(pool, &target_id, |page| {
cmd_runtime_get_properties(page, &object_id, own_properties)
}),
BridgeCommand::RuntimeCallFunctionOn {
target_id,
object_id,
execution_context_id,
function_declaration,
arguments,
return_by_value,
await_promise,
object_group,
} => with_page(pool, &target_id, |page| {
cmd_runtime_call_function_on(
page,
object_id.as_deref(),
execution_context_id,
&function_declaration,
arguments.as_ref(),
return_by_value,
await_promise,
object_group.as_deref(),
)
}),
BridgeCommand::RuntimeReleaseObject {
target_id,
object_id,
} => with_page(pool, &target_id, |page| {
cmd_runtime_release_object(page, &object_id)
}),
BridgeCommand::RuntimeReleaseObjectGroup {
target_id,
object_group,
} => with_page(pool, &target_id, |page| {
cmd_runtime_release_object_group(page, &object_group)
}),
BridgeCommand::TerminateServiceWorker {
target_id,
registration_id,
} => with_page(pool, &target_id, |page| {
cmd_terminate_service_worker(page, ®istration_id)
}),
BridgeCommand::ListWorkerTargets { target_id } => {
with_page(pool, &target_id, |page| {
let state = page.webview_state();
let st = state.borrow();
let mut workers: Vec<Value> = st
.dedicated_worker_scopes()
.into_iter()
.map(|scope| worker_target_json(&scope.worker_id.0, "worker"))
.collect();
workers.extend(
st.shared_worker_scopes()
.into_iter()
.map(|scope| worker_target_json(&scope.shared_worker_id.script_url, "shared_worker")),
);
Ok(serde_json::json!({ "workerTargets": workers }))
})
}
BridgeCommand::GetWorkerTargetInfo {
target_id,
worker_id,
} => with_page(pool, &target_id, |page| {
let state = page.webview_state();
let st = state.borrow();
if let Some(scope) = st.dedicated_worker_scope_by_url(&worker_id) {
return Ok(serde_json::json!({
"targetInfo": worker_target_json(&scope.worker_id.0, "worker")
}));
}
if let Some(scope) = st.shared_worker_scope_by_script_url(&worker_id) {
return Ok(serde_json::json!({
"targetInfo": worker_target_json(
&scope.shared_worker_id.script_url,
"shared_worker"
)
}));
}
Err(format!("unknown worker targetId: {worker_id}"))
}),
BridgeCommand::ListServiceWorkerRegistrations { target_id } => {
with_page(pool, &target_id, |page| {
let state = page.webview_state();
let st = state.borrow();
let registrations: Vec<Value> = st
.controlling_service_worker()
.map(|h| sw_registration_to_json(h.clone()))
.into_iter()
.collect();
Ok(serde_json::json!({ "registrations": registrations }))
})
}
BridgeCommand::GetServiceWorkerRegistrationInfo {
target_id,
registration_id,
} => {
with_page(pool, &target_id, |page| {
cmd_sw_registration_info(page, ®istration_id)
})
}
BridgeCommand::StopServiceWorker {
target_id,
registration_id,
} => with_page(pool, &target_id, |page| {
cmd_terminate_service_worker(page, ®istration_id)
}),
};
BridgeResponse { result }
}
fn parse_target_id(target_id: &str) -> Option<usize> {
target_id.parse::<usize>().ok()
}
fn with_page<F>(pool: &PagePool, target_id: &str, f: F) -> Result<Value, String>
where
F: FnOnce(&PageHandle) -> Result<Value, String>,
{
let id = parse_target_id(target_id).ok_or_else(|| format!("invalid target_id: {target_id}"))?;
let page = pool
.get_page(id)
.ok_or_else(|| format!("page not found: {target_id}"))?;
f(&page)
}
fn parse_sw_registration_id(registration_id: &str) -> Result<ServiceWorkerRegistrationId, String> {
let parts: Vec<&str> = registration_id.splitn(2, "::").collect();
if parts.len() != 2 {
return Err(format!(
"invalid registrationId format: {registration_id} (expected 'script_url::scope')"
));
}
Ok(ServiceWorkerRegistrationId {
script_url: parts[0].to_string(),
scope: parts[1].to_string(),
})
}
fn sw_registration_to_json(handle: ServiceWorkerHandle) -> Value {
let is_active = handle.registration_state() == ServiceWorkerRegistrationState::Activated;
serde_json::json!({
"registrationId": format!("{}::{}", handle.script_url, handle.scope),
"scriptURL": handle.script_url,
"scope": handle.scope,
"state": match handle.registration_state() {
ServiceWorkerRegistrationState::Idle => "idle",
ServiceWorkerRegistrationState::Installing => "installing",
ServiceWorkerRegistrationState::Installed => "installed",
ServiceWorkerRegistrationState::Activating => "activating",
ServiceWorkerRegistrationState::Activated => "activated",
ServiceWorkerRegistrationState::Redundant => "redundant",
},
"isFetchIntercepting": handle.is_intercepting_fetch(),
"isActive": is_active,
})
}
fn cmd_create_target(pool: &PagePool, url: &str) -> Result<Value, String> {
let config = PageConfig {
url: if url.is_empty() {
None
} else {
Some(url.to_string())
},
..Default::default()
};
let page = pool.create_page(&config).map_err(|e| format!("{e}"))?;
let page_id = page.id();
Ok(serde_json::json!({ "targetId": page_id.to_string() }))
}
fn cmd_list_targets(pool: &PagePool) -> Result<Value, String> {
let stats = pool.stats();
let mut targets: Vec<Value> = Vec::new();
for id in 1..=(stats.active + stats.idle) {
if let Some(page) = pool.get_page(id) {
targets.push(serde_json::json!({
"id": id.to_string(),
"title": page.page_title().unwrap_or_default(),
"url": page.current_url().unwrap_or_else(|| "about:blank".into()),
}));
}
}
Ok(serde_json::json!(targets))
}
fn worker_target_json(target_id: &str, target_type: &str) -> Value {
serde_json::json!({
"targetId": target_id,
"type": target_type,
"title": target_id,
"url": target_id,
"attached": false,
})
}
fn cmd_network_enable(page: &PageHandle) -> Result<Value, String> {
let event_tx = {
let state = page.webview_state();
let st = state.borrow();
st.event_tx.clone()
};
let Some(event_tx) = event_tx else {
return Err(
"Network.enable cannot observe requests: no runtime event channel \
(set_event_channel) is wired for this page"
.into(),
);
};
let tap: servo::BaoNetworkTap = std::sync::Arc::new(move |event| match event {
servo::BaoNetworkTapEvent::Request {
request_id,
url,
method,
headers,
resource_type,
webview_id,
} => {
let _ = event_tx.send(ServoEvent::NetworkRequest {
target_id: webview_id.unwrap_or_else(|| "0".to_string()),
request_id,
url,
method,
headers: headers.into_iter().collect(),
post_data: None,
resource_type,
frame_id: "0".to_string(),
});
},
servo::BaoNetworkTapEvent::Response {
request_id,
url,
status,
status_text,
headers,
mime_type,
webview_id,
} => {
let _ = event_tx.send(ServoEvent::NetworkResponse {
target_id: webview_id.unwrap_or_else(|| "0".to_string()),
request_id,
url,
status,
status_text,
headers: headers.into_iter().collect(),
mime_type,
remote_ip: None,
});
},
});
servo::set_network_event_tap(Some(tap));
Ok(serde_json::json!({}))
}
fn to_browser_error(e: BrowserError) -> String {
format!("{e}")
}
fn next_cdp_id(prefix: &str) -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{prefix}-{n:016x}")
}
fn cmd_navigate(page: &PageHandle, url: &str) -> Result<Value, String> {
page.navigate(url).map_err(to_browser_error)?;
Ok(serde_json::json!({
"frameId": page.id().to_string(),
"loaderId": next_cdp_id("loader"),
}))
}
fn cmd_evaluate(
page: &PageHandle,
expression: &str,
return_by_value: bool,
) -> Result<Value, String> {
let result = page.evaluate_js_web(expression).map_err(to_browser_error)?;
if return_by_value {
let parsed: Result<Value, _> = serde_json::from_str(&result);
let (value_type, value) = match parsed {
Ok(v) => (json_type(&v), v),
Err(_) => (json_type_string(&result), serde_json::json!(result)),
};
Ok(serde_json::json!({
"result": {
"type": value_type,
"value": value,
},
"exceptionDetails": null
}))
} else {
page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
.map_err(to_browser_error)?;
let expr_json = serde_json::to_string(expression).unwrap_or_default();
let js = format!(
r#"(function() {{
try {{
var r = eval({expr_json});
return JSON.stringify({{ result: window.__bao_cdp.wrap(r, false, ''), exceptionDetails: null }});
}} catch (e) {{
var exObj = (e !== null && typeof e === 'object') ? window.__bao_cdp.wrap(e, false, '') : undefined;
return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((e && e.message) || e), exception: exObj, exceptionId: 0 }} }});
}}
}})()"#,
);
let out = page.evaluate_js_web(&js).map_err(to_browser_error)?;
serde_json::from_str(&out).map_err(|e| {
format!("Runtime.evaluate: handle wrapper unparseable: {e} (got: {out:.200})")
})
}
}
fn cmd_screenshot(page: &PageHandle, format: &str) -> Result<Value, String> {
let fmt = match format {
"jpeg" => ScreenshotFormat::Jpeg,
"webp" => ScreenshotFormat::WebP,
_ => ScreenshotFormat::Png,
};
let bytes = page.take_screenshot(fmt).map_err(to_browser_error)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({ "data": b64 }))
}
fn cmd_get_title(page: &PageHandle) -> Result<Value, String> {
let title = page.page_title().unwrap_or_default();
Ok(serde_json::json!(title))
}
fn cmd_get_url(page: &PageHandle) -> Result<Value, String> {
let url = page.current_url().unwrap_or_else(|| "about:blank".into());
Ok(serde_json::json!(url))
}
fn cmd_get_document(page: &PageHandle) -> Result<Value, String> {
let js = r#"
(function() {
function walk(node, id) {
var result = {
nodeId: id,
backendNodeId: id,
nodeType: node.nodeType,
nodeName: node.nodeName,
localName: node.localName || '',
nodeValue: node.nodeValue || '',
};
if (node.childNodes && node.childNodes.length > 0) {
result.childNodeCount = node.childNodes.length;
result.children = [];
for (var i = 0; i < Math.min(node.childNodes.length, 20); i++) {
result.children.push(walk(node.childNodes[i], id * 100 + i + 1));
}
}
return result;
}
return JSON.stringify(walk(document, 1));
})()
"#;
let doc_str = page.evaluate_js(js).map_err(to_browser_error)?;
let doc_val: Value = serde_json::from_str(&doc_str).unwrap_or_else(|_| serde_json::json!({}));
Ok(serde_json::json!({ "root": doc_val }))
}
fn cmd_query_selector(page: &PageHandle, selector: &str) -> Result<Value, String> {
let js = format!(
"(function() {{ var e = document.querySelector({}); return e ? 1 : 0; }})()",
serde_json::to_string(selector).unwrap_or_default()
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
let node_id: i64 = result.trim().parse().unwrap_or(0);
Ok(serde_json::json!({ "nodeId": node_id }))
}
fn cmd_query_selector_all(page: &PageHandle, selector: &str) -> Result<Value, String> {
let js = format!(
"(function() {{ return document.querySelectorAll({}).length; }})()",
serde_json::to_string(selector).unwrap_or_default()
);
let count_str = page.evaluate_js(&js).map_err(to_browser_error)?;
let count: i64 = count_str.trim().parse().unwrap_or(0);
let ids: Vec<i64> = (1..=count).collect();
Ok(serde_json::json!({ "nodeIds": ids }))
}
fn cmd_get_outer_html(page: &PageHandle) -> Result<Value, String> {
let js = "document.documentElement.outerHTML";
let html = page.evaluate_js(js).map_err(to_browser_error)?;
Ok(serde_json::json!({ "outerHTML": html }))
}
fn cmd_set_attribute(page: &PageHandle, name: &str, value: &str) -> Result<Value, String> {
let js = format!(
"(function() {{ document.querySelector('[data-cdp]')?.setAttribute({}, {}); }})()",
serde_json::to_string(name).unwrap_or_default(),
serde_json::to_string(value).unwrap_or_default(),
);
let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_mouse_event(
_page: &PageHandle,
_event_type: &str,
_x: f64,
_y: f64,
_button: Option<i64>,
_click_count: Option<i64>,
) -> Result<Value, String> {
Ok(serde_json::json!({}))
}
fn cmd_key_event(
_page: &PageHandle,
_event_type: &str,
_key: &str,
_code: &str,
_text: Option<&str>,
) -> Result<Value, String> {
Ok(serde_json::json!({}))
}
fn cmd_insert_text(page: &PageHandle, text: &str) -> Result<Value, String> {
let js = format!(
"(function() {{ var el = document.activeElement; if (el && 'value' in el) el.value += {}; }})()",
serde_json::to_string(text).unwrap_or_default(),
);
let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_set_viewport(_page: &PageHandle, _width: u32, _height: u32) -> Result<Value, String> {
Ok(serde_json::json!({}))
}
fn cmd_set_user_agent(page: &PageHandle, ua: &str) -> Result<Value, String> {
let js = format!(
"Object.defineProperty(navigator, 'userAgent', {{ get: function() {{ return {}; }} }});",
serde_json::to_string(ua).unwrap_or_default(),
);
let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_add_script(page: &PageHandle, source: &str) -> Result<Value, String> {
page.add_script_to_evaluate_on_new_document(source)
.map_err(to_browser_error)?;
let _ = page.evaluate_js_web(source).map_err(to_browser_error)?;
Ok(serde_json::json!({ "identifier": next_cdp_id("script") }))
}
fn cmd_reload(page: &PageHandle) -> Result<Value, String> {
page.reload().map_err(to_browser_error)?;
Ok(serde_json::json!({
"frameId": page.id().to_string(),
"loaderId": next_cdp_id("loader"),
}))
}
fn cmd_go_back(page: &PageHandle) -> Result<Value, String> {
if !page.can_go_back() {
return Err("cannot go back: no previous entry in session history".into());
}
page.go_back().map_err(to_browser_error)?;
Ok(serde_json::json!({ "frameId": page.id().to_string() }))
}
fn cmd_go_forward(page: &PageHandle) -> Result<Value, String> {
if !page.can_go_forward() {
return Err("cannot go forward: no forward entry in session history".into());
}
page.go_forward().map_err(to_browser_error)?;
Ok(serde_json::json!({ "frameId": page.id().to_string() }))
}
fn cmd_collect_garbage(page: &PageHandle) -> Result<Value, String> {
page.evaluate_js_web("navigator.servo.GarbageCollectAllContexts()")
.map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_performance_get_metrics(page: &PageHandle) -> Result<Value, String> {
let js = r#"(function() {
return JSON.stringify({
Timestamp: Date.now(),
Documents: 1 + window.frames.length,
Frames: 1 + window.frames.length,
Nodes: document.getElementsByTagName('*').length
});
})()"#;
let result = page.evaluate_js(js).map_err(to_browser_error)?;
let v: Value = serde_json::from_str(result.trim())
.map_err(|e| format!("Performance.getMetrics: page did not return JSON: {e}"))?;
let metrics: Vec<Value> = v
.as_object()
.map(|o| {
o.iter()
.map(|(name, val)| serde_json::json!({ "name": name, "value": val }))
.collect()
})
.unwrap_or_default();
Ok(serde_json::json!({ "metrics": metrics }))
}
fn cmd_terminate_service_worker(
page: &PageHandle,
registration_id: &str,
) -> Result<Value, String> {
let id = parse_sw_registration_id(registration_id)?;
let state = page.webview_state();
let mut st = state.borrow_mut();
let is_match = st
.controlling_service_worker()
.map(|h| h.script_url == id.script_url && h.scope == id.scope)
.unwrap_or(false);
if !is_match {
return Err(format!(
"no controlling ServiceWorker registration '{registration_id}'"
));
}
if let Some(handle) = st.controlling_service_worker() {
handle.terminate();
}
st.clear_controlling_service_worker();
Ok(serde_json::json!({}))
}
fn cmd_sw_registration_info(
page: &PageHandle,
registration_id: &str,
) -> Result<Value, String> {
let id = parse_sw_registration_id(registration_id)?;
let state = page.webview_state();
let st = state.borrow();
match st.controlling_service_worker() {
Some(handle) if handle.script_url == id.script_url && handle.scope == id.scope => {
Ok(serde_json::json!({
"registration": sw_registration_to_json(handle.clone())
}))
}
_ => Err(format!(
"no controlling ServiceWorker registration '{registration_id}'"
)),
}
}
const DEBUGGER_SETUP: &str = r#"
(function() {
var g = globalThis;
if (g.__bao_dbg_active) return 'already-active';
g.__bao_dbg_active = true;
// Fail-closed setup with staged diagnostics: any failure aborts the
// install and returns a 'debugger-setup:' string (the node-realm
// evaluate face drops JS exception messages — returning the reason rides
// the success path), which cmd_debugger_enable turns into an explicit
// error — never a half-installed debugger.
try {
if (typeof Debugger !== 'function')
throw new Error('Debugger constructor missing on the Node Realm global (native install callback did not run)');
var dbg;
try {
dbg = new Debugger();
} catch (e) {
throw new Error('new Debugger() threw: ' + e);
}
g.__bao_dbg = dbg;
// Breakpoint registry — created EAGERLY: the command glue reads it as a
// bare global, and a bare read of a never-created property throws
// ReferenceError on the first setBreakpointByUrl.
g.__bao_dbg_bps = {};
// Attach the page global (this-realm `window` lazy getter wraps the Page
// Realm global cross-compartment — the SM-mandated different compartment).
try {
dbg.addDebuggee(window);
} catch (e) {
throw new Error('addDebuggee(window) threw: ' + e);
}
// Stable script identity: SM Debugger.Script has NO id accessor. The
// Debugger instance caches wrappers per JSScript (ScriptWeakMap), so the
// wrapper object is a stable identity key within this Debugger's life.
var nextScriptId = 1;
var scriptIds = new WeakMap();
g.__bao_dbg_scripts = {};
g.__bao_dbg_sid = function(s) {
var id = scriptIds.get(s);
if (id === undefined) {
id = String(nextScriptId++);
scriptIds.set(s, id);
g.__bao_dbg_scripts[id] = s;
}
return id;
};
// SM offset -> CDP Location (0-origin), via native getOffsetLocation.
g.__bao_dbg_cdp_loc = function(s, offset) {
try {
var loc = s.getOffsetLocation(offset);
return {
scriptId: g.__bao_dbg_sid(s),
lineNumber: Math.max(0, (loc.lineNumber || 1) - 1),
columnNumber: Math.max(0, (loc.columnNumber || 1) - 1),
};
} catch (e) {
return { scriptId: g.__bao_dbg_sid(s), lineNumber: 0, columnNumber: 0 };
}
};
// Debuggee value -> CDP RemoteObject, WITHOUT running debuggee code:
// primitives cross as plain values; debuggee objects arrive as
// Debugger.Object wrappers and are described (className), never read.
g.__bao_dbg_val = function(v) {
try {
if (v === null) return { type: 'object', subtype: 'null' };
var t = typeof v;
if (t === 'object') return { type: 'object', className: (v && typeof v.class === 'string') ? v.class : 'Object' };
if (t === 'undefined') return { type: 'undefined' };
if (t === 'string') return { type: 'string', value: String(v) };
if (t === 'number') return { type: 'number', value: v };
if (t === 'boolean') return { type: 'boolean', value: v };
return { type: t };
} catch (e) {
return { type: 'undefined' };
}
};
// Debugger.Environment -> CDP Scope. Declarative envs classify by native
// scopeKind ('function' -> local); object envs: outermost -> global,
// intermediate -> closure; 'with' -> with. The scope's object cannot be
// materialized as an inspectable RemoteObject (its properties would
// require running debuggee code) — className describes it honestly.
g.__bao_dbg_scope = function(env) {
var type = env.type;
var cdpType;
if (type === 'with') cdpType = 'with';
else if (type === 'object') cdpType = env.parent === null ? 'global' : 'closure';
else if (env.scopeKind === 'function') cdpType = 'local';
else cdpType = 'closure';
var cls = 'Object';
try { if (env.object) cls = env.object.class || 'Object'; } catch (e) {}
return { type: cdpType, object: { type: 'object', className: cls } };
};
g.__bao_dbg_frame = function(f, idx) {
var info = {
callFrameId: 'frame-' + idx,
functionName: '(anonymous)',
location: { scriptId: '', lineNumber: 0, columnNumber: 0 },
scopeChain: [],
this: { type: 'undefined' },
};
try {
if (f.callee && typeof f.callee.name === 'string' && f.callee.name)
info.functionName = f.callee.name;
} catch (e) {}
try {
var s = f.script;
if (s && f.offset !== undefined) info.location = g.__bao_dbg_cdp_loc(s, f.offset);
} catch (e) {}
try {
var env = f.environment;
var chain = [];
while (env && chain.length < 50) {
chain.push(g.__bao_dbg_scope(env));
env = env.parent;
}
info.scopeChain = chain;
} catch (e) {}
try { info.this = g.__bao_dbg_val(f.this); } catch (e) {}
return info;
};
// Walk the live frame chain at pause time and emit the CDP paused event
// through the PAGE console (servo delegate transport — see header).
g.__bao_dbg_emit_paused = function(frame, reason, hitBps) {
var callFrames = [];
var f = frame;
var idx = 0;
while (f && idx < 100) {
callFrames.push(g.__bao_dbg_frame(f, idx));
try { f = f.older; } catch (e) { break; }
idx++;
}
window.console.log('__BAO_EVT__Debugger.paused\n' + JSON.stringify({
callFrames: callFrames,
reason: reason,
hitBreakpoints: hitBps || [],
}));
};
function emitParsed(script) {
var info;
try {
// SM startLine/lineCount are 1-origin; CDP scriptParsed is 0-origin.
var start = script.startLine || 1;
info = JSON.stringify({
id: g.__bao_dbg_sid(script),
url: script.url || '',
startLine: Math.max(0, start - 1),
endLine: Math.max(0, start + (script.lineCount || 1) - 2),
});
} catch (e) { return; }
window.console.log('__BAO_EVT__Debugger.scriptParsed\n' + info);
}
dbg.onNewScript = function(script) { emitParsed(script); };
dbg.onDebuggerStatement = function(frame) {
g.__bao_dbg_emit_paused(frame, 'debuggerStatement', []);
return undefined;
};
// Surface scripts that predate this enable (findScripts sees them now
// that the page global is a debuggee) — the enable-time scriptParsed
// flush clients rely on.
dbg.findScripts().forEach(emitParsed);
return 'ok';
} catch (e) {
g.__bao_dbg_active = false;
g.__bao_dbg = undefined;
return 'debugger-setup: ' + (e && e.message ? e.message : String(e));
}
})();
"#;
fn cmd_debugger_enable(page: &PageHandle) -> Result<Value, String> {
if let Some(webview_id) = page.webview_id() {
crate::runtime_bridge::register_node_realm_debugger_install(webview_id);
}
let status = page
.evaluate_js(DEBUGGER_SETUP)
.map_err(to_browser_error)?;
if status != "ok" && status != "already-active" {
return Err(format!("Debugger.enable failed: {status}"));
}
Ok(serde_json::json!({}))
}
fn cmd_debugger_disable(page: &PageHandle) -> Result<Value, String> {
let js = "(function() { try { if (__bao_dbg) { var scripts = __bao_dbg.findScripts(); for (var i = 0; i < scripts.length; i++) { try { scripts[i].clearAllBreakpoints(); } catch (e) {} } __bao_dbg.onNewScript = undefined; __bao_dbg.onDebuggerStatement = undefined; __bao_dbg.onEnterFrame = undefined; __bao_dbg = null; __bao_dbg_active = false; } } catch (e) { throw e; } })()";
let _ = page.evaluate_js(js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_set_breakpoint(
page: &PageHandle,
url: Option<&str>,
url_regex: Option<&str>,
line: u32,
column: Option<u32>,
) -> Result<Value, String> {
let sm_line = line + 1;
let url_filter = match (url, url_regex) {
(Some(u), _) => format!("s.url === {}", serde_json::to_string(u).unwrap_or_default()),
(None, Some(r)) => format!(
"new RegExp({}).test(s.url)",
serde_json::to_string(r).unwrap_or_default()
),
(None, None) => format!(
"s.startLine <= {sm_line} && {sm_line} <= s.startLine + s.lineCount - 1"
),
};
let col = column.unwrap_or(0);
let has_column = column.is_some();
let js = format!(
r#"(function() {{
try {{
if (!__bao_dbg) throw new Error('Debugger.enable required before setting breakpoints');
var scripts = __bao_dbg.findScripts();
var diag = 'scripts=' + scripts.length;
for (var i = 0; i < scripts.length; i++) {{
var s = scripts[i];
diag += ' [url=' + (s.url || '') + ' startLine=' + s.startLine + ' lineCount=' + s.lineCount + ' lo' + {sm_line} + '=';
var offsets = [];
try {{ offsets = s.getLineOffsets({sm_line}) || []; }} catch (e) {{ diag += 'threw:' + e.message + ']'; continue; }}
diag += offsets.length + ']';
if (!({url_filter})) continue;
if (offsets.length === 0) continue;
var chosen = offsets[0];
if ({has_column}) {{
var want = {col} + 1;
var matched = false;
for (var j = 0; j < offsets.length; j++) {{
try {{
if (s.getOffsetLocation(offsets[j]).columnNumber === want) {{
chosen = offsets[j];
matched = true;
break;
}}
}} catch (e) {{}}
}}
if (!matched) throw new Error('no breakpoint offset at column {col} on line {line} of script ' + __bao_dbg_sid(s));
}}
var bpId = 'bp-' + __bao_dbg_sid(s) + '-' + {line} + '-' + {col};
var handler = {{
hit: (function(id) {{
return function(frame) {{
__bao_dbg_emit_paused(frame, 'breakpoint', [id]);
return undefined;
}};
}})(bpId)
}};
s.setBreakpoint(chosen, handler);
if (!__bao_dbg_bps) __bao_dbg_bps = {{}};
__bao_dbg_bps[bpId] = {{ scriptId: __bao_dbg_sid(s), offset: chosen, handler: handler }};
var loc = __bao_dbg_cdp_loc(s, chosen);
return JSON.stringify({{ breakpointId: bpId, locations: [loc] }});
}}
throw new Error('no script matched the breakpoint location (url/urlRegex/line {line}); diag: ' + diag);
}} catch (e) {{ return 'ERR: ' + (e && e.message ? e.message : String(e)); }}
}})()"#,
url_filter = url_filter,
sm_line = sm_line,
has_column = has_column,
col = col,
line = line,
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
if let Some(reason) = result.strip_prefix("ERR: ") {
return Err(format!("Debugger.setBreakpointByUrl failed: {reason}"));
}
parse_js_result(&result)
}
fn cmd_debugger_remove_breakpoint(page: &PageHandle, breakpoint_id: &str) -> Result<Value, String> {
let js = format!(
r#"(function() {{
if (!__bao_dbg) throw new Error('Debugger.enable required before removing breakpoints');
var info = __bao_dbg_bps && __bao_dbg_bps[{bp_id}];
if (info) {{
var s = __bao_dbg_scripts[info.scriptId];
if (s && info.handler) {{
try {{ s.clearBreakpoint(info.handler); }} catch (e) {{}}
}}
delete __bao_dbg_bps[{bp_id}];
}}
return JSON.stringify({{}});
}})()"#,
bp_id = serde_json::to_string(breakpoint_id).unwrap_or_default(),
);
let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_interrupt(page: &PageHandle) -> Result<Value, String> {
let js = "(function() { if (!__bao_dbg) throw new Error('Debugger.enable required before pause'); __bao_dbg.onEnterFrame = function(frame) { __bao_dbg.onEnterFrame = undefined; frame.onStep = function() { frame.onStep = undefined; __bao_dbg_emit_paused(frame, 'interrupt', []); return undefined; }; return undefined; }; })()";
let _ = page.evaluate_js(js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_resume(page: &PageHandle, step_type: Option<&str>) -> Result<Value, String> {
let js = match step_type {
Some("next") => "(function() { if (!__bao_dbg) throw new Error('Debugger.enable required'); __bao_dbg.onEnterFrame = function(frame) { __bao_dbg.onEnterFrame = undefined; frame.onPop = function() { frame.onPop = undefined; __bao_dbg_emit_paused(frame, 'step', []); }; return undefined; }; })()",
Some("step") => "(function() { if (!__bao_dbg) throw new Error('Debugger.enable required'); __bao_dbg.onEnterFrame = function(frame) { __bao_dbg.onEnterFrame = undefined; frame.onStep = function() { frame.onStep = undefined; __bao_dbg_emit_paused(frame, 'step', []); return undefined; }; return undefined; }; })()",
Some("finish") => "(function() { if (!__bao_dbg) throw new Error('Debugger.enable required'); __bao_dbg.onEnterFrame = function(frame) { __bao_dbg.onEnterFrame = undefined; frame.onPop = function() { frame.onPop = undefined; __bao_dbg_emit_paused(frame, 'step', []); }; return undefined; }; })()",
_ => "(function() { /* resume: clear step hooks */ if (__bao_dbg) { __bao_dbg.onEnterFrame = undefined; } })()",
};
let _ = page.evaluate_js(js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_list_frames(page: &PageHandle) -> Result<Value, String> {
let js = "(function() { if (!__bao_dbg) throw new Error('Debugger.enable required before listing frames'); var f = __bao_dbg.getNewestFrame(); var frames = []; var idx = 0; while (f && idx < 100) { frames.push(__bao_dbg_frame(f, idx)); f = f.older; idx++; } return JSON.stringify({frames: frames}); })()";
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_debugger_get_environment(page: &PageHandle) -> Result<Value, String> {
let js = r#"(function() {
if (!__bao_dbg) throw new Error('Debugger.enable required before reading the environment');
var f = __bao_dbg.getNewestFrame();
if (!f || !f.environment) return JSON.stringify({environment: []});
var envs = [];
var env = f.environment;
while (env && envs.length < 50) {
var e = {type: env.type, scopeKind: null, names: [], namesTruncated: false, variables: {}};
try { e.scopeKind = env.scopeKind; } catch (err) {}
try {
var names = env.names() || [];
if (names.length > 50) { names = names.slice(0, 50); e.namesTruncated = true; }
e.names = names;
} catch (err) {}
for (var i = 0; i < e.names.length; i++) {
try {
var v = env.getVariable(e.names[i]);
if (v !== null && (typeof v === 'object') &&
(v.optimizedOut || v.uninitialized || v.missingArgument)) {
e.variables[e.names[i]] = {unavailable: true};
} else {
e.variables[e.names[i]] = __bao_dbg_val(v);
}
} catch (err) {
// DebuggeeWouldRun (accessor binding in a with-env) — refusal, not silence.
e.variables[e.names[i]] = {unavailable: true};
}
}
envs.push(e);
env = env.parent;
}
return JSON.stringify({environment: envs});
})()"#;
let result = page.evaluate_js(js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_debugger_get_possible_breakpoints(
page: &PageHandle,
start_script_id: &str,
) -> Result<Value, String> {
let filter_by_script = if start_script_id.is_empty() {
"true".to_string()
} else {
format!(
"__bao_dbg_sid(s) === {}",
serde_json::to_string(start_script_id).unwrap_or_default()
)
};
let js = format!(
r#"(function() {{
if (!__bao_dbg) throw new Error('Debugger.enable required before querying possible breakpoints');
var scripts = __bao_dbg.findScripts();
var locs = [];
for (var i = 0; i < scripts.length; i++) {{
var s = scripts[i];
if (!({filter})) continue;
var candidates = [];
try {{ candidates = s.getPossibleBreakpoints() || []; }} catch (e) {{ continue; }}
for (var j = 0; j < candidates.length; j++) {{
locs.push({{
scriptId: __bao_dbg_sid(s),
lineNumber: Math.max(0, (candidates[j].lineNumber || 1) - 1),
columnNumber: Math.max(0, (candidates[j].columnNumber || 1) - 1),
}});
}}
}}
return JSON.stringify({{locations: locs}});
}})()"#,
filter = filter_by_script
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_debugger_get_script_source(page: &PageHandle, script_id: u32) -> Result<Value, String> {
let js = format!(
r#"(function() {{
if (!__bao_dbg) throw new Error('Debugger.enable required before reading script source');
var s = __bao_dbg_scripts[String({script_id})];
if (!s) throw new Error('unknown script id {script_id}');
var text = '';
try {{ text = s.source.text || ''; }} catch (e) {{}}
return JSON.stringify({{scriptSource: text}});
}})()"#,
script_id = script_id
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_debugger_blackbox(_page: &PageHandle) -> Result<Value, String> {
Err("Debugger.blackbox not supported: SpiderMonkey's Debugger API has no native blackbox; refusing to fake success".into())
}
fn cmd_debugger_unblackbox(_page: &PageHandle) -> Result<Value, String> {
Err("Debugger.unblackbox not supported: SpiderMonkey's Debugger API has no native blackbox; refusing to fake success".into())
}
fn resolve_node_by_id(page: &PageHandle, node_id: i64) -> Result<String, String> {
if node_id <= 0 {
let js: String = match node_id {
0 | 1 => "document".to_string(),
2 => "document.documentElement".to_string(),
_ => {
let idx = node_id - 3;
format!("document.documentElement.childNodes[{}]", idx)
}
};
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
Ok(result)
} else {
let js = format!(
"(function() {{ var el = document.querySelector('[data-node-id=\"{}\"]'); if (el) return 'found'; return 'not-found'; }})()",
node_id
);
let found = page.evaluate_js(&js).map_err(to_browser_error)?;
if found.trim() == "found" {
Ok(format!(
"document.querySelector('[data-node-id=\"{}\"]')",
node_id
))
} else {
Ok(format!("document.body.childNodes[{}]", node_id - 3))
}
}
}
fn cmd_css_get_computed_style(page: &PageHandle, node_id: i64) -> Result<Value, String> {
let node_ref = resolve_node_by_id(page, node_id)?;
let js = format!(
r#"(function() {{
var el = {node_ref};
if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"computedStyle": []}});
try {{
var styles = getComputedStyle(el);
var result = [];
for (var i = 0; i < styles.length; i++) {{
var name = styles[i];
result.push({{ name: name, value: styles.getPropertyValue(name) }});
}}
return JSON.stringify({{"computedStyle": result}});
}} catch(e) {{
return JSON.stringify({{"computedStyle": []}});
}}
}})()"#,
node_ref = node_ref
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_css_get_matched_styles(page: &PageHandle, node_id: i64) -> Result<Value, String> {
let node_ref = resolve_node_by_id(page, node_id)?;
let js = format!(
r#"(function() {{
var el = {node_ref};
if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"matchedCSSRules": [], "inlineStyle": null, "attributesStyle": null}});
try {{
var rules = [];
var sheets = document.styleSheets;
for (var s = 0; s < sheets.length; s++) {{
try {{
var cssRules = sheets[s].cssRules || sheets[s].rules;
for (var r = 0; r < cssRules.length; r++) {{
try {{
if (cssRules[r].selectorText && el.matches(cssRules[r].selectorText)) {{
var rule = {{
rule: {{
selectorList: {{ selectors: [{{ text: cssRules[r].selectorText }}] }},
style: {{ cssProperties: [], shorthandEntries: [] }},
origin: sheets[s].href ? "regular" : "user-agent",
sourceURL: sheets[s].href || ""
}},
matchingSelectors: [r]
}};
var decls = cssRules[r].style;
for (var d = 0; d < decls.length; d++) {{
rule.rule.style.cssProperties.push({{
name: decls[d],
value: decls.getPropertyValue(decls[d]),
important: decls.getPropertyPriority(decls[d]) === "important"
}});
}}
rules.push(rule);
}}
}} catch(e2) {{}}
}}
}} catch(e1) {{}}
}}
var inlineStyle = null;
if (el.style && el.style.length > 0) {{
inlineStyle = {{ cssProperties: [], shorthandEntries: [] }};
for (var i = 0; i < el.style.length; i++) {{
inlineStyle.cssProperties.push({{
name: el.style[i],
value: el.style.getPropertyValue(el.style[i]),
important: el.style.getPropertyPriority(el.style[i]) === "important"
}});
}}
}}
return JSON.stringify({{"matchedCSSRules": rules, "inlineStyle": inlineStyle, "attributesStyle": null}});
}} catch(e) {{
return JSON.stringify({{"matchedCSSRules": [], "inlineStyle": null, "attributesStyle": null}});
}}
}})()"#,
node_ref = node_ref
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_css_get_inline_styles(page: &PageHandle, node_id: i64) -> Result<Value, String> {
let node_ref = resolve_node_by_id(page, node_id)?;
let js = format!(
r#"(function() {{
var el = {node_ref};
if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"inlineStyle": null}});
try {{
var inlineStyle = null;
if (el.style && el.style.length > 0) {{
inlineStyle = {{ cssProperties: [], shorthandEntries: [] }};
for (var i = 0; i < el.style.length; i++) {{
inlineStyle.cssProperties.push({{
name: el.style[i],
value: el.style.getPropertyValue(el.style[i]),
important: el.style.getPropertyPriority(el.style[i]) === "important"
}});
}}
}}
var attributesStyle = null;
if (el.getAttribute('style')) {{
attributesStyle = {{ cssProperties: [], shorthandEntries: [] }};
var styleText = el.getAttribute('style');
var pairs = styleText.split(';');
for (var p = 0; p < pairs.length; p++) {{
var kv = pairs[p].trim();
if (kv) {{
var colon = kv.indexOf(':');
if (colon > 0) {{
var name = kv.substring(0, colon).trim();
var value = kv.substring(colon + 1).trim();
var important = value.endsWith(' !important');
if (important) value = value.substring(0, value.length - 11).trim();
attributesStyle.cssProperties.push({{
name: name, value: value, important: important
}});
}}
}}
}}
}}
return JSON.stringify({{"inlineStyle": inlineStyle, "attributesStyle": attributesStyle}});
}} catch(e) {{
return JSON.stringify({{"inlineStyle": null}});
}}
}})()"#,
node_ref = node_ref
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
const CDP_REGISTRY_PRELUDE: &str = r#"(function() {
if (window.__bao_cdp) return 'ok';
var objs = Object.create(null);
var seq = 0;
function alloc(v, group) {
seq++;
var oid = 'obj-' + seq + '-' + Math.floor(Math.random() * 1e9).toString(36);
objs[oid] = { v: v, g: group || '' };
return oid;
}
function get(oid) { return (oid in objs) ? objs[oid].v : undefined; }
function release(oid) { delete objs[oid]; }
function releaseGroup(g) { for (var k in objs) { if (objs[k].g === g) delete objs[k]; } }
function wrap(v, byValue, group) {
var ro;
if (v === null) {
ro = { type: 'object', subtype: 'null', description: 'null' };
} else if (v === undefined) {
ro = { type: 'undefined' };
} else {
var t = typeof v;
if (t === 'number') {
var us = null;
if (v !== v) us = 'NaN';
else if (v === Infinity) us = 'Infinity';
else if (v === -Infinity) us = '-Infinity';
else if (v === 0 && 1 / v < 0) us = '-0';
ro = us
? { type: 'number', unserializableValue: us, description: us }
: { type: 'number', value: v, description: String(v) };
} else if (t === 'string') {
ro = { type: 'string', value: v };
} else if (t === 'boolean') {
ro = { type: 'boolean', value: v };
} else if (t === 'bigint') {
ro = { type: 'bigint', unserializableValue: String(v), description: String(v) + 'n' };
} else if (t === 'symbol') {
ro = { type: 'symbol', description: String(v) };
} else if (t === 'function') {
ro = { type: 'function', className: 'Function', description: (v.name ? 'function ' + v.name + '()' : 'function ()'), objectId: alloc(v, group) };
} else {
var sub;
if (Array.isArray(v)) sub = 'array';
else if (v instanceof Date) sub = 'date';
else if (v instanceof RegExp) sub = 'regexp';
else if (v instanceof Error) sub = 'error';
else if (v === window) sub = 'window';
else if (typeof Node !== 'undefined' && v instanceof Node) sub = 'node';
var desc;
if (sub === 'array') desc = 'Array(' + v.length + ')';
else if (sub === 'date') desc = String(v);
else if (sub === 'regexp') desc = String(v);
else if (sub === 'error') desc = ((v.constructor && v.constructor.name) ? (v.constructor.name + ': ') : '') + (v.message || '');
else if (sub === 'node') { try { desc = v.nodeName.toLowerCase() + (v.id ? '#' + v.id : ''); } catch (e2) { desc = String(v.nodeName); } }
else { try { desc = String(v); } catch (e2) { desc = 'Object'; } }
var cn = 'Object';
try { if (v.constructor && v.constructor.name) cn = v.constructor.name; } catch (e2) {}
ro = { type: 'object', className: cn, description: desc, objectId: alloc(v, group) };
if (sub) ro.subtype = sub;
}
}
if (byValue && v !== null && v !== undefined && (typeof v === 'object' || typeof v === 'function')) {
try { ro.value = v; } catch (e) {}
}
return ro;
}
window.__bao_cdp = { alloc: alloc, get: get, release: release, releaseGroup: releaseGroup, wrap: wrap };
return 'ok';
})()"#;
fn resolve_object_by_id(object_id: &str) -> String {
if object_id.starts_with("node-") {
let idx: i64 = object_id[5..].parse().unwrap_or(0);
match idx {
0 | 1 => "document".to_string(),
2 => "document.documentElement".to_string(),
_ => format!("document.body.childNodes[{}]", idx - 3),
}
} else {
format!(
"window.__bao_cdp.get({})",
serde_json::to_string(object_id).unwrap_or_default()
)
}
}
fn cmd_runtime_get_properties(
page: &PageHandle,
object_id: &str,
own_properties: Option<bool>,
) -> Result<Value, String> {
page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
.map_err(to_browser_error)?;
let obj_ref = resolve_object_by_id(object_id);
let own = own_properties.unwrap_or(true);
let js = format!(
r#"(function() {{
try {{
var obj = {obj_ref};
if (obj === null || obj === undefined) return JSON.stringify({{ "result": [] }});
var names;
if ({own}) {{
names = Object.getOwnPropertyNames(obj);
}} else {{
names = [];
for (var n in obj) names.push(n);
}}
var result = [];
for (var i = 0; i < names.length; i++) {{
var name = names[i];
try {{
var desc = Object.getOwnPropertyDescriptor(obj, name);
if (!desc) continue;
var entry = {{ name: name, configurable: !!desc.configurable, enumerable: !!desc.enumerable, isOwn: {own} }};
if ('value' in desc) {{
entry.value = window.__bao_cdp.wrap(desc.value, false, '');
entry.writable = !!desc.writable;
}} else {{
if (desc.get) entry.get = window.__bao_cdp.wrap(desc.get, false, '');
if (desc.set) entry.set = window.__bao_cdp.wrap(desc.set, false, '');
}}
result.push(entry);
}} catch (e2) {{
result.push({{ name: name, value: {{ type: 'undefined' }}, configurable: false, enumerable: false }});
}}
}}
return JSON.stringify({{ "result": result }});
}} catch (e) {{
return JSON.stringify({{ "result": [] }});
}}
}})()"#,
obj_ref = obj_ref,
own = own,
);
let result = page.evaluate_js_web(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn call_argument_expr(arg: &Value) -> Result<String, String> {
let Some(obj) = arg.as_object() else {
return Err("callFunctionOn arguments entries must be CallArgument objects".into());
};
if let Some(v) = obj.get("value") {
return Ok(serde_json::to_string(v).unwrap_or_else(|_| "undefined".into()));
}
if let Some(us) = obj.get("unserializableValue").and_then(|v| v.as_str()) {
return match us {
"NaN" | "Infinity" | "-Infinity" | "-0" => Ok(us.to_string()),
_ if us.ends_with('n') && us.len() >= 2 => {
let digits = us[..us.len() - 1]
.strip_prefix('-')
.unwrap_or(&us[..us.len() - 1]);
if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
Ok(us.to_string())
} else {
Err(format!("unsupported unserializableValue: {us}"))
}
}
_ => Err(format!("unsupported unserializableValue: {us}")),
};
}
if let Some(oid) = obj.get("objectId").and_then(|v| v.as_str()) {
return Ok(resolve_object_by_id(oid));
}
Ok("undefined".to_string())
}
fn cmd_runtime_call_function_on(
page: &PageHandle,
object_id: Option<&str>,
execution_context_id: Option<i64>,
function_declaration: &str,
arguments: Option<&Value>,
return_by_value: Option<bool>,
await_promise: Option<bool>,
object_group: Option<&str>,
) -> Result<Value, String> {
page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
.map_err(to_browser_error)?;
let _ctx = execution_context_id; let this_expr = match object_id {
Some(oid) => resolve_object_by_id(oid),
None => "undefined".to_string(),
};
let mut args_js = String::from("[");
if let Some(Value::Array(arr)) = arguments {
let parts: Vec<String> = arr
.iter()
.map(call_argument_expr)
.collect::<Result<_, _>>()?;
args_js.push_str(&parts.join(", "));
} else if let Some(other) = arguments {
return Err(format!(
"callFunctionOn arguments must be an array, got: {other:.200}"
));
}
args_js.push(']');
let rbv = return_by_value.unwrap_or(false);
let await_js = await_promise.unwrap_or(false);
let group_json =
serde_json::to_string(object_group.unwrap_or("")).unwrap_or_else(|_| "\"..\"".into());
let func_json = serde_json::to_string(function_declaration).unwrap_or_default();
let js = format!(
r#"(function() {{
try {{
var fn = Function('return (' + {func_json} + ')')();
if (typeof fn !== 'function') {{
return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: 'functionDeclaration did not evaluate to a function', exceptionId: 0 }} }});
}}
var r = fn.apply({this_expr}, {args_js});
if ({await_js} && r !== null && typeof r === 'object' && typeof r.then === 'function') {{
window.__bao_async = {{ state: 'pending' }};
Promise.resolve(r).then(
function(v) {{ window.__bao_async = {{ state: 'ok', v: v }}; }},
function(e) {{ window.__bao_async = {{ state: 'err', e: e }}; }}
);
return JSON.stringify({{ __baoAsync: true }});
}}
return JSON.stringify({{ result: window.__bao_cdp.wrap(r, {rbv}, {group_json}), exceptionDetails: null }});
}} catch (e) {{
var exObj = (e !== null && typeof e === 'object') ? window.__bao_cdp.wrap(e, false, {group_json}) : undefined;
return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((e && e.message) || e), exception: exObj, exceptionId: 0 }} }});
}}
}})()"#,
this_expr = this_expr,
args_js = args_js,
func_json = func_json,
rbv = rbv,
await_js = await_js,
group_json = group_json,
);
let result = page.evaluate_js_web(&js).map_err(to_browser_error)?;
let parsed: Value = serde_json::from_str(&result).map_err(|e| {
format!("Runtime.callFunctionOn: page did not return the wrapper JSON: {e} (got: {result:.200})")
})?;
if parsed.get("__baoAsync").and_then(|v| v.as_bool()) == Some(true) {
return wait_bao_async_promise(page, rbv, &group_json);
}
Ok(parsed)
}
fn wait_bao_async_promise(
page: &PageHandle,
return_by_value: bool,
group_json: &str,
) -> Result<Value, String> {
let poll = format!(
r#"(function() {{
var a = window.__bao_async;
if (!a || a.state === 'pending') return JSON.stringify({{ pending: true }});
if (a.state === 'ok') return JSON.stringify({{ result: window.__bao_cdp.wrap(a.v, {rbv}, {group_json}), exceptionDetails: null }});
var exObj = (a.e !== null && typeof a.e === 'object') ? window.__bao_cdp.wrap(a.e, false, {group_json}) : undefined;
return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((a.e && a.e.message) || a.e), exception: exObj, exceptionId: 0 }} }});
}})()"#,
rbv = return_by_value,
group_json = group_json,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
let out = page.evaluate_js_web(&poll).map_err(to_browser_error)?;
let parsed: Value = serde_json::from_str(&out).map_err(|e| {
format!("Runtime.callFunctionOn: async poll wrapper unparseable: {e} (got: {out:.200})")
})?;
if parsed.get("pending").and_then(|v| v.as_bool()) != Some(true) {
return Ok(parsed);
}
if std::time::Instant::now() >= deadline {
return Err(
"Runtime.callFunctionOn: awaitPromise did not settle within 20s".into(),
);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn cmd_runtime_release_object(page: &PageHandle, object_id: &str) -> Result<Value, String> {
let oid_json = serde_json::to_string(object_id).unwrap_or_default();
let js = format!(
r#"(function() {{ if (window.__bao_cdp) window.__bao_cdp.release({oid_json}); return 'ok'; }})()"#
);
page.evaluate_js_web(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_runtime_release_object_group(
page: &PageHandle,
object_group: &str,
) -> Result<Value, String> {
let group_json = serde_json::to_string(object_group).unwrap_or_default();
let js = format!(
r#"(function() {{ if (window.__bao_cdp) window.__bao_cdp.releaseGroup({group_json}); return 'ok'; }})()"#
);
page.evaluate_js_web(&js).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn json_type(v: &Value) -> &'static str {
match v {
Value::Null => "undefined",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "object",
Value::Object(_) => "object",
}
}
fn parse_js_result(result: &str) -> Result<Value, String> {
serde_json::from_str(result)
.map_err(|e| format!("unparseable JS wrapper output: {e} (got: {result:.200})"))
}
fn json_type_string(s: &str) -> &'static str {
if s.is_empty() || s == "undefined" {
"undefined"
} else if s == "null" {
"object"
} else if s == "true" || s == "false" {
"boolean"
} else if s.parse::<f64>().is_ok() {
"number"
} else if s.starts_with('{') || s.starts_with('[') {
"object"
} else {
"string"
}
}
fn cookie_to_cdp(c: &cookie::Cookie) -> Value {
let same_site = match c.same_site() {
Some(cookie::SameSite::Strict) => "Strict",
Some(cookie::SameSite::Lax) => "Lax",
Some(cookie::SameSite::None) => "None",
None => "None",
};
let expires = c
.expires_datetime()
.map(|dt| dt.unix_timestamp() as f64)
.unwrap_or(-1.0);
serde_json::json!({
"name": c.name(),
"value": c.value(),
"domain": c.domain().unwrap_or(""),
"path": c.path().unwrap_or("/"),
"expires": expires,
"size": c.name().len() + c.value().len(),
"httpOnly": c.http_only().unwrap_or(false),
"secure": c.secure().unwrap_or(false),
"sameSite": same_site,
"session": expires == -1.0,
"priority": "Medium",
"sourceScheme": "Unset",
"sourcePort": 0,
})
}
fn cdp_params_to_cookie(
name: &str,
value: &str,
_url: Option<&str>,
domain: Option<&str>,
) -> cookie::Cookie<'static> {
let mut builder = cookie::Cookie::build((name.to_string(), value.to_string()));
if let Some(d) = domain {
if d.starts_with('.') {
builder = builder.domain(d.to_string());
} else {
builder = builder.domain(format!(".{d}"));
}
}
builder = builder.path("/");
builder.build()
}
fn cmd_get_cookies(page: &PageHandle, urls: &[String]) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
let cookies: Vec<Value> = if urls.is_empty() {
let current_url = page.current_url().unwrap_or_default();
if current_url.is_empty() || current_url == "about:blank" {
Vec::new()
} else {
match url::Url::parse(¤t_url) {
Ok(parsed) => {
let servo_cookies = sdm.cookies_for_url(parsed, CookieSource::HTTP);
servo_cookies.iter().map(cookie_to_cdp).collect()
}
Err(_) => Vec::new(),
}
}
} else {
let mut seen = HashSet::new();
let mut result = Vec::new();
for url_str in urls {
if let Ok(parsed) = url::Url::parse(url_str) {
for c in sdm.cookies_for_url(parsed, CookieSource::HTTP) {
let key = (
c.name().to_string(),
c.domain().unwrap_or("").to_string(),
c.path().unwrap_or("").to_string(),
);
if seen.insert(key) {
result.push(cookie_to_cdp(&c));
}
}
}
}
result
};
Ok(serde_json::json!({ "cookies": cookies }))
}
fn cmd_get_all_cookies(page: &PageHandle) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
let site_data = sdm.site_data(StorageType::Cookies);
let mut cookies: Vec<Value> = Vec::new();
let mut seen = HashSet::new();
for sd in site_data {
let site_name = sd.name();
let url_str = if site_name.starts_with("http://") || site_name.starts_with("https://") {
site_name.clone()
} else {
format!("https://{site_name}")
};
if let Ok(parsed) = url::Url::parse(&url_str) {
for c in sdm.cookies_for_url(parsed, CookieSource::HTTP) {
let key = (
c.name().to_string(),
c.domain().unwrap_or("").to_string(),
c.path().unwrap_or("").to_string(),
);
if seen.insert(key) {
cookies.push(cookie_to_cdp(&c));
}
}
}
}
Ok(serde_json::json!({ "cookies": cookies }))
}
fn cmd_set_cookie(
page: &PageHandle,
name: &str,
value: &str,
url: Option<&str>,
domain: Option<&str>,
) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
let cookie = cdp_params_to_cookie(name, value, url, domain);
let fallback_url = page.current_url().unwrap_or_default();
let url_str = url.unwrap_or_else(|| {
if fallback_url.is_empty() || fallback_url == "about:blank" {
"https://localhost/"
} else {
fallback_url.as_str()
}
});
let parsed = url::Url::parse(url_str).map_err(|e| format!("invalid URL for setCookie: {e}"))?;
sdm.set_cookie_for_url(parsed, cookie, None);
Ok(serde_json::json!({ "success": true }))
}
fn cmd_delete_cookie(page: &PageHandle, name: &str, url: Option<&str>) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
if let Some(url_str) = url {
let parsed =
url::Url::parse(url_str).map_err(|e| format!("invalid URL for deleteCookies: {e}"))?;
let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
let site = parsed.host_str().unwrap_or("");
sdm.clear_site_data(&[site], StorageType::Cookies);
for c in current {
if c.name() != name {
sdm.set_cookie_for_url(parsed.clone(), c, None);
}
}
} else {
let site_data = sdm.site_data(StorageType::Cookies);
for sd in site_data {
let site_name = sd.name();
let url_str = if site_name.starts_with("http://") || site_name.starts_with("https://") {
site_name.clone()
} else {
format!("https://{site_name}")
};
if let Ok(parsed) = url::Url::parse(&url_str) {
let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
let has_match = current.iter().any(|c| c.name() == name);
if has_match {
sdm.clear_site_data(&[&site_name], StorageType::Cookies);
for c in current {
if c.name() != name {
sdm.set_cookie_for_url(parsed.clone(), c, None);
}
}
}
}
}
}
Ok(serde_json::json!({}))
}
fn cmd_network_set_cache_disabled(
page: &PageHandle,
cache_disabled: bool,
) -> Result<Value, String> {
if cache_disabled {
let servo = page.servo();
let nm = servo.network_manager();
nm.clear_cache();
}
Ok(serde_json::json!({}))
}
fn cmd_network_clear_browser_cache(page: &PageHandle) -> Result<Value, String> {
let servo = page.servo();
let nm = servo.network_manager();
nm.clear_cache();
Ok(serde_json::json!({}))
}
fn cmd_network_clear_browser_cookies(page: &PageHandle) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
sdm.clear_cookies(None);
Ok(serde_json::json!({}))
}
fn cmd_storage_get_items(
page: &PageHandle,
origin: String,
storage_type: String,
) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
let st = parse_storage_type(&storage_type);
let site_data = sdm.site_data(st);
let items: Vec<Value> = site_data
.iter()
.filter(|sd| {
let site_name = sd.name();
origin.is_empty()
|| site_name == origin
|| site_name.ends_with(&format!(".{origin}"))
|| origin.ends_with(&format!(".{site_name}"))
})
.map(|sd| {
serde_json::json!({
"origin": sd.name(),
"storageType": storage_type,
})
})
.collect();
Ok(serde_json::json!({ "storageItems": items }))
}
fn cmd_storage_clear_data(
page: &PageHandle,
origin: String,
storage_type: String,
) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
let st = parse_storage_type(&storage_type);
if origin.is_empty() {
sdm.clear_cookies(None);
} else {
sdm.clear_site_data(&[&origin], st);
}
Ok(serde_json::json!({}))
}
fn parse_storage_type(storage_type: &str) -> StorageType {
match storage_type {
"cookies" | "cookie" => StorageType::Cookies,
"local_storage" | "local" => StorageType::Local,
"session_storage" | "session" => StorageType::Session,
"all" => StorageType::Cookies | StorageType::Local | StorageType::Session,
_ => StorageType::Cookies | StorageType::Local | StorageType::Session,
}
}
fn ok_empty() -> Result<Value, String> {
Ok(serde_json::json!({}))
}
#[cfg(test)]
mod tests {
use crate::delegate::{ServiceWorkerHandle, ServiceWorkerRegistrationState};
use serde_json::{json, Value};
#[test]
fn json_type_null_returns_undefined() {
assert_eq!(super::json_type(&json!(null)), "undefined");
}
#[test]
fn json_type_bool_returns_boolean() {
assert_eq!(super::json_type(&json!(true)), "boolean");
assert_eq!(super::json_type(&json!(false)), "boolean");
}
#[test]
fn json_type_number_returns_number() {
assert_eq!(super::json_type(&json!(42)), "number");
assert_eq!(super::json_type(&json!(3.14)), "number");
assert_eq!(super::json_type(&json!(0)), "number");
assert_eq!(super::json_type(&json!(-1)), "number");
}
#[test]
fn json_type_string_returns_string() {
assert_eq!(super::json_type(&json!("hello")), "string");
assert_eq!(super::json_type(&json!("")), "string");
}
#[test]
fn json_type_array_returns_object() {
assert_eq!(super::json_type(&json!([1, 2, 3])), "object");
assert_eq!(super::json_type(&json!([])), "object");
}
#[test]
fn json_type_object_returns_object() {
assert_eq!(super::json_type(&json!({"a": 1})), "object");
assert_eq!(super::json_type(&json!({})), "object");
}
#[test]
fn json_type_string_empty_returns_undefined() {
assert_eq!(super::json_type_string(""), "undefined");
}
#[test]
fn json_type_string_undefined_returns_undefined() {
assert_eq!(super::json_type_string("undefined"), "undefined");
}
#[test]
fn json_type_string_null_returns_object() {
assert_eq!(super::json_type_string("null"), "object");
}
#[test]
fn json_type_string_true_returns_boolean() {
assert_eq!(super::json_type_string("true"), "boolean");
}
#[test]
fn json_type_string_false_returns_boolean() {
assert_eq!(super::json_type_string("false"), "boolean");
}
#[test]
fn json_type_string_integer_returns_number() {
assert_eq!(super::json_type_string("42"), "number");
assert_eq!(super::json_type_string("0"), "number");
assert_eq!(super::json_type_string("-7"), "number");
}
#[test]
fn json_type_string_float_returns_number() {
assert_eq!(super::json_type_string("3.14"), "number");
assert_eq!(super::json_type_string("-0.5"), "number");
}
#[test]
fn json_type_string_object_brace_returns_object() {
assert_eq!(super::json_type_string("{\"a\":1}"), "object");
}
#[test]
fn json_type_string_array_bracket_returns_object() {
assert_eq!(super::json_type_string("[1,2,3]"), "object");
}
#[test]
fn json_type_string_regular_text_returns_string() {
assert_eq!(super::json_type_string("hello world"), "string");
assert_eq!(super::json_type_string("some result"), "string");
}
#[test]
fn json_type_large_number() {
assert_eq!(super::json_type(&json!(i64::MAX)), "number");
assert_eq!(super::json_type(&json!(f64::MAX)), "number");
}
#[test]
fn json_type_nested_object() {
assert_eq!(super::json_type(&json!({"a": {"b": 1}})), "object");
}
#[test]
fn json_type_nested_array() {
assert_eq!(super::json_type(&json!([[1, 2], [3, 4]])), "object");
}
#[test]
fn json_type_string_scientific_notation() {
assert_eq!(super::json_type_string("1e10"), "number");
assert_eq!(super::json_type_string("-2.5e-3"), "number");
}
#[test]
fn json_type_string_whitespace_is_string() {
assert_eq!(super::json_type_string(" "), "string");
assert_eq!(super::json_type_string(" 42"), "string");
}
#[test]
fn json_type_string_special_strings() {
assert_eq!(super::json_type_string("NaN"), "number");
assert_eq!(super::json_type_string("Infinity"), "number");
assert_eq!(super::json_type_string("[object Object]"), "object");
}
#[test]
fn json_type_string_negative_zero() {
assert_eq!(super::json_type_string("-0"), "number");
assert_eq!(super::json_type_string("0.0"), "number");
}
#[test]
fn to_browser_error_init_variant() {
let err = crate::error::BrowserError::Init("failed to start".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("browser init error"));
assert!(msg.contains("failed to start"));
}
#[test]
fn to_browser_error_navigation_variant() {
let err = crate::error::BrowserError::Navigation("invalid url".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("navigation error"));
assert!(msg.contains("invalid url"));
}
#[test]
fn to_browser_error_rendering_variant() {
let err = crate::error::BrowserError::Rendering("gpu lost".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("rendering error"));
assert!(msg.contains("gpu lost"));
}
#[test]
fn to_browser_error_javascript_variant() {
let err = crate::error::BrowserError::JavaScript("syntax error".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("javascript error"));
assert!(msg.contains("syntax error"));
}
#[test]
fn to_browser_error_cdp_variant() {
let err = crate::error::BrowserError::CDP("connection refused".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("cdp error"));
assert!(msg.contains("connection refused"));
}
#[test]
fn to_browser_error_empty_message() {
let err = crate::error::BrowserError::Init(String::new());
let msg = super::to_browser_error(err);
assert!(msg.contains("browser init error"));
}
#[test]
fn to_browser_error_unicode_message() {
let err = crate::error::BrowserError::Navigation("页面加载失败".into());
let msg = super::to_browser_error(err);
assert!(msg.contains("页面加载失败"));
}
#[test]
fn next_cdp_id_is_unique_and_prefixed() {
let a = super::next_cdp_id("loader");
let b = super::next_cdp_id("loader");
assert!(a.starts_with("loader-"), "id must carry its prefix: {a}");
assert_ne!(a, b, "ids must be unique per call");
let s = super::next_cdp_id("script");
assert!(s.starts_with("script-"), "id must carry its prefix: {s}");
}
#[test]
fn cmd_navigate_uses_page_id_frame_and_generated_loader() {
let source = include_str!("cdp_handler.rs");
assert!(
source.contains("\"frameId\": page.id().to_string()"),
"cmd_navigate/cmd_reload must return the real page id as frameId"
);
assert!(
source.contains("\"loaderId\": next_cdp_id(\"loader\")"),
"cmd_navigate/cmd_reload must generate a fresh loaderId per load"
);
assert!(
!source.contains("\"loaderId\": \"0\""),
"no canned loaderId \"0\" may remain"
);
}
#[test]
fn cmd_evaluate_return_by_value_true_json_parse() {
let result_str = r#"{"a":1}"#;
let parsed: Result<Value, _> = serde_json::from_str(result_str);
assert!(parsed.is_ok());
assert_eq!(super::json_type(&parsed.unwrap()), "object");
}
#[test]
fn cmd_evaluate_return_by_value_true_non_json_falls_back() {
let result_str = "hello world";
let parsed: Result<Value, _> = serde_json::from_str(result_str);
assert!(parsed.is_err());
assert_eq!(super::json_type_string(result_str), "string");
}
#[test]
fn cmd_evaluate_return_by_value_true_null_json() {
let parsed: Result<Value, _> = serde_json::from_str("null");
assert!(parsed.is_ok());
assert_eq!(super::json_type(&parsed.unwrap()), "undefined");
}
#[test]
fn cmd_evaluate_return_by_value_true_number_json() {
let parsed: Result<Value, _> = serde_json::from_str("42");
assert!(parsed.is_ok());
assert_eq!(super::json_type(&parsed.unwrap()), "number");
}
#[test]
fn cmd_evaluate_return_by_value_true_boolean_json() {
let parsed: Result<Value, _> = serde_json::from_str("true");
assert!(parsed.is_ok());
assert_eq!(super::json_type(&parsed.unwrap()), "boolean");
}
#[test]
fn cmd_evaluate_return_by_value_false_uses_description() {
let result_str = "some JS output";
assert_eq!(super::json_type_string(result_str), "string");
}
#[test]
fn cmd_screenshot_format_jpeg_mapping() {
let fmt = match "jpeg" {
"jpeg" => "Jpeg",
_ => "Png",
};
assert_eq!(fmt, "Jpeg");
}
#[test]
fn cmd_screenshot_format_png_mapping() {
let fmt = match "png" {
"jpeg" => "Jpeg",
_ => "Png",
};
assert_eq!(fmt, "Png");
}
#[test]
fn cmd_screenshot_format_unknown_defaults_to_png() {
let fmt = match "bmp" {
"jpeg" => "Jpeg",
"webp" => "WebP",
_ => "Png",
};
assert_eq!(fmt, "Png");
}
#[test]
fn cmd_screenshot_format_webp_mapping() {
let fmt = match "webp" {
"jpeg" => "Jpeg",
"webp" => "WebP",
_ => "Png",
};
assert_eq!(fmt, "WebP");
}
#[test]
fn cmd_screenshot_format_empty_defaults_to_png() {
let fmt = match "" {
"jpeg" => "Jpeg",
_ => "Png",
};
assert_eq!(fmt, "Png");
}
#[test]
fn cmd_screenshot_base64_encoding() {
let bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47]; let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
assert!(!b64.is_empty());
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64);
assert!(decoded.is_ok());
assert_eq!(decoded.unwrap(), bytes);
}
#[test]
fn cmd_screenshot_base64_empty_bytes() {
let bytes: Vec<u8> = vec![];
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
assert_eq!(b64, ""); }
#[test]
fn cmd_query_selector_js_construction_valid_selector() {
let selector = "div.main";
let js = format!(
"(function() {{ var e = document.querySelector({}); return e ? 1 : 0; }})()",
serde_json::to_string(selector).unwrap_or_default()
);
assert!(js.contains("document.querySelector"));
assert!(js.contains("\"div.main\""));
}
#[test]
fn cmd_query_selector_js_construction_empty_selector() {
let selector = "";
let json_str = serde_json::to_string(selector).unwrap_or_default();
assert_eq!(json_str, "\"\"");
}
#[test]
fn cmd_query_selector_js_construction_special_chars() {
let selector = "div[data-attr='value']";
let json_str = serde_json::to_string(selector).unwrap_or_default();
assert!(json_str.contains("div[data-attr"));
}
#[test]
fn cmd_query_selector_js_construction_unicode() {
let selector = "div.中文类名";
let json_str = serde_json::to_string(selector).unwrap_or_default();
assert!(json_str.contains("中文类名"));
}
#[test]
fn cmd_query_selector_all_js_construction() {
let selector = "li.item";
let js = format!(
"(function() {{ return document.querySelectorAll({}).length; }})()",
serde_json::to_string(selector).unwrap_or_default()
);
assert!(js.contains("document.querySelectorAll"));
assert!(js.contains(".length"));
}
#[test]
fn cmd_query_selector_all_count_to_node_ids() {
let count: i64 = 3;
let ids: Vec<i64> = (1..=count).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn cmd_query_selector_all_zero_count() {
let count: i64 = 0;
let ids: Vec<i64> = (1..=count).collect();
assert!(ids.is_empty());
}
#[test]
fn cmd_query_selector_all_large_count() {
let count: i64 = 100;
let ids: Vec<i64> = (1..=count).collect();
assert_eq!(ids.len(), 100);
assert_eq!(ids[0], 1);
assert_eq!(ids[99], 100);
}
#[test]
fn cmd_set_attribute_js_construction() {
let name = "class";
let value = "active";
let js = format!(
"(function() {{ document.querySelector('[data-cdp]')?.setAttribute({}, {}); }})()",
serde_json::to_string(name).unwrap_or_default(),
serde_json::to_string(value).unwrap_or_default(),
);
assert!(js.contains("setAttribute"));
assert!(js.contains("\"class\""));
assert!(js.contains("\"active\""));
}
#[test]
fn cmd_set_attribute_js_with_quotes_in_value() {
let name = "data-info";
let value = r#"he said "hello""#;
let _json_name = serde_json::to_string(name).unwrap_or_default();
let json_value = serde_json::to_string(value).unwrap_or_default();
assert!(json_value.contains("\\\""));
}
#[test]
fn cmd_insert_text_js_construction() {
let text = "hello";
let js = format!(
"(function() {{ var el = document.activeElement; if (el && 'value' in el) el.value += {}; }})()",
serde_json::to_string(text).unwrap_or_default(),
);
assert!(js.contains("document.activeElement"));
assert!(js.contains("el.value"));
}
#[test]
fn cmd_insert_text_js_empty_string() {
let text = "";
let json_str = serde_json::to_string(text).unwrap_or_default();
assert_eq!(json_str, "\"\"");
}
#[test]
fn cmd_insert_text_js_newline_escaped() {
let text = "line1\nline2";
let json_str = serde_json::to_string(text).unwrap_or_default();
assert!(json_str.contains("\\n"));
}
#[test]
fn cmd_set_user_agent_js_construction() {
let ua = "Mozilla/5.0 Test";
let js = format!(
"Object.defineProperty(navigator, 'userAgent', {{ get: function() {{ return {}; }} }});",
serde_json::to_string(ua).unwrap_or_default(),
);
assert!(js.contains("Object.defineProperty"));
assert!(js.contains("navigator"));
assert!(js.contains("userAgent"));
}
#[test]
fn cmd_set_user_agent_js_empty_string() {
let ua = "";
let json_str = serde_json::to_string(ua).unwrap_or_default();
assert_eq!(json_str, "\"\"");
}
#[test]
fn cmd_get_document_js_template_structure() {
let js = r#"
(function() {
function walk(node, id) {
var result = {
nodeId: id,
backendNodeId: id,
nodeType: node.nodeType,
nodeName: node.nodeName,
localName: node.localName || '',
nodeValue: node.nodeValue || '',
};
if (node.childNodes && node.childNodes.length > 0) {
result.childNodeCount = node.childNodes.length;
result.children = [];
for (var i = 0; i < Math.min(node.childNodes.length, 20); i++) {
result.children.push(walk(node.childNodes[i], id * 100 + i + 1));
}
}
return result;
}
return JSON.stringify(walk(document, 1));
})()
"#;
assert!(js.contains("walk"));
assert!(js.contains("nodeId"));
assert!(js.contains("nodeType"));
assert!(js.contains("nodeName"));
assert!(js.contains("childNodeCount"));
assert!(js.contains("Math.min"));
assert!(js.contains("JSON.stringify"));
}
#[test]
fn cmd_get_document_js_limits_children_to_20() {
let _js = r#"(function() { return Math.min(50, 20); })()"#;
assert_eq!(50usize.min(20), 20);
}
#[test]
fn cmd_get_outer_html_js_is_simple_expression() {
let js = "document.documentElement.outerHTML";
assert!(js.contains("document.documentElement"));
assert!(js.contains("outerHTML"));
}
#[test]
fn cmd_add_script_response_has_generated_identifier() {
let resp = json!({ "identifier": super::next_cdp_id("script") });
assert!(resp["identifier"].as_str().unwrap().starts_with("script-"));
}
#[test]
fn cmd_reload_response_structure() {
let source = include_str!("cdp_handler.rs");
assert!(
source.contains("page.reload().map_err(to_browser_error)?;"),
"cmd_reload must call PageHandle::reload"
);
}
#[test]
fn handle_bridge_command_go_back_forward_wired_stop_loading_errors() {
let source = include_str!("cdp_handler.rs");
assert!(
source.contains("BridgeCommand::GoBack { target_id } => with_page"),
"GoBack must be dispatched to a page handler"
);
assert!(
source.contains("BridgeCommand::GoForward { target_id } => with_page"),
"GoForward must be dispatched to a page handler"
);
assert!(
source.contains("Page.stopLoading not supported"),
"StopLoading must return an explicit error, never a fake ok"
);
}
#[test]
fn no_canned_successes_remain_in_dispatch() {
let source = include_str!("cdp_handler.rs");
assert!(!source.contains("\"snapshot\": {}"));
assert!(!source.contains("\"profile\": {}"));
assert!(!source.contains("\"jsEventListeners\": 0"));
assert!(!source.contains("\"body\": \"\""));
assert!(!source.contains("\"identifier\": \"1\""));
}
#[test]
fn profiler_and_heapprofiler_report_explicit_errors() {
let source = include_str!("cdp_handler.rs");
assert!(source.contains("Profiler not supported"));
assert!(source.contains("HeapProfiler snapshot/tracking not supported"));
assert!(source.contains("Memory.getDOMCounters not supported"));
assert!(source.contains("Network.getResponseBody not supported"));
assert!(source.contains("Network.setExtraHTTPHeaders not supported"));
assert!(source.contains("Security.setOverrideCertificateErrors not supported"));
}
#[test]
fn collect_garbage_uses_servo_gc_api() {
let source = include_str!("cdp_handler.rs");
assert!(
source.contains("navigator.servo.GarbageCollectAllContexts()"),
"GC must go through servo's real GC DOM API"
);
}
#[test]
fn handle_bridge_command_close_page_returns_empty() {
let expected = json!({});
assert_eq!(expected, json!({}));
}
#[test]
fn handle_bridge_command_unsupported_returns_error() {
let err_msg = "unsupported bridge command";
assert!(!err_msg.is_empty());
}
#[test]
fn json_type_string_leading_dot_is_string() {
let result = ".5".parse::<f64>();
if result.is_ok() {
assert_eq!(super::json_type_string(".5"), "number");
} else {
assert_eq!(super::json_type_string(".5"), "string");
}
}
#[test]
fn json_type_string_positive_infinity() {
assert_eq!(super::json_type_string("inf"), "number");
}
#[test]
fn json_type_string_negative_infinity() {
assert_eq!(super::json_type_string("-inf"), "number");
}
#[test]
fn json_type_string_hex_string_is_string() {
assert_eq!(super::json_type_string("0x1A"), "string");
}
#[test]
fn json_type_string_very_long_number() {
let long_num = "123456789012345678901234567890";
assert_eq!(super::json_type_string(long_num), "number");
}
#[test]
fn json_type_string_mixed_alphanumeric_is_string() {
assert_eq!(super::json_type_string("abc123"), "string");
}
#[test]
fn json_type_string_empty_object_string() {
assert_eq!(super::json_type_string("{}"), "object");
}
#[test]
fn json_type_string_empty_array_string() {
assert_eq!(super::json_type_string("[]"), "object");
}
#[test]
fn json_type_negative_number() {
assert_eq!(super::json_type(&json!(-999)), "number");
}
#[test]
fn json_type_large_float() {
assert_eq!(super::json_type(&json!(f64::MIN)), "number");
}
#[test]
fn json_type_deeply_nested_value() {
let deep = json!({"a": {"b": {"c": {"d": [1, 2, {"e": true}]}}}});
assert_eq!(super::json_type(&deep), "object");
}
#[test]
fn json_type_string_with_special_chars() {
assert_eq!(super::json_type(&json!("\n\t\r")), "string");
assert_eq!(super::json_type(&json!("\0")), "string");
}
#[test]
fn json_type_mixed_array() {
assert_eq!(
super::json_type(&json!([1, "two", null, true, {}])),
"object"
);
}
#[test]
fn sw_registration_id_parses_double_colon_format() {
let id = super::parse_sw_registration_id("sw.js::/").unwrap();
assert_eq!(id.script_url, "sw.js");
assert_eq!(id.scope, "/");
}
#[test]
fn sw_registration_id_parses_complex_scope() {
let id = super::parse_sw_registration_id("https://example.com/sw.js::/app/").unwrap();
assert_eq!(id.script_url, "https://example.com/sw.js");
assert_eq!(id.scope, "/app/");
}
#[test]
fn sw_registration_id_rejects_missing_separator() {
assert!(super::parse_sw_registration_id("sw.js").is_err());
assert!(super::parse_sw_registration_id("sw.js/").is_err());
}
#[test]
fn sw_registration_to_json_activated() {
let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
handle.transition_state(ServiceWorkerRegistrationState::Activated);
handle.enable_fetch_interception();
let json_val = super::sw_registration_to_json(handle);
assert_eq!(json_val["registrationId"], "sw.js::/");
assert_eq!(json_val["scriptURL"], "sw.js");
assert_eq!(json_val["scope"], "/");
assert_eq!(json_val["state"], "activated");
assert_eq!(json_val["isActive"], true);
assert_eq!(json_val["isFetchIntercepting"], true);
}
#[test]
fn sw_registration_to_json_installing_state() {
let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
let json_val = super::sw_registration_to_json(handle);
assert_eq!(json_val["state"], "installing");
assert_eq!(json_val["isActive"], false);
assert_eq!(json_val["isFetchIntercepting"], false);
}
#[test]
fn sw_registration_to_json_all_states() {
let states_and_expected = vec![
(ServiceWorkerRegistrationState::Idle, "idle"),
(ServiceWorkerRegistrationState::Installing, "installing"),
(ServiceWorkerRegistrationState::Installed, "installed"),
(ServiceWorkerRegistrationState::Activating, "activating"),
(ServiceWorkerRegistrationState::Activated, "activated"),
(ServiceWorkerRegistrationState::Redundant, "redundant"),
];
for (state, expected) in states_and_expected {
let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
handle.transition_state(state.clone());
let json_val = super::sw_registration_to_json(handle);
assert_eq!(json_val["state"], expected, "state mapping for {:?}", state);
}
}
#[test]
fn sw_registration_terminate_disables_fetch_interception() {
let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
handle.enable_fetch_interception();
assert!(handle.is_intercepting_fetch());
handle.terminate();
assert!(handle.is_closing());
assert!(!handle.is_intercepting_fetch());
}
#[test]
fn sw_registration_terminate_is_idempotent() {
let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
handle.terminate();
handle.terminate();
handle.terminate();
assert!(handle.is_closing());
}
#[test]
fn sw_registration_stealth_profile_inherited() {
let profile = bao_stealth::StealthProfile::chrome_default();
let handle =
ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), Some(profile.clone()));
assert!(handle.stealth_profile.is_some());
let json_val = super::sw_registration_to_json(handle.clone());
assert_eq!(json_val["scriptURL"], "sw.js");
assert!(handle.stealth_profile.is_some());
}
#[test]
fn sw_registration_id_format_with_colon_in_url() {
let id = super::parse_sw_registration_id("https://a.io/sw.js::/scope").unwrap();
assert_eq!(id.script_url, "https://a.io/sw.js");
assert_eq!(id.scope, "/scope");
}
}