// @trace REQ-CDP-001 REQ-CDP-003: Bridge handler — routes BridgeCommand to servo WebView operations
// Runs on the main thread during the event loop to process CDP commands.
use bao_cdp::servo_bridge::{BridgeCommand, BridgeResponse};
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;
/// Process a single bridge command by dispatching to the appropriate page in the pool.
pub fn handle_bridge_command(cmd: BridgeCommand, pool: &PagePool) -> BridgeResponse {
let result = match cmd {
// Multi-target management commands — operate on the pool, not a specific page
BridgeCommand::CreateTarget { url } => cmd_create_target(pool, &url),
BridgeCommand::ListTargets => cmd_list_targets(pool),
// All other commands require a target_id to look up the page
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),
// servo WebView exposes no stop-loading API (load cancellation is not
// part of the embedding surface) — explicit error, never a fake ok.
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}")),
}
}
// Cookie commands — bridge to servo SiteDataManager
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())
}),
// servo does not store network response bodies for embedder access —
// explicit error instead of an empty-body fake success.
BridgeCommand::GetResponseBody { .. } => Err(
"Network.getResponseBody not supported: servo does not expose stored response bodies to the embedder".into(),
),
// Network domain — cache/cookies clearing, enable/disable
BridgeCommand::NetworkEnable { .. } => ok_empty(),
BridgeCommand::NetworkDisable { .. } => ok_empty(),
BridgeCommand::NetworkSetCacheDisabled {
target_id,
cache_disabled,
} => with_page(pool, &target_id, |page| {
cmd_network_set_cache_disabled(page, cache_disabled)
}),
// servo WebView has no per-target extra-headers injection API —
// explicit error instead of silently dropping the headers.
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)
}
// Storage domain — origin-scoped storage queries and clearing
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)
}),
// Security domain — enable/disable/certificate override
BridgeCommand::SecurityEnable { .. } => ok_empty(),
BridgeCommand::SecurityDisable { .. } => ok_empty(),
// Certificate-error override is startup-only (BaoConfig.ignore_certificate_errors
// → servo opts, read by the connector at init). No runtime per-target
// override face exists — explicit error, never a silent no-op ok.
BridgeCommand::SecuritySetOverrideCertificateErrors { .. } => Err(
"Security.setOverrideCertificateErrors not supported at runtime: certificate-error override is startup-only (BaoConfig.ignore_certificate_errors)".into(),
),
// Debugger domain — route through EvaluateJs to servo's debugger.js
// These BridgeCommands are typed (no JS string injection from CDP layer).
// cdp_handler translates them into servo debugger.js control messages.
// @trace BUG-CDP-006 [domain:Debugger]: current path is EvaluateJs →
// servo debugger.js. A future enhancement is direct routing via
// DevtoolScriptControlMsg once servo's devtools channel is exposed to Bao.
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))
}
// ── Profiler commands ──
// mozjs FFI exposes no SpiderMonkey sampling-profiler hooks
// (SPS/GekkoProfiler are not wrapped) — explicit error, never a fake
// empty profile.
BridgeCommand::ProfilerStart { .. }
| BridgeCommand::ProfilerStop { .. }
| BridgeCommand::ProfilerSetSamplingInterval { .. } => Err(
"Profiler not supported: SpiderMonkey sampling profiler is not exposed through the mozjs FFI surface".into(),
),
// ── HeapProfiler commands ──
// mozjs FFI exposes no heap-snapshot serializer — explicit error.
BridgeCommand::HeapProfilerTakeSnapshot { .. }
| BridgeCommand::HeapProfilerStartTracking { .. }
| BridgeCommand::HeapProfilerStopTracking { .. } => Err(
"HeapProfiler snapshot/tracking not supported: mozjs FFI exposes no heap-snapshot API".into(),
),
// collectGarbage IS real: servo exposes navigator.servo.GarbageCollectAllContexts()
// → ScriptToConstellationMessage::TriggerGarbageCollection → JS_GC on
// the script thread (the only thread allowed to touch the JSContext).
BridgeCommand::HeapProfilerCollectGarbage { target_id } => {
with_page(pool, &target_id, cmd_collect_garbage)
}
// ── Memory commands ──
// jsEventListeners is not introspectable in SpiderMonkey — explicit
// error rather than a zeroed counters object.
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)
}
// ── Performance commands ──
BridgeCommand::PerformanceGetMetrics { target_id } => {
with_page(pool, &target_id, cmd_performance_get_metrics)
}
// ── CSS domain commands — JS evaluate for computed/matched/inline styles ──
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)
})
}
// ── Runtime domain commands — JS evaluate for object inspection and function calls ──
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)
}),
// ServiceWorker domain — terminate a registered ServiceWorker
// @trace REQ-BRW-004 [entity:ServiceWorker]
BridgeCommand::TerminateServiceWorker {
target_id,
registration_id,
} => with_page(pool, &target_id, |page| {
cmd_terminate_service_worker(page, ®istration_id)
}),
// Worker/ServiceWorker target management — CDP Target domain for Workers
// @trace REQ-BRW-004 [entity:Worker] [entity:ServiceWorker]
BridgeCommand::ListWorkerTargets { target_id } => {
with_page(pool, &target_id, |page| {
// Real worker registry: the per-webview scope tables populated
// by Worker construction (DEC-WK-001 native path). Every entry
// is a live Dedicated/Shared Worker owned by this page — no
// synthetic worker-N ids.
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| {
// Real registry lookup: the worker id must identify a registered
// Dedicated/Shared Worker scope — unknown ids are an explicit
// error, never a fabricated TargetInfo.
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| {
// Real per-webview registry: the page's controlling
// ServiceWorker (BaoWebViewState.controlled_service_worker).
// Empty list means no registration — real state, not a stub.
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 }
}
/// Parse a string target_id into a usize page ID.
fn parse_target_id(target_id: &str) -> Option<usize> {
target_id.parse::<usize>().ok()
}
/// Look up a page by target_id string and execute the closure with it.
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)
}
/// Parse CDP registrationId into ServiceWorkerRegistrationId.
///
/// Format: "script_url::scope" (double-colon separator).
/// Example: "sw.js::/" or "https://example.com/sw.js::/app/"
///
/// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
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(),
})
}
/// Serialize ServiceWorkerHandle to CDP JSON format.
///
/// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8 C6
fn sw_registration_to_json(handle: ServiceWorkerHandle) -> Value {
// Per DEC-WK-008: fetch interception mode is tracked but servo upstream
// does not dispatch FetchEvent yet. CDP exposes the mode regardless.
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> {
// Real enumeration: every tracked page with its live title/url. Shape is
// the array form ServoTargetProvider::list_targets parses ({id,title,url}
// entries) — the previous {"targetIds": [...]} object never matched the
// provider, silently forcing the single-target fallback path.
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))
}
/// CDP TargetInfo JSON for a Worker sub-target. `target_id` is the Worker's
/// script URL (the WorkerId) — the real registry key, not a synthetic index.
/// @trace REQ-BRW-004 [entity:Worker] [entity:SharedWorker] [criterion:19]
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 to_browser_error(e: BrowserError) -> String {
format!("{e}")
}
/// Monotonic id source for CDP loaderId / script identifiers.
///
/// Chrome semantics: frameId is stable across navigations (we use the page id),
/// loaderId is fresh per load. A monotonic counter yields genuinely unique,
/// non-repeating ids — never a hardcoded constant.
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> {
// Web-scope evaluation (REQ-SEC-002/003): CDP Runtime.evaluate is the
// page's DevTools console — it must run in the Page Realm WITHOUT Node
// API injection. (The privileged evaluate_js face is bao-internal only
// and additionally does not survive navigation.)
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 {
// returnByValue=false: hand back a full RemoteObject with a
// registry-pinned objectId. This is the Playwright evaluateHandle
// path — the utilityScript handle is minted here and then driven via
// Runtime.callFunctionOn (objectId roundtrip).
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> {
// Use evaluate_js to extract DOM structure via JS
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> {
// Mouse event dispatch through servo requires InputEvent API
// For now, acknowledge the command
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> {
// Viewport resize requires re-creating the rendering context
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> {
// Real navigation replay: the script is registered on the page's servo
// UserContentManager and re-executed by the script thread on every future
// document load. Additionally applied to the current document so it is
// observable without a reload (a superset of Chrome's new-documents-only
// semantics — both executions are real).
page.add_script_to_evaluate_on_new_document(source)
.map_err(to_browser_error)?;
// Web-scope for the immediate application (REQ-SEC-002/003): the script
// is a page-level init script, not privileged bao code.
let _ = page.evaluate_js_web(source).map_err(to_browser_error)?;
Ok(serde_json::json!({ "identifier": next_cdp_id("script") }))
}
/// Page.reload — real servo reload (WebView::reload), not a re-navigate.
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"),
}))
}
/// Page.goBack — real servo session-history traversal (WebView::go_back).
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() }))
}
/// Page.goForward — real servo session-history traversal (WebView::go_forward).
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() }))
}
/// HeapProfiler.collectGarbage / Memory.forciblyPurgeJavaScriptMemory —
/// triggers a real full GC on the page's script thread via servo's
/// `navigator.servo.GarbageCollectAllContexts()` DOM API
/// (ScriptToConstellationMessage::TriggerGarbageCollection → JS_GC).
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!({}))
}
/// Performance.getMetrics — real values via a single page evaluation.
/// Only metrics that are truly computable are reported (Chrome's full set
/// includes LayoutCount/RecalcStyleCount/JSEventListeners which have no
/// SpiderMonkey/servo equivalent — omitted rather than zero-filled).
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 }))
}
/// ServiceWorker.terminateWorker / ServiceWorker.stopWorker — terminate the
/// page's controlling ServiceWorker when the registration id matches.
/// @trace REQ-BRW-004 [entity:ServiceWorker] DF-WK-8
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!({}))
}
/// ServiceWorker.getRegistration — read the page's controlling ServiceWorker.
/// @trace REQ-BRW-004 [entity:ServiceWorker] DF-WK-8
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}'"
)),
}
}
// ---------------------------------------------------------------------------
// Debugger domain commands — servo debugger.js bridge
// ---------------------------------------------------------------------------
/// JS that sets up servo's built-in SpiderMonkey Debugger instance.
/// Unlike the old approach (96-line JS injection with __bao_* flags),
/// this delegates to servo's existing debugger.js infrastructure via
/// the DebuggerGlobalScope event system.
const DEBUGGER_SETUP: &str = r#"
(function() {
if (window.__bao_dbg_active) return;
window.__bao_dbg_active = true;
try {
const dbg = new Debugger();
window.__bao_dbg = dbg;
dbg.onNewScript = function(script) {
const info = JSON.stringify({
id: script.id || ('s-' + Date.now()),
url: script.url || '',
startLine: script.startLine || 0,
endLine: script.startLine + (script.lineCount || 1) - 1,
});
console.log('__BAO_EVT__Debugger.scriptParsed\n' + info);
};
dbg.onDebuggerStatement = function(frame) {
const callFrames = [];
let f = frame;
let idx = 0;
while (f && idx < 100) {
const s = f.script;
callFrames.push({
callFrameId: 'frame-' + idx + '-' + (s ? s.id : 'x'),
functionName: f.callee ? (f.callee.name || '(anonymous)') : '(anonymous)',
location: { scriptId: s ? String(s.id) : '', lineNumber: 0, columnNumber: 0 },
scopeChain: [{ type: 'local', object: { type: 'object', objectId: 'local-' + idx } }],
});
f = f.older;
idx++;
}
const paused = JSON.stringify({ callFrames, reason: 'debuggerStatement', hitBreakpoints: [] });
console.log('__BAO_EVT__Debugger.paused\n' + paused);
};
dbg.findScripts().forEach(function(script) {
const info = JSON.stringify({
id: script.id || ('s-' + Date.now()),
url: script.url || '',
startLine: script.startLine || 0,
endLine: script.startLine + (script.lineCount || 1) - 1,
});
console.log('__BAO_EVT__Debugger.scriptParsed\n' + info);
});
} catch(e) {}
})();
"#;
fn cmd_debugger_enable(page: &PageHandle) -> Result<Value, String> {
let _ = page.evaluate_js(DEBUGGER_SETUP).map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_disable(page: &PageHandle) -> Result<Value, String> {
let js = "if (window.__bao_dbg) { window.__bao_dbg.onNewScript = undefined; window.__bao_dbg.onDebuggerStatement = undefined; window.__bao_dbg = null; window.__bao_dbg_active = false; }";
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 col = column.unwrap_or(0);
// Build a script filter: match by url (exact) or urlRegex, fall back to line-range match
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 <= {line} && {line} <= s.startLine + s.lineCount - 1"),
};
let js = format!(
"(function() {{ try {{ if (!window.__bao_dbg) return '{{}}'; var scripts = window.__bao_dbg.findScripts(); for (var i = 0; i < scripts.length; i++) {{ var s = scripts[i]; if ({url_filter}) {{ var offset = s.offsetLine ? s.offsetLine({line}, {col}) : 0; var bpId = 'bp-' + String(s.id) + '-' + {line} + '-' + {col}; s.setBreakpoint(offset, {{ hit: function(frame) {{ console.log('__BAO_EVT__Debugger.paused\\n' + JSON.stringify({{ callFrames: [], reason: 'breakpoint', hitBreakpoints: [bpId] }})); }} }}); if (!window.__bao_bps) window.__bao_bps = {{}}; window.__bao_bps[bpId] = {{ scriptId: String(s.id), offset: offset }}; return JSON.stringify({{ breakpointId: bpId, actualLocation: {{ scriptId: String(s.id), lineNumber: {line}, columnNumber: {col} }} }}); }} }} }} catch(e) {{}} return '{{}}'; }})()",
url_filter = url_filter, line = line, col = col
);
let result = page.evaluate_js(&js).map_err(to_browser_error)?;
parse_js_result(&result)
}
fn cmd_debugger_remove_breakpoint(page: &PageHandle, breakpoint_id: &str) -> Result<Value, String> {
let js = format!(
"(function() {{ try {{ if (!window.__bao_dbg) return; if (window.__bao_bps && window.__bao_bps[{}]) {{ var info = window.__bao_bps[{}]; var scripts = window.__bao_dbg.findScripts(); for (var i = 0; i < scripts.length; i++) {{ if (String(scripts[i].id) === info.scriptId) {{ scripts[i].clearAllBreakpoints(); break; }} }} delete window.__bao_bps[{}]; }} else {{ var scripts = window.__bao_dbg.findScripts(); scripts.forEach(function(s) {{ s.clearAllBreakpoints(); }}); }} }} catch(e) {{}} }})()",
serde_json::to_string(breakpoint_id).unwrap_or_default(),
serde_json::to_string(breakpoint_id).unwrap_or_default(),
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() { try { if (!window.__bao_dbg) return; window.__bao_dbg.onEnterFrame = function(frame) { window.__bao_dbg.onEnterFrame = undefined; frame.onStep = function() { frame.onStep = undefined; console.log('__BAO_EVT__Debugger.paused\n' + JSON.stringify({ callFrames: [], reason: 'interrupt', hitBreakpoints: [] })); return undefined; }; return undefined; }; } catch(e) {} })()";
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() { try { if (window.__bao_dbg) { window.__bao_dbg.onEnterFrame = function(frame) { window.__bao_dbg.onEnterFrame = undefined; frame.onPop = function() { frame.onPop = undefined; console.log('__BAO_EVT__Debugger.paused\n' + JSON.stringify({callFrames:[],reason:'step',hitBreakpoints:[]})); }; return undefined; }; } } catch(e) {} })()",
Some("step") => "(function() { try { if (window.__bao_dbg) { window.__bao_dbg.onEnterFrame = function(frame) { window.__bao_dbg.onEnterFrame = undefined; frame.onStep = function() { frame.onStep = undefined; console.log('__BAO_EVT__Debugger.paused\n' + JSON.stringify({callFrames:[],reason:'step',hitBreakpoints:[]})); }; return undefined; }; } } catch(e) {} })()",
Some("finish") => "(function() { try { if (window.__bao_dbg) { window.__bao_dbg.onEnterFrame = function(frame) { window.__bao_dbg.onEnterFrame = undefined; frame.onPop = function() { frame.onPop = undefined; console.log('__BAO_EVT__Debugger.paused\n' + JSON.stringify({callFrames:[],reason:'step',hitBreakpoints:[]})); }; return undefined; }; } } catch(e) {} })()",
_ => "(function() { /* resume: clear step hooks */ try { if (window.__bao_dbg) { window.__bao_dbg.onEnterFrame = undefined; } } catch(e) {} })()",
};
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() { try { if (!window.__bao_dbg) return JSON.stringify({frames:[]}); var f = window.__bao_dbg.getNewestFrame(); var frames = []; var idx = 0; while (f && idx < 100) { frames.push({callFrameId: 'frame-' + idx, functionName: f.callee ? (f.callee.name || '(anonymous)') : '(anonymous)', location: {scriptId: f.script ? String(f.script.id) : '', lineNumber: 0}}); f = f.older; idx++; } return JSON.stringify({frames: frames}); } catch(e) { return JSON.stringify({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 = "(function() { try { if (!window.__bao_dbg) return '{}'; var f = window.__bao_dbg.getNewestFrame(); if (!f || !f.environment) return '{}'; return JSON.stringify({environment: {}}); } catch(e) { return '{}'; } })()";
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!(
"String(s.id) === {}",
serde_json::to_string(start_script_id).unwrap_or_default()
)
};
let js = format!(
"(function() {{ try {{ if (!window.__bao_dbg) return JSON.stringify({{locations: []}}); var scripts = window.__bao_dbg.findScripts(); var locs = []; scripts.forEach(function(s) {{ if ({filter}) {{ for (var line = s.startLine; line < s.startLine + s.lineCount; line++) {{ locs.push({{scriptId: String(s.id), lineNumber: line}}); }} }} }}); return JSON.stringify({{locations: locs}}); }} catch(e) {{ return JSON.stringify({{locations: []}}); }} }})()",
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!(
"(function() {{ try {{ if (!window.__bao_dbg) return JSON.stringify({{scriptSource: ''}}); var scripts = window.__bao_dbg.findScripts(); for (var i = 0; i < scripts.length; i++) {{ if (String(scripts[i].id) === '{}') return JSON.stringify({{scriptSource: scripts[i].source.text || ''}}); }} return JSON.stringify({{scriptSource: ''}}); }} catch(e) {{ return JSON.stringify({{scriptSource: ''}}); }} }})()",
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> {
let _ = page
.evaluate_js("(function() { /* blackbox: not yet supported */ })()")
.map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
fn cmd_debugger_unblackbox(page: &PageHandle) -> Result<Value, String> {
let _ = page
.evaluate_js("(function() { /* unblackbox: not yet supported */ })()")
.map_err(to_browser_error)?;
Ok(serde_json::json!({}))
}
// ---------------------------------------------------------------------------
// CSS domain commands — JS evaluate for computed/matched/inline styles
// ---------------------------------------------------------------------------
/// Resolve a CDP nodeId to a DOM element via JS evaluate.
/// nodeId in our CDP implementation maps to a synthetic data-node-id attribute,
/// or falls back to traversing the DOM tree by index.
fn resolve_node_by_id(page: &PageHandle, node_id: i64) -> Result<String, String> {
if node_id <= 0 {
// nodeId 1 = document, 2 = html element
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 {
// Try data-node-id attribute first, then fall back to DOM traversal
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 {
// Fall back to body.childNodes traversal
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)
}
// ---------------------------------------------------------------------------
// Runtime domain commands — JS evaluate for object inspection and function calls
// ---------------------------------------------------------------------------
/// JS prelude that installs the page-realm object registry backing the CDP
/// Runtime object protocol (evaluate-handle / callFunctionOn / getProperties /
/// releaseObject). Idempotent — a second run is a no-op.
///
/// The registry IS the CDP object reference table: every RemoteObject objectId
/// pins a strong reference until `Runtime.releaseObject`/`releaseObjectGroup`
/// drops it, which is what keeps handed-out objects GC-alive across
/// evaluations. DEVIATION (documented): Chrome keeps this table debugger-side;
/// the servo embedder exposes no JSObject egress through the evaluate bridge,
/// so the strong refs live in a page-realm `Object.create(null)` map and the
/// table is page-visible (acceptable for an automation-facing CDP face).
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';
})()"#;
/// Resolve a CDP objectId to a JS expression that references the object.
/// objectId formats:
/// "node-N" → DOM node reference (legacy DOM-domain mapping)
/// "obj-*" → page-realm registry entry (CDP_REGISTRY_PRELUDE table)
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);
// Property enumeration: own=true → getOwnPropertyNames (own properties,
// data + accessors); own=false → for-in (own + inherited enumerables).
// Every property value becomes a real RemoteObject — object/function
// values are registered in the page registry, so the returned objectIds
// roundtrip through callFunctionOn/getProperties (the previous code
// fabricated ids it never stored, and resolving them always gave null).
// Web-scope evaluation per REQ-SEC-002/003 (registry is page-realm).
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)
}
/// Materialize one CDP CallArgument as a JS expression: `{value}` (JSON
/// literal), `{unserializableValue}` (NaN/±Infinity/-0/BigInt) or `{objectId}`
/// resolved through the page-realm registry (or the DOM node mapping).
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()) {
// BigInt literal ("123n" / "-5n")
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> {
// The page realm's security contract (REQ-SEC-002/003): page-facing CDP
// evaluation runs web-scope — evaluate_js_web, never the Node-realm
// privileged face. The registry must exist before any object reference
// is resolved; installing is idempotent.
page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
.map_err(to_browser_error)?;
// `this` for the call: objectId wins. executionContextId alone means
// this=undefined (single page-realm context — DEVIATION: the servo
// embedder exposes no isolated worlds, so all context ids evaluate
// against the page realm).
let _ctx = execution_context_id; // single-realm: routing is the page itself
let this_expr = match object_id {
Some(oid) => resolve_object_by_id(oid),
None => "undefined".to_string(),
};
// CDP CallArgument materialization ({value} / {unserializableValue} /
// {objectId}).
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();
// functionDeclaration is a stringized function ("function(a, b) { ... }");
// it is called with the materialized arguments on the resolved `this`.
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)?;
// The wrapper always returns JSON.stringify({result/exceptionDetails}) —
// an unparseable output is a real failure, never a silent {}.
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)
}
/// awaitPromise resolution: the call wrapper parked a pending Promise's
/// continuation in `window.__bao_async`; poll it. Every `evaluate_js_web`
/// spins the servo event loop itself, so microtask/timer/fetch chains keep
/// making progress between polls. Bounded — a never-settling promise
/// surfaces as an error instead of hanging the bridge worker.
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));
}
}
/// Runtime.releaseObject — drop one registry entry (frees the strong ref the
/// handed-out objectId pinned; guards against evaluateHandle leak loops).
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!({}))
}
/// Runtime.releaseObjectGroup — drop every registry entry minted under the
/// objectGroup (Playwright releases its "utility"/"console" groups on
/// context teardown with exactly this call).
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",
}
}
/// Parse a JS evaluate_js string result into a serde_json::Value.
fn parse_js_result(result: &str) -> Result<Value, String> {
// Fail-closed: an unparseable wrapper output is a real failure, never a
// silent {}. (The previous `.unwrap_or(Ok(json!({})))` had a type-level
// bug — serde deserialized into an externally-tagged Result<Value,String>
// envelope, which never matches normal JSON, so EVERY caller silently
// got {} back. BCE: silent-fallback masquerading as delivery.)
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"
}
}
// ─── Network / Cookie / Storage / Security domain handlers ──────────────
// Bridge servo's SiteDataManager and NetworkManager to CDP protocol.
/// Convert a servo `Cookie<'static>` to CDP Cookie JSON object.
/// CDP Cookie spec: https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-Cookie
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,
})
}
/// Build a `cookie::Cookie<'static>` from CDP setCookie parameters.
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()
}
/// Network.getCookies — retrieve cookies for the given URLs (or current page URL).
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() {
// No URLs specified — use the current page URL
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 {
// Collect cookies for each URL, deduplicating by (name, domain, path)
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 }))
}
/// Network.getAllCookies — retrieve all cookies from the cookie jar.
fn cmd_get_all_cookies(page: &PageHandle) -> Result<Value, String> {
let servo = page.servo();
let sdm = servo.site_data_manager();
// Get all sites that have cookies, then collect cookies for each
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();
// Construct a URL from the site name to query cookies
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 }))
}
/// Network.setCookie — set a cookie via servo's SiteDataManager.
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);
// Determine the URL to associate the cookie with
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 }))
}
/// Network.deleteCookies — delete cookies matching name (and optionally url/domain).
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}"))?;
// Get current cookies for this URL
let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
// Clear all cookies for this site, then re-set the ones that don't match the name
let site = parsed.host_str().unwrap_or("");
sdm.clear_site_data(&[site], StorageType::Cookies);
// Re-set cookies that don't match the name to delete
for c in current {
if c.name() != name {
sdm.set_cookie_for_url(parsed.clone(), c, None);
}
}
} else {
// No URL — clear cookies for all sites matching the name
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!({}))
}
/// Network.setCacheDisabled — clear cache when cache_disabled is true.
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!({}))
}
/// Network.clearBrowserCache — clear the HTTP cache via servo's NetworkManager.
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!({}))
}
/// Network.clearBrowserCookies — clear all cookies via servo's SiteDataManager.
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!({}))
}
/// Storage.getStorageItemsForOrigin — list storage data for an origin.
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 }))
}
/// Storage.clearDataForOrigin — clear storage data for a specific origin.
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!({}))
}
/// Parse CDP storage type string to servo StorageType bitflags.
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");
}
// ─── json_type edge cases ─────────────────────────────────────
// @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
#[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");
}
// ─── json_type_string edge cases ──────────────────────────────
// @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
#[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() {
// NaN and Infinity parse as f64, so they're "number"
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");
}
// ─── to_browser_error edge cases ───────────────────────────────────
// @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
#[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("页面加载失败"));
}
// ─── cmd_navigate/cmd_reload id generation (pure logic) ────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn next_cdp_id_is_unique_and_prefixed() {
// loaderId/script identifiers are generated from a monotonic counter —
// never the hardcoded "0"/"1" constants (Chrome semantics: fresh id
// per load / per added script).
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() {
// frameId = real page id (stable across navigations), loaderId =
// generated per load — no hardcoded "0" constants remain.
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"
);
}
// ─── cmd_evaluate response structure (pure logic) ──────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn cmd_evaluate_return_by_value_true_json_parse() {
// When return_by_value is true and result is valid JSON, it's parsed
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() {
// When return_by_value is true but result is not valid JSON, falls back to json_type_string
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() {
// When return_by_value is false, result uses json_type_string for type
let result_str = "some JS output";
assert_eq!(super::json_type_string(result_str), "string");
}
// ─── cmd_screenshot format mapping (pure logic) ────────────────────
// @trace REQ-CDP-007 [req:REQ-CDP-007] [level:unit]
#[test]
fn cmd_screenshot_format_jpeg_mapping() {
// "jpeg" -> ScreenshotFormat::Jpeg, anything else -> Png
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() {
// Verify base64 encoding produces valid output
let bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
assert!(!b64.is_empty());
// Base64 should be decodable back
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, ""); // empty input -> empty base64
}
// ─── cmd_query_selector JS construction (pure logic) ────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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();
// serde_json should escape the single quotes properly
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("中文类名"));
}
// ─── cmd_query_selector_all JS construction (pure logic) ────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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() {
// When count is 3, nodeIds should be [1, 2, 3]
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);
}
// ─── cmd_set_attribute JS construction (pure logic) ─────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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();
// The double quotes should be escaped in JSON
assert!(json_value.contains("\\\""));
}
// ─── cmd_insert_text JS construction (pure logic) ──────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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"));
}
// ─── cmd_set_user_agent JS construction (pure logic) ───────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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, "\"\"");
}
// ─── cmd_get_document JS template (pure logic) ─────────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[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() {
// The JS template caps children to 20 via Math.min(node.childNodes.length, 20)
let _js = r#"(function() { return Math.min(50, 20); })()"#;
// This is just verifying the logic — 50 children would be capped to 20
assert_eq!(50usize.min(20), 20);
}
// ─── cmd_get_outer_html JS expression (pure logic) ─────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn cmd_get_outer_html_js_is_simple_expression() {
let js = "document.documentElement.outerHTML";
assert!(js.contains("document.documentElement"));
assert!(js.contains("outerHTML"));
}
// ─── cmd_add_script response structure (pure logic) ────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn cmd_add_script_response_has_generated_identifier() {
// identifier comes from the monotonic counter — never "1".
let resp = json!({ "identifier": super::next_cdp_id("script") });
assert!(resp["identifier"].as_str().unwrap().starts_with("script-"));
}
// ─── cmd_reload response structure (pure logic) ────────────────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn cmd_reload_response_structure() {
// cmd_reload uses the real servo reload path (WebView::reload), not a
// re-navigate of the current URL.
let source = include_str!("cdp_handler.rs");
assert!(
source.contains("page.reload().map_err(to_browser_error)?;"),
"cmd_reload must call PageHandle::reload"
);
}
// ─── handle_bridge_command wildcard commands (pure logic) ──────────
// @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
#[test]
fn handle_bridge_command_go_back_forward_wired_stop_loading_errors() {
// GoBack/GoForward dispatch to real servo traversal; StopLoading is an
// explicit error (servo WebView has no stop-loading API).
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"
);
}
// ─── canned-response eradication (source-level guarantees) ─────────
// @trace REQ-CDP-003 [req:REQ-CDP-003] [level:unit]
#[test]
fn no_canned_successes_remain_in_dispatch() {
// Every audited canned success is replaced by either a real path or an
// explicit error. The literals below must never reappear.
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() {
// Real GC path: navigator.servo.GarbageCollectAllContexts() →
// TriggerGarbageCollection → JS_GC on the script thread.
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() {
// The wildcard `_` match returns Err("unsupported bridge command")
let err_msg = "unsupported bridge command";
assert!(!err_msg.is_empty());
}
// ─── json_type_string additional edge cases ────────────────────────
// @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
#[test]
fn json_type_string_leading_dot_is_string() {
// ".5" is not a valid f64 parse in some contexts, but Rust's parse handles it
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() {
// "0x1A" is not a valid f64 parse, so it's "string"
assert_eq!(super::json_type_string("0x1A"), "string");
}
#[test]
fn json_type_string_very_long_number() {
let long_num = "123456789012345678901234567890";
// This parses as f64 (with precision loss), so it's "number"
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");
}
// ─── json_type additional edge cases ───────────────────────────────
// @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
#[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"
);
}
// ─── ServiceWorker registration JSON serialization (REQ-BRW-4 C6/C19, DF-WK-8) ───
// @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:6] [criterion:19]
#[test]
fn sw_registration_id_parses_double_colon_format() {
// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
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() {
// @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:6] [criterion:19] DF-WK-8
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);
// Per DEC-WK-008: fetch interception mode tracked in registry even
// though servo upstream does not dispatch FetchEvent yet.
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);
// Default state is Installing
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() {
// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
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() {
// @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:19] DF-WK-8
// SPEC criterion #19: "terminate 后正确注销"
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());
// terminate() must disable fetch interception
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() {
// @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:19] DF-WK-10
// SPEC: SW must inherit registering page's stealth profile.
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());
// Stealth profile is preserved across registry lookups
let json_val = super::sw_registration_to_json(handle.clone());
// Note: stealth profile is internal state, not serialized to CDP JSON
// (it's used for stealth consistency verification, not CDP reporting)
assert_eq!(json_val["scriptURL"], "sw.js");
assert!(handle.stealth_profile.is_some());
}
#[test]
fn sw_registration_id_format_with_colon_in_url() {
// Edge case: URL containing colon (not double-colon) should parse correctly
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");
}
}