Skip to main content

bao_browser/
cdp_handler.rs

1// @trace REQ-CDP-001  REQ-CDP-003: Bridge handler — routes BridgeCommand to servo WebView operations
2// Runs on the main thread during the event loop to process CDP commands.
3
4use bao_cdp::servo_bridge::{BridgeCommand, BridgeResponse};
5use base64::Engine;
6use serde_json::Value;
7use servo::{CookieSource, StorageType};
8use std::collections::HashSet;
9
10use crate::config::PageConfig;
11use crate::delegate::{
12    ServiceWorkerHandle, ServiceWorkerRegistrationId, ServiceWorkerRegistrationState,
13};
14use crate::error::BrowserError;
15use crate::page::PageHandle;
16use crate::page_pool::PagePool;
17use crate::screenshot::ScreenshotFormat;
18
19/// Process a single bridge command by dispatching to the appropriate page in the pool.
20pub fn handle_bridge_command(cmd: BridgeCommand, pool: &PagePool) -> BridgeResponse {
21    let result = match cmd {
22        // Multi-target management commands — operate on the pool, not a specific page
23        BridgeCommand::CreateTarget { url } => cmd_create_target(pool, &url),
24        BridgeCommand::ListTargets => cmd_list_targets(pool),
25
26        // All other commands require a target_id to look up the page
27        BridgeCommand::Navigate { target_id, url } => {
28            with_page(pool, &target_id, |page| cmd_navigate(page, &url))
29        }
30        BridgeCommand::EvaluateJs {
31            target_id,
32            expression,
33            return_by_value,
34        } => with_page(pool, &target_id, |page| {
35            cmd_evaluate(page, &expression, return_by_value)
36        }),
37        BridgeCommand::TakeScreenshot {
38            target_id,
39            format,
40            quality: _,
41        } => with_page(pool, &target_id, |page| cmd_screenshot(page, &format)),
42        BridgeCommand::GetTitle { target_id } => with_page(pool, &target_id, cmd_get_title),
43        BridgeCommand::GetUrl { target_id } => with_page(pool, &target_id, cmd_get_url),
44        BridgeCommand::GetDocument { target_id } => with_page(pool, &target_id, cmd_get_document),
45        BridgeCommand::QuerySelector {
46            target_id,
47            selector,
48        } => with_page(pool, &target_id, |page| cmd_query_selector(page, &selector)),
49        BridgeCommand::QuerySelectorAll {
50            target_id,
51            selector,
52        } => with_page(pool, &target_id, |page| {
53            cmd_query_selector_all(page, &selector)
54        }),
55        BridgeCommand::GetOuterHtml { target_id, .. } => {
56            with_page(pool, &target_id, cmd_get_outer_html)
57        }
58        BridgeCommand::SetAttributeValue {
59            target_id,
60            node_id: _,
61            name,
62            value,
63        } => with_page(pool, &target_id, |page| {
64            cmd_set_attribute(page, &name, &value)
65        }),
66        BridgeCommand::DispatchMouseEvent {
67            target_id,
68            event_type,
69            x,
70            y,
71            button,
72            click_count,
73        } => with_page(pool, &target_id, |page| {
74            cmd_mouse_event(page, &event_type, x, y, button, click_count)
75        }),
76        BridgeCommand::DispatchKeyEvent {
77            target_id,
78            event_type,
79            key,
80            code,
81            text,
82        } => with_page(pool, &target_id, |page| {
83            cmd_key_event(page, &event_type, &key, &code, text.as_deref())
84        }),
85        BridgeCommand::InsertText { target_id, text } => {
86            with_page(pool, &target_id, |page| cmd_insert_text(page, &text))
87        }
88        BridgeCommand::SetViewport {
89            target_id,
90            width,
91            height,
92            device_scale_factor: _,
93        } => with_page(pool, &target_id, |page| {
94            cmd_set_viewport(page, width, height)
95        }),
96        BridgeCommand::SetUserAgent {
97            target_id,
98            user_agent,
99        } => with_page(pool, &target_id, |page| {
100            cmd_set_user_agent(page, &user_agent)
101        }),
102        BridgeCommand::AddScriptToEvaluateOnNewDocument { target_id, source } => {
103            with_page(pool, &target_id, |page| cmd_add_script(page, &source))
104        }
105        BridgeCommand::Reload {
106            target_id,
107            ignore_cache: _,
108        } => with_page(pool, &target_id, cmd_reload),
109        BridgeCommand::GoBack { target_id } => with_page(pool, &target_id, cmd_go_back),
110        BridgeCommand::GoForward { target_id } => with_page(pool, &target_id, cmd_go_forward),
111        // servo WebView exposes no stop-loading API (load cancellation is not
112        // part of the embedding surface) — explicit error, never a fake ok.
113        BridgeCommand::StopLoading { .. } => Err(
114            "Page.stopLoading not supported: servo WebView has no stop-loading API".into(),
115        ),
116        BridgeCommand::ClosePage { target_id } => {
117            let id = parse_target_id(&target_id);
118            match id {
119                Some(id) => {
120                    let _ = pool.close_page(id);
121                    Ok(serde_json::json!({}))
122                }
123                None => Err(format!("invalid target_id: {target_id}")),
124            }
125        }
126        // Cookie commands — bridge to servo SiteDataManager
127        BridgeCommand::GetCookies { target_id, urls } => {
128            with_page(pool, &target_id, |page| cmd_get_cookies(page, &urls))
129        }
130        BridgeCommand::GetAllCookies { target_id } => {
131            with_page(pool, &target_id, cmd_get_all_cookies)
132        }
133        BridgeCommand::DeleteCookie {
134            target_id,
135            name,
136            url,
137        } => with_page(pool, &target_id, |page| {
138            cmd_delete_cookie(page, &name, url.as_deref())
139        }),
140        BridgeCommand::SetCookie {
141            target_id,
142            name,
143            value,
144            url,
145            domain,
146        } => with_page(pool, &target_id, |page| {
147            cmd_set_cookie(page, &name, &value, url.as_deref(), domain.as_deref())
148        }),
149        // servo does not store network response bodies for embedder access —
150        // explicit error instead of an empty-body fake success.
151        BridgeCommand::GetResponseBody { .. } => Err(
152            "Network.getResponseBody not supported: servo does not expose stored response bodies to the embedder".into(),
153        ),
154
155        // Network domain — cache/cookies clearing, enable/disable
156        BridgeCommand::NetworkEnable { .. } => ok_empty(),
157        BridgeCommand::NetworkDisable { .. } => ok_empty(),
158        BridgeCommand::NetworkSetCacheDisabled {
159            target_id,
160            cache_disabled,
161        } => with_page(pool, &target_id, |page| {
162            cmd_network_set_cache_disabled(page, cache_disabled)
163        }),
164        // servo WebView has no per-target extra-headers injection API —
165        // explicit error instead of silently dropping the headers.
166        BridgeCommand::NetworkSetExtraHTTPHeaders { .. } => Err(
167            "Network.setExtraHTTPHeaders not supported: servo WebView has no extra-headers injection API".into(),
168        ),
169        BridgeCommand::NetworkClearBrowserCache { target_id } => {
170            with_page(pool, &target_id, cmd_network_clear_browser_cache)
171        }
172        BridgeCommand::NetworkClearBrowserCookies { target_id } => {
173            with_page(pool, &target_id, cmd_network_clear_browser_cookies)
174        }
175
176        // Storage domain — origin-scoped storage queries and clearing
177        BridgeCommand::StorageGetStorageItemsForOrigin {
178            target_id,
179            origin,
180            storage_type,
181        } => with_page(pool, &target_id, |page| {
182            cmd_storage_get_items(page, origin, storage_type)
183        }),
184        BridgeCommand::StorageClearDataForOrigin {
185            target_id,
186            origin,
187            storage_type,
188        } => with_page(pool, &target_id, |page| {
189            cmd_storage_clear_data(page, origin, storage_type)
190        }),
191
192        // Security domain — enable/disable/certificate override
193        BridgeCommand::SecurityEnable { .. } => ok_empty(),
194        BridgeCommand::SecurityDisable { .. } => ok_empty(),
195        // Certificate-error override is startup-only (BaoConfig.ignore_certificate_errors
196        // → servo opts, read by the connector at init). No runtime per-target
197        // override face exists — explicit error, never a silent no-op ok.
198        BridgeCommand::SecuritySetOverrideCertificateErrors { .. } => Err(
199            "Security.setOverrideCertificateErrors not supported at runtime: certificate-error override is startup-only (BaoConfig.ignore_certificate_errors)".into(),
200        ),
201
202        // Debugger domain — route through EvaluateJs to servo's debugger.js
203        // These BridgeCommands are typed (no JS string injection from CDP layer).
204        // cdp_handler translates them into servo debugger.js control messages.
205        // @trace BUG-CDP-006 [domain:Debugger]: current path is EvaluateJs →
206        // servo debugger.js. A future enhancement is direct routing via
207        // DevtoolScriptControlMsg once servo's devtools channel is exposed to Bao.
208        BridgeCommand::DebuggerEnable { target_id } => {
209            with_page(pool, &target_id, |page| cmd_debugger_enable(page))
210        }
211        BridgeCommand::DebuggerDisable { target_id } => {
212            with_page(pool, &target_id, |page| cmd_debugger_disable(page))
213        }
214        BridgeCommand::DebuggerSetBreakpoint {
215            target_id,
216            url,
217            url_regex,
218            line,
219            column,
220        } => with_page(pool, &target_id, |page| {
221            cmd_debugger_set_breakpoint(page, url.as_deref(), url_regex.as_deref(), line, column)
222        }),
223        BridgeCommand::DebuggerRemoveBreakpoint {
224            target_id,
225            breakpoint_id,
226        } => with_page(pool, &target_id, |page| {
227            cmd_debugger_remove_breakpoint(page, &breakpoint_id)
228        }),
229        BridgeCommand::DebuggerInterrupt { target_id } => {
230            with_page(pool, &target_id, |page| cmd_debugger_interrupt(page))
231        }
232        BridgeCommand::DebuggerResume {
233            target_id,
234            step_type,
235        } => with_page(pool, &target_id, |page| {
236            cmd_debugger_resume(page, step_type.as_deref())
237        }),
238        BridgeCommand::DebuggerListFrames { target_id } => {
239            with_page(pool, &target_id, |page| cmd_debugger_list_frames(page))
240        }
241        BridgeCommand::DebuggerGetEnvironment { target_id, .. } => {
242            with_page(pool, &target_id, |page| cmd_debugger_get_environment(page))
243        }
244        BridgeCommand::DebuggerEval {
245            target_id,
246            expression,
247            frame_actor_id: _,
248        } => with_page(pool, &target_id, |page| {
249            cmd_evaluate(page, &expression, true)
250        }),
251        BridgeCommand::DebuggerGetPossibleBreakpoints {
252            target_id,
253            start_script_id,
254        } => with_page(pool, &target_id, |page| {
255            cmd_debugger_get_possible_breakpoints(page, &start_script_id)
256        }),
257        BridgeCommand::DebuggerGetScriptSource {
258            target_id,
259            script_id,
260        } => with_page(pool, &target_id, |page| {
261            cmd_debugger_get_script_source(page, script_id)
262        }),
263        BridgeCommand::DebuggerBlackbox { target_id, .. } => {
264            with_page(pool, &target_id, |page| cmd_debugger_blackbox(page))
265        }
266        BridgeCommand::DebuggerUnblackbox { target_id, .. } => {
267            with_page(pool, &target_id, |page| cmd_debugger_unblackbox(page))
268        }
269        // ── Profiler commands ──
270        // mozjs FFI exposes no SpiderMonkey sampling-profiler hooks
271        // (SPS/GekkoProfiler are not wrapped) — explicit error, never a fake
272        // empty profile.
273        BridgeCommand::ProfilerStart { .. }
274        | BridgeCommand::ProfilerStop { .. }
275        | BridgeCommand::ProfilerSetSamplingInterval { .. } => Err(
276            "Profiler not supported: SpiderMonkey sampling profiler is not exposed through the mozjs FFI surface".into(),
277        ),
278        // ── HeapProfiler commands ──
279        // mozjs FFI exposes no heap-snapshot serializer — explicit error.
280        BridgeCommand::HeapProfilerTakeSnapshot { .. }
281        | BridgeCommand::HeapProfilerStartTracking { .. }
282        | BridgeCommand::HeapProfilerStopTracking { .. } => Err(
283            "HeapProfiler snapshot/tracking not supported: mozjs FFI exposes no heap-snapshot API".into(),
284        ),
285        // collectGarbage IS real: servo exposes navigator.servo.GarbageCollectAllContexts()
286        // → ScriptToConstellationMessage::TriggerGarbageCollection → JS_GC on
287        // the script thread (the only thread allowed to touch the JSContext).
288        BridgeCommand::HeapProfilerCollectGarbage { target_id } => {
289            with_page(pool, &target_id, cmd_collect_garbage)
290        }
291        // ── Memory commands ──
292        // jsEventListeners is not introspectable in SpiderMonkey — explicit
293        // error rather than a zeroed counters object.
294        BridgeCommand::MemoryGetDOMCounters { .. } => Err(
295            "Memory.getDOMCounters not supported: jsEventListeners count is not introspectable in SpiderMonkey".into(),
296        ),
297        BridgeCommand::MemoryPurgeJS { target_id } => {
298            with_page(pool, &target_id, cmd_collect_garbage)
299        }
300        // ── Performance commands ──
301        BridgeCommand::PerformanceGetMetrics { target_id } => {
302            with_page(pool, &target_id, cmd_performance_get_metrics)
303        }
304
305        // ── CSS domain commands — JS evaluate for computed/matched/inline styles ──
306        BridgeCommand::CssGetComputedStyleForNode { target_id, node_id } => {
307            with_page(pool, &target_id, |page| {
308                cmd_css_get_computed_style(page, node_id)
309            })
310        }
311        BridgeCommand::CssGetMatchedStylesForNode { target_id, node_id } => {
312            with_page(pool, &target_id, |page| {
313                cmd_css_get_matched_styles(page, node_id)
314            })
315        }
316        BridgeCommand::CssGetInlineStylesForNode { target_id, node_id } => {
317            with_page(pool, &target_id, |page| {
318                cmd_css_get_inline_styles(page, node_id)
319            })
320        }
321
322        // ── Runtime domain commands — JS evaluate for object inspection and function calls ──
323        BridgeCommand::RuntimeGetProperties {
324            target_id,
325            object_id,
326            own_properties,
327        } => with_page(pool, &target_id, |page| {
328            cmd_runtime_get_properties(page, &object_id, own_properties)
329        }),
330        BridgeCommand::RuntimeCallFunctionOn {
331            target_id,
332            object_id,
333            execution_context_id,
334            function_declaration,
335            arguments,
336            return_by_value,
337            await_promise,
338            object_group,
339        } => with_page(pool, &target_id, |page| {
340            cmd_runtime_call_function_on(
341                page,
342                object_id.as_deref(),
343                execution_context_id,
344                &function_declaration,
345                arguments.as_ref(),
346                return_by_value,
347                await_promise,
348                object_group.as_deref(),
349            )
350        }),
351        BridgeCommand::RuntimeReleaseObject {
352            target_id,
353            object_id,
354        } => with_page(pool, &target_id, |page| {
355            cmd_runtime_release_object(page, &object_id)
356        }),
357        BridgeCommand::RuntimeReleaseObjectGroup {
358            target_id,
359            object_group,
360        } => with_page(pool, &target_id, |page| {
361            cmd_runtime_release_object_group(page, &object_group)
362        }),
363
364        // ServiceWorker domain — terminate a registered ServiceWorker
365        // @trace REQ-BRW-004 [entity:ServiceWorker]
366        BridgeCommand::TerminateServiceWorker {
367            target_id,
368            registration_id,
369        } => with_page(pool, &target_id, |page| {
370            cmd_terminate_service_worker(page, &registration_id)
371        }),
372
373        // Worker/ServiceWorker target management — CDP Target domain for Workers
374        // @trace REQ-BRW-004 [entity:Worker] [entity:ServiceWorker]
375        BridgeCommand::ListWorkerTargets { target_id } => {
376            with_page(pool, &target_id, |page| {
377                // Real worker registry: the per-webview scope tables populated
378                // by Worker construction (DEC-WK-001 native path). Every entry
379                // is a live Dedicated/Shared Worker owned by this page — no
380                // synthetic worker-N ids.
381                let state = page.webview_state();
382                let st = state.borrow();
383                let mut workers: Vec<Value> = st
384                    .dedicated_worker_scopes()
385                    .into_iter()
386                    .map(|scope| worker_target_json(&scope.worker_id.0, "worker"))
387                    .collect();
388                workers.extend(
389                    st.shared_worker_scopes()
390                        .into_iter()
391                        .map(|scope| worker_target_json(&scope.shared_worker_id.script_url, "shared_worker")),
392                );
393                Ok(serde_json::json!({ "workerTargets": workers }))
394            })
395        }
396        BridgeCommand::GetWorkerTargetInfo {
397            target_id,
398            worker_id,
399        } => with_page(pool, &target_id, |page| {
400            // Real registry lookup: the worker id must identify a registered
401            // Dedicated/Shared Worker scope — unknown ids are an explicit
402            // error, never a fabricated TargetInfo.
403            let state = page.webview_state();
404            let st = state.borrow();
405            if let Some(scope) = st.dedicated_worker_scope_by_url(&worker_id) {
406                return Ok(serde_json::json!({
407                    "targetInfo": worker_target_json(&scope.worker_id.0, "worker")
408                }));
409            }
410            if let Some(scope) = st.shared_worker_scope_by_script_url(&worker_id) {
411                return Ok(serde_json::json!({
412                    "targetInfo": worker_target_json(
413                        &scope.shared_worker_id.script_url,
414                        "shared_worker"
415                    )
416                }));
417            }
418            Err(format!("unknown worker targetId: {worker_id}"))
419        }),
420        BridgeCommand::ListServiceWorkerRegistrations { target_id } => {
421            with_page(pool, &target_id, |page| {
422                // Real per-webview registry: the page's controlling
423                // ServiceWorker (BaoWebViewState.controlled_service_worker).
424                // Empty list means no registration — real state, not a stub.
425                let state = page.webview_state();
426                let st = state.borrow();
427                let registrations: Vec<Value> = st
428                    .controlling_service_worker()
429                    .map(|h| sw_registration_to_json(h.clone()))
430                    .into_iter()
431                    .collect();
432                Ok(serde_json::json!({ "registrations": registrations }))
433            })
434        }
435        BridgeCommand::GetServiceWorkerRegistrationInfo {
436            target_id,
437            registration_id,
438        } => {
439            with_page(pool, &target_id, |page| {
440                cmd_sw_registration_info(page, &registration_id)
441            })
442        }
443        BridgeCommand::StopServiceWorker {
444            target_id,
445            registration_id,
446        } => with_page(pool, &target_id, |page| {
447            cmd_terminate_service_worker(page, &registration_id)
448        }),
449    };
450    BridgeResponse { result }
451}
452
453/// Parse a string target_id into a usize page ID.
454fn parse_target_id(target_id: &str) -> Option<usize> {
455    target_id.parse::<usize>().ok()
456}
457
458/// Look up a page by target_id string and execute the closure with it.
459fn with_page<F>(pool: &PagePool, target_id: &str, f: F) -> Result<Value, String>
460where
461    F: FnOnce(&PageHandle) -> Result<Value, String>,
462{
463    let id = parse_target_id(target_id).ok_or_else(|| format!("invalid target_id: {target_id}"))?;
464    let page = pool
465        .get_page(id)
466        .ok_or_else(|| format!("page not found: {target_id}"))?;
467    f(&page)
468}
469
470/// Parse CDP registrationId into ServiceWorkerRegistrationId.
471///
472/// Format: "script_url::scope" (double-colon separator).
473/// Example: "sw.js::/" or "https://example.com/sw.js::/app/"
474///
475/// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
476fn parse_sw_registration_id(registration_id: &str) -> Result<ServiceWorkerRegistrationId, String> {
477    let parts: Vec<&str> = registration_id.splitn(2, "::").collect();
478    if parts.len() != 2 {
479        return Err(format!(
480            "invalid registrationId format: {registration_id} (expected 'script_url::scope')"
481        ));
482    }
483    Ok(ServiceWorkerRegistrationId {
484        script_url: parts[0].to_string(),
485        scope: parts[1].to_string(),
486    })
487}
488
489/// Serialize ServiceWorkerHandle to CDP JSON format.
490///
491/// @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8 C6
492fn sw_registration_to_json(handle: ServiceWorkerHandle) -> Value {
493    // Per DEC-WK-008: fetch interception mode is tracked but servo upstream
494    // does not dispatch FetchEvent yet. CDP exposes the mode regardless.
495    let is_active = handle.registration_state() == ServiceWorkerRegistrationState::Activated;
496    serde_json::json!({
497        "registrationId": format!("{}::{}", handle.script_url, handle.scope),
498        "scriptURL": handle.script_url,
499        "scope": handle.scope,
500        "state": match handle.registration_state() {
501            ServiceWorkerRegistrationState::Idle => "idle",
502            ServiceWorkerRegistrationState::Installing => "installing",
503            ServiceWorkerRegistrationState::Installed => "installed",
504            ServiceWorkerRegistrationState::Activating => "activating",
505            ServiceWorkerRegistrationState::Activated => "activated",
506            ServiceWorkerRegistrationState::Redundant => "redundant",
507        },
508        "isFetchIntercepting": handle.is_intercepting_fetch(),
509        "isActive": is_active,
510    })
511}
512
513fn cmd_create_target(pool: &PagePool, url: &str) -> Result<Value, String> {
514    let config = PageConfig {
515        url: if url.is_empty() {
516            None
517        } else {
518            Some(url.to_string())
519        },
520        ..Default::default()
521    };
522    let page = pool.create_page(&config).map_err(|e| format!("{e}"))?;
523    let page_id = page.id();
524    Ok(serde_json::json!({ "targetId": page_id.to_string() }))
525}
526
527fn cmd_list_targets(pool: &PagePool) -> Result<Value, String> {
528    // Real enumeration: every tracked page with its live title/url. Shape is
529    // the array form ServoTargetProvider::list_targets parses ({id,title,url}
530    // entries) — the previous {"targetIds": [...]} object never matched the
531    // provider, silently forcing the single-target fallback path.
532    let stats = pool.stats();
533    let mut targets: Vec<Value> = Vec::new();
534    for id in 1..=(stats.active + stats.idle) {
535        if let Some(page) = pool.get_page(id) {
536            targets.push(serde_json::json!({
537                "id": id.to_string(),
538                "title": page.page_title().unwrap_or_default(),
539                "url": page.current_url().unwrap_or_else(|| "about:blank".into()),
540            }));
541        }
542    }
543    Ok(serde_json::json!(targets))
544}
545
546/// CDP TargetInfo JSON for a Worker sub-target. `target_id` is the Worker's
547/// script URL (the WorkerId) — the real registry key, not a synthetic index.
548/// @trace REQ-BRW-004 [entity:Worker] [entity:SharedWorker] [criterion:19]
549fn worker_target_json(target_id: &str, target_type: &str) -> Value {
550    serde_json::json!({
551        "targetId": target_id,
552        "type": target_type,
553        "title": target_id,
554        "url": target_id,
555        "attached": false,
556    })
557}
558
559fn to_browser_error(e: BrowserError) -> String {
560    format!("{e}")
561}
562
563/// Monotonic id source for CDP loaderId / script identifiers.
564///
565/// Chrome semantics: frameId is stable across navigations (we use the page id),
566/// loaderId is fresh per load. A monotonic counter yields genuinely unique,
567/// non-repeating ids — never a hardcoded constant.
568fn next_cdp_id(prefix: &str) -> String {
569    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
570    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
571    format!("{prefix}-{n:016x}")
572}
573
574fn cmd_navigate(page: &PageHandle, url: &str) -> Result<Value, String> {
575    page.navigate(url).map_err(to_browser_error)?;
576    Ok(serde_json::json!({
577        "frameId": page.id().to_string(),
578        "loaderId": next_cdp_id("loader"),
579    }))
580}
581
582fn cmd_evaluate(
583    page: &PageHandle,
584    expression: &str,
585    return_by_value: bool,
586) -> Result<Value, String> {
587    // Web-scope evaluation (REQ-SEC-002/003): CDP Runtime.evaluate is the
588    // page's DevTools console — it must run in the Page Realm WITHOUT Node
589    // API injection. (The privileged evaluate_js face is bao-internal only
590    // and additionally does not survive navigation.)
591    let result = page.evaluate_js_web(expression).map_err(to_browser_error)?;
592    if return_by_value {
593        let parsed: Result<Value, _> = serde_json::from_str(&result);
594        let (value_type, value) = match parsed {
595            Ok(v) => (json_type(&v), v),
596            Err(_) => (json_type_string(&result), serde_json::json!(result)),
597        };
598        Ok(serde_json::json!({
599            "result": {
600                "type": value_type,
601                "value": value,
602            },
603            "exceptionDetails": null
604        }))
605    } else {
606        // returnByValue=false: hand back a full RemoteObject with a
607        // registry-pinned objectId. This is the Playwright evaluateHandle
608        // path — the utilityScript handle is minted here and then driven via
609        // Runtime.callFunctionOn (objectId roundtrip).
610        page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
611            .map_err(to_browser_error)?;
612        let expr_json = serde_json::to_string(expression).unwrap_or_default();
613        let js = format!(
614            r#"(function() {{
615                try {{
616                    var r = eval({expr_json});
617                    return JSON.stringify({{ result: window.__bao_cdp.wrap(r, false, ''), exceptionDetails: null }});
618                }} catch (e) {{
619                    var exObj = (e !== null && typeof e === 'object') ? window.__bao_cdp.wrap(e, false, '') : undefined;
620                    return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((e && e.message) || e), exception: exObj, exceptionId: 0 }} }});
621                }}
622            }})()"#,
623        );
624        let out = page.evaluate_js_web(&js).map_err(to_browser_error)?;
625        serde_json::from_str(&out).map_err(|e| {
626            format!("Runtime.evaluate: handle wrapper unparseable: {e} (got: {out:.200})")
627        })
628    }
629}
630
631fn cmd_screenshot(page: &PageHandle, format: &str) -> Result<Value, String> {
632    let fmt = match format {
633        "jpeg" => ScreenshotFormat::Jpeg,
634        "webp" => ScreenshotFormat::WebP,
635        _ => ScreenshotFormat::Png,
636    };
637    let bytes = page.take_screenshot(fmt).map_err(to_browser_error)?;
638    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
639    Ok(serde_json::json!({ "data": b64 }))
640}
641
642fn cmd_get_title(page: &PageHandle) -> Result<Value, String> {
643    let title = page.page_title().unwrap_or_default();
644    Ok(serde_json::json!(title))
645}
646
647fn cmd_get_url(page: &PageHandle) -> Result<Value, String> {
648    let url = page.current_url().unwrap_or_else(|| "about:blank".into());
649    Ok(serde_json::json!(url))
650}
651
652fn cmd_get_document(page: &PageHandle) -> Result<Value, String> {
653    // Use evaluate_js to extract DOM structure via JS
654    let js = r#"
655        (function() {
656            function walk(node, id) {
657                var result = {
658                    nodeId: id,
659                    backendNodeId: id,
660                    nodeType: node.nodeType,
661                    nodeName: node.nodeName,
662                    localName: node.localName || '',
663                    nodeValue: node.nodeValue || '',
664                };
665                if (node.childNodes && node.childNodes.length > 0) {
666                    result.childNodeCount = node.childNodes.length;
667                    result.children = [];
668                    for (var i = 0; i < Math.min(node.childNodes.length, 20); i++) {
669                        result.children.push(walk(node.childNodes[i], id * 100 + i + 1));
670                    }
671                }
672                return result;
673            }
674            return JSON.stringify(walk(document, 1));
675        })()
676    "#;
677    let doc_str = page.evaluate_js(js).map_err(to_browser_error)?;
678    let doc_val: Value = serde_json::from_str(&doc_str).unwrap_or_else(|_| serde_json::json!({}));
679    Ok(serde_json::json!({ "root": doc_val }))
680}
681
682fn cmd_query_selector(page: &PageHandle, selector: &str) -> Result<Value, String> {
683    let js = format!(
684        "(function() {{ var e = document.querySelector({}); return e ? 1 : 0; }})()",
685        serde_json::to_string(selector).unwrap_or_default()
686    );
687    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
688    let node_id: i64 = result.trim().parse().unwrap_or(0);
689    Ok(serde_json::json!({ "nodeId": node_id }))
690}
691
692fn cmd_query_selector_all(page: &PageHandle, selector: &str) -> Result<Value, String> {
693    let js = format!(
694        "(function() {{ return document.querySelectorAll({}).length; }})()",
695        serde_json::to_string(selector).unwrap_or_default()
696    );
697    let count_str = page.evaluate_js(&js).map_err(to_browser_error)?;
698    let count: i64 = count_str.trim().parse().unwrap_or(0);
699    let ids: Vec<i64> = (1..=count).collect();
700    Ok(serde_json::json!({ "nodeIds": ids }))
701}
702
703fn cmd_get_outer_html(page: &PageHandle) -> Result<Value, String> {
704    let js = "document.documentElement.outerHTML";
705    let html = page.evaluate_js(js).map_err(to_browser_error)?;
706    Ok(serde_json::json!({ "outerHTML": html }))
707}
708
709fn cmd_set_attribute(page: &PageHandle, name: &str, value: &str) -> Result<Value, String> {
710    let js = format!(
711        "(function() {{ document.querySelector('[data-cdp]')?.setAttribute({}, {}); }})()",
712        serde_json::to_string(name).unwrap_or_default(),
713        serde_json::to_string(value).unwrap_or_default(),
714    );
715    let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
716    Ok(serde_json::json!({}))
717}
718
719fn cmd_mouse_event(
720    _page: &PageHandle,
721    _event_type: &str,
722    _x: f64,
723    _y: f64,
724    _button: Option<i64>,
725    _click_count: Option<i64>,
726) -> Result<Value, String> {
727    // Mouse event dispatch through servo requires InputEvent API
728    // For now, acknowledge the command
729    Ok(serde_json::json!({}))
730}
731
732fn cmd_key_event(
733    _page: &PageHandle,
734    _event_type: &str,
735    _key: &str,
736    _code: &str,
737    _text: Option<&str>,
738) -> Result<Value, String> {
739    Ok(serde_json::json!({}))
740}
741
742fn cmd_insert_text(page: &PageHandle, text: &str) -> Result<Value, String> {
743    let js = format!(
744        "(function() {{ var el = document.activeElement; if (el && 'value' in el) el.value += {}; }})()",
745        serde_json::to_string(text).unwrap_or_default(),
746    );
747    let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
748    Ok(serde_json::json!({}))
749}
750
751fn cmd_set_viewport(_page: &PageHandle, _width: u32, _height: u32) -> Result<Value, String> {
752    // Viewport resize requires re-creating the rendering context
753    Ok(serde_json::json!({}))
754}
755
756fn cmd_set_user_agent(page: &PageHandle, ua: &str) -> Result<Value, String> {
757    let js = format!(
758        "Object.defineProperty(navigator, 'userAgent', {{ get: function() {{ return {}; }} }});",
759        serde_json::to_string(ua).unwrap_or_default(),
760    );
761    let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
762    Ok(serde_json::json!({}))
763}
764
765fn cmd_add_script(page: &PageHandle, source: &str) -> Result<Value, String> {
766    // Real navigation replay: the script is registered on the page's servo
767    // UserContentManager and re-executed by the script thread on every future
768    // document load. Additionally applied to the current document so it is
769    // observable without a reload (a superset of Chrome's new-documents-only
770    // semantics — both executions are real).
771    page.add_script_to_evaluate_on_new_document(source)
772        .map_err(to_browser_error)?;
773    // Web-scope for the immediate application (REQ-SEC-002/003): the script
774    // is a page-level init script, not privileged bao code.
775    let _ = page.evaluate_js_web(source).map_err(to_browser_error)?;
776    Ok(serde_json::json!({ "identifier": next_cdp_id("script") }))
777}
778
779/// Page.reload — real servo reload (WebView::reload), not a re-navigate.
780fn cmd_reload(page: &PageHandle) -> Result<Value, String> {
781    page.reload().map_err(to_browser_error)?;
782    Ok(serde_json::json!({
783        "frameId": page.id().to_string(),
784        "loaderId": next_cdp_id("loader"),
785    }))
786}
787
788/// Page.goBack — real servo session-history traversal (WebView::go_back).
789fn cmd_go_back(page: &PageHandle) -> Result<Value, String> {
790    if !page.can_go_back() {
791        return Err("cannot go back: no previous entry in session history".into());
792    }
793    page.go_back().map_err(to_browser_error)?;
794    Ok(serde_json::json!({ "frameId": page.id().to_string() }))
795}
796
797/// Page.goForward — real servo session-history traversal (WebView::go_forward).
798fn cmd_go_forward(page: &PageHandle) -> Result<Value, String> {
799    if !page.can_go_forward() {
800        return Err("cannot go forward: no forward entry in session history".into());
801    }
802    page.go_forward().map_err(to_browser_error)?;
803    Ok(serde_json::json!({ "frameId": page.id().to_string() }))
804}
805
806/// HeapProfiler.collectGarbage / Memory.forciblyPurgeJavaScriptMemory —
807/// triggers a real full GC on the page's script thread via servo's
808/// `navigator.servo.GarbageCollectAllContexts()` DOM API
809/// (ScriptToConstellationMessage::TriggerGarbageCollection → JS_GC).
810fn cmd_collect_garbage(page: &PageHandle) -> Result<Value, String> {
811    page.evaluate_js_web("navigator.servo.GarbageCollectAllContexts()")
812        .map_err(to_browser_error)?;
813    Ok(serde_json::json!({}))
814}
815
816/// Performance.getMetrics — real values via a single page evaluation.
817/// Only metrics that are truly computable are reported (Chrome's full set
818/// includes LayoutCount/RecalcStyleCount/JSEventListeners which have no
819/// SpiderMonkey/servo equivalent — omitted rather than zero-filled).
820fn cmd_performance_get_metrics(page: &PageHandle) -> Result<Value, String> {
821    let js = r#"(function() {
822        return JSON.stringify({
823            Timestamp: Date.now(),
824            Documents: 1 + window.frames.length,
825            Frames: 1 + window.frames.length,
826            Nodes: document.getElementsByTagName('*').length
827        });
828    })()"#;
829    let result = page.evaluate_js(js).map_err(to_browser_error)?;
830    let v: Value = serde_json::from_str(result.trim())
831        .map_err(|e| format!("Performance.getMetrics: page did not return JSON: {e}"))?;
832    let metrics: Vec<Value> = v
833        .as_object()
834        .map(|o| {
835            o.iter()
836                .map(|(name, val)| serde_json::json!({ "name": name, "value": val }))
837                .collect()
838        })
839        .unwrap_or_default();
840    Ok(serde_json::json!({ "metrics": metrics }))
841}
842
843/// ServiceWorker.terminateWorker / ServiceWorker.stopWorker — terminate the
844/// page's controlling ServiceWorker when the registration id matches.
845/// @trace REQ-BRW-004 [entity:ServiceWorker] DF-WK-8
846fn cmd_terminate_service_worker(
847    page: &PageHandle,
848    registration_id: &str,
849) -> Result<Value, String> {
850    let id = parse_sw_registration_id(registration_id)?;
851    let state = page.webview_state();
852    let mut st = state.borrow_mut();
853    let is_match = st
854        .controlling_service_worker()
855        .map(|h| h.script_url == id.script_url && h.scope == id.scope)
856        .unwrap_or(false);
857    if !is_match {
858        return Err(format!(
859            "no controlling ServiceWorker registration '{registration_id}'"
860        ));
861    }
862    if let Some(handle) = st.controlling_service_worker() {
863        handle.terminate();
864    }
865    st.clear_controlling_service_worker();
866    Ok(serde_json::json!({}))
867}
868
869/// ServiceWorker.getRegistration — read the page's controlling ServiceWorker.
870/// @trace REQ-BRW-004 [entity:ServiceWorker] DF-WK-8
871fn cmd_sw_registration_info(
872    page: &PageHandle,
873    registration_id: &str,
874) -> Result<Value, String> {
875    let id = parse_sw_registration_id(registration_id)?;
876    let state = page.webview_state();
877    let st = state.borrow();
878    match st.controlling_service_worker() {
879        Some(handle) if handle.script_url == id.script_url && handle.scope == id.scope => {
880            Ok(serde_json::json!({
881                "registration": sw_registration_to_json(handle.clone())
882            }))
883        }
884        _ => Err(format!(
885            "no controlling ServiceWorker registration '{registration_id}'"
886        )),
887    }
888}
889
890// ---------------------------------------------------------------------------
891// Debugger domain commands — servo debugger.js bridge
892// ---------------------------------------------------------------------------
893
894/// JS that sets up servo's built-in SpiderMonkey Debugger instance.
895/// Unlike the old approach (96-line JS injection with __bao_* flags),
896/// this delegates to servo's existing debugger.js infrastructure via
897/// the DebuggerGlobalScope event system.
898const DEBUGGER_SETUP: &str = r#"
899(function() {
900    if (window.__bao_dbg_active) return;
901    window.__bao_dbg_active = true;
902    try {
903        const dbg = new Debugger();
904        window.__bao_dbg = dbg;
905        dbg.onNewScript = function(script) {
906            const info = JSON.stringify({
907                id: script.id || ('s-' + Date.now()),
908                url: script.url || '',
909                startLine: script.startLine || 0,
910                endLine: script.startLine + (script.lineCount || 1) - 1,
911            });
912            console.log('__BAO_EVT__Debugger.scriptParsed\n' + info);
913        };
914        dbg.onDebuggerStatement = function(frame) {
915            const callFrames = [];
916            let f = frame;
917            let idx = 0;
918            while (f && idx < 100) {
919                const s = f.script;
920                callFrames.push({
921                    callFrameId: 'frame-' + idx + '-' + (s ? s.id : 'x'),
922                    functionName: f.callee ? (f.callee.name || '(anonymous)') : '(anonymous)',
923                    location: { scriptId: s ? String(s.id) : '', lineNumber: 0, columnNumber: 0 },
924                    scopeChain: [{ type: 'local', object: { type: 'object', objectId: 'local-' + idx } }],
925                });
926                f = f.older;
927                idx++;
928            }
929            const paused = JSON.stringify({ callFrames, reason: 'debuggerStatement', hitBreakpoints: [] });
930            console.log('__BAO_EVT__Debugger.paused\n' + paused);
931        };
932        dbg.findScripts().forEach(function(script) {
933            const info = JSON.stringify({
934                id: script.id || ('s-' + Date.now()),
935                url: script.url || '',
936                startLine: script.startLine || 0,
937                endLine: script.startLine + (script.lineCount || 1) - 1,
938            });
939            console.log('__BAO_EVT__Debugger.scriptParsed\n' + info);
940        });
941    } catch(e) {}
942})();
943"#;
944
945fn cmd_debugger_enable(page: &PageHandle) -> Result<Value, String> {
946    let _ = page.evaluate_js(DEBUGGER_SETUP).map_err(to_browser_error)?;
947    Ok(serde_json::json!({}))
948}
949
950fn cmd_debugger_disable(page: &PageHandle) -> Result<Value, String> {
951    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; }";
952    let _ = page.evaluate_js(js).map_err(to_browser_error)?;
953    Ok(serde_json::json!({}))
954}
955
956fn cmd_debugger_set_breakpoint(
957    page: &PageHandle,
958    url: Option<&str>,
959    url_regex: Option<&str>,
960    line: u32,
961    column: Option<u32>,
962) -> Result<Value, String> {
963    let col = column.unwrap_or(0);
964    // Build a script filter: match by url (exact) or urlRegex, fall back to line-range match
965    let url_filter = match (url, url_regex) {
966        (Some(u), _) => format!("s.url === {}", serde_json::to_string(u).unwrap_or_default()),
967        (None, Some(r)) => format!(
968            "new RegExp({}).test(s.url)",
969            serde_json::to_string(r).unwrap_or_default()
970        ),
971        (None, None) => format!("s.startLine <= {line} && {line} <= s.startLine + s.lineCount - 1"),
972    };
973    let js = format!(
974        "(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 '{{}}'; }})()",
975        url_filter = url_filter, line = line, col = col
976    );
977    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
978    parse_js_result(&result)
979}
980
981fn cmd_debugger_remove_breakpoint(page: &PageHandle, breakpoint_id: &str) -> Result<Value, String> {
982    let js = format!(
983        "(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) {{}} }})()",
984        serde_json::to_string(breakpoint_id).unwrap_or_default(),
985        serde_json::to_string(breakpoint_id).unwrap_or_default(),
986        serde_json::to_string(breakpoint_id).unwrap_or_default(),
987    );
988    let _ = page.evaluate_js(&js).map_err(to_browser_error)?;
989    Ok(serde_json::json!({}))
990}
991
992fn cmd_debugger_interrupt(page: &PageHandle) -> Result<Value, String> {
993    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) {} })()";
994    let _ = page.evaluate_js(js).map_err(to_browser_error)?;
995    Ok(serde_json::json!({}))
996}
997
998fn cmd_debugger_resume(page: &PageHandle, step_type: Option<&str>) -> Result<Value, String> {
999    let js = match step_type {
1000        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) {} })()",
1001        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) {} })()",
1002        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) {} })()",
1003        _ => "(function() { /* resume: clear step hooks */ try { if (window.__bao_dbg) { window.__bao_dbg.onEnterFrame = undefined; } } catch(e) {} })()",
1004    };
1005    let _ = page.evaluate_js(js).map_err(to_browser_error)?;
1006    Ok(serde_json::json!({}))
1007}
1008
1009fn cmd_debugger_list_frames(page: &PageHandle) -> Result<Value, String> {
1010    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: []}); } })()";
1011    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1012    parse_js_result(&result)
1013}
1014
1015fn cmd_debugger_get_environment(page: &PageHandle) -> Result<Value, String> {
1016    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 '{}'; } })()";
1017    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1018    parse_js_result(&result)
1019}
1020
1021fn cmd_debugger_get_possible_breakpoints(
1022    page: &PageHandle,
1023    start_script_id: &str,
1024) -> Result<Value, String> {
1025    let filter_by_script = if start_script_id.is_empty() {
1026        "true".to_string()
1027    } else {
1028        format!(
1029            "String(s.id) === {}",
1030            serde_json::to_string(start_script_id).unwrap_or_default()
1031        )
1032    };
1033    let js = format!(
1034        "(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: []}}); }} }})()",
1035        filter = filter_by_script
1036    );
1037    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1038    parse_js_result(&result)
1039}
1040
1041fn cmd_debugger_get_script_source(page: &PageHandle, script_id: u32) -> Result<Value, String> {
1042    let js = format!(
1043        "(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: ''}}); }} }})()",
1044        script_id
1045    );
1046    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1047    parse_js_result(&result)
1048}
1049
1050fn cmd_debugger_blackbox(page: &PageHandle) -> Result<Value, String> {
1051    let _ = page
1052        .evaluate_js("(function() { /* blackbox: not yet supported */ })()")
1053        .map_err(to_browser_error)?;
1054    Ok(serde_json::json!({}))
1055}
1056
1057fn cmd_debugger_unblackbox(page: &PageHandle) -> Result<Value, String> {
1058    let _ = page
1059        .evaluate_js("(function() { /* unblackbox: not yet supported */ })()")
1060        .map_err(to_browser_error)?;
1061    Ok(serde_json::json!({}))
1062}
1063
1064// ---------------------------------------------------------------------------
1065// CSS domain commands — JS evaluate for computed/matched/inline styles
1066// ---------------------------------------------------------------------------
1067
1068/// Resolve a CDP nodeId to a DOM element via JS evaluate.
1069/// nodeId in our CDP implementation maps to a synthetic data-node-id attribute,
1070/// or falls back to traversing the DOM tree by index.
1071fn resolve_node_by_id(page: &PageHandle, node_id: i64) -> Result<String, String> {
1072    if node_id <= 0 {
1073        // nodeId 1 = document, 2 = html element
1074        let js: String = match node_id {
1075            0 | 1 => "document".to_string(),
1076            2 => "document.documentElement".to_string(),
1077            _ => {
1078                let idx = node_id - 3;
1079                format!("document.documentElement.childNodes[{}]", idx)
1080            }
1081        };
1082        let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1083        Ok(result)
1084    } else {
1085        // Try data-node-id attribute first, then fall back to DOM traversal
1086        let js = format!(
1087            "(function() {{ var el = document.querySelector('[data-node-id=\"{}\"]'); if (el) return 'found'; return 'not-found'; }})()",
1088            node_id
1089        );
1090        let found = page.evaluate_js(&js).map_err(to_browser_error)?;
1091        if found.trim() == "found" {
1092            Ok(format!(
1093                "document.querySelector('[data-node-id=\"{}\"]')",
1094                node_id
1095            ))
1096        } else {
1097            // Fall back to body.childNodes traversal
1098            Ok(format!("document.body.childNodes[{}]", node_id - 3))
1099        }
1100    }
1101}
1102
1103fn cmd_css_get_computed_style(page: &PageHandle, node_id: i64) -> Result<Value, String> {
1104    let node_ref = resolve_node_by_id(page, node_id)?;
1105    let js = format!(
1106        r#"(function() {{
1107            var el = {node_ref};
1108            if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"computedStyle": []}});
1109            try {{
1110                var styles = getComputedStyle(el);
1111                var result = [];
1112                for (var i = 0; i < styles.length; i++) {{
1113                    var name = styles[i];
1114                    result.push({{ name: name, value: styles.getPropertyValue(name) }});
1115                }}
1116                return JSON.stringify({{"computedStyle": result}});
1117            }} catch(e) {{
1118                return JSON.stringify({{"computedStyle": []}});
1119            }}
1120        }})()"#,
1121        node_ref = node_ref
1122    );
1123    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1124    parse_js_result(&result)
1125}
1126
1127fn cmd_css_get_matched_styles(page: &PageHandle, node_id: i64) -> Result<Value, String> {
1128    let node_ref = resolve_node_by_id(page, node_id)?;
1129    let js = format!(
1130        r#"(function() {{
1131            var el = {node_ref};
1132            if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"matchedCSSRules": [], "inlineStyle": null, "attributesStyle": null}});
1133            try {{
1134                var rules = [];
1135                var sheets = document.styleSheets;
1136                for (var s = 0; s < sheets.length; s++) {{
1137                    try {{
1138                        var cssRules = sheets[s].cssRules || sheets[s].rules;
1139                        for (var r = 0; r < cssRules.length; r++) {{
1140                            try {{
1141                                if (cssRules[r].selectorText && el.matches(cssRules[r].selectorText)) {{
1142                                    var rule = {{
1143                                        rule: {{
1144                                            selectorList: {{ selectors: [{{ text: cssRules[r].selectorText }}] }},
1145                                            style: {{ cssProperties: [], shorthandEntries: [] }},
1146                                            origin: sheets[s].href ? "regular" : "user-agent",
1147                                            sourceURL: sheets[s].href || ""
1148                                        }},
1149                                        matchingSelectors: [r]
1150                                    }};
1151                                    var decls = cssRules[r].style;
1152                                    for (var d = 0; d < decls.length; d++) {{
1153                                        rule.rule.style.cssProperties.push({{
1154                                            name: decls[d],
1155                                            value: decls.getPropertyValue(decls[d]),
1156                                            important: decls.getPropertyPriority(decls[d]) === "important"
1157                                        }});
1158                                    }}
1159                                    rules.push(rule);
1160                                }}
1161                            }} catch(e2) {{}}
1162                        }}
1163                    }} catch(e1) {{}}
1164                }}
1165                var inlineStyle = null;
1166                if (el.style && el.style.length > 0) {{
1167                    inlineStyle = {{ cssProperties: [], shorthandEntries: [] }};
1168                    for (var i = 0; i < el.style.length; i++) {{
1169                        inlineStyle.cssProperties.push({{
1170                            name: el.style[i],
1171                            value: el.style.getPropertyValue(el.style[i]),
1172                            important: el.style.getPropertyPriority(el.style[i]) === "important"
1173                        }});
1174                    }}
1175                }}
1176                return JSON.stringify({{"matchedCSSRules": rules, "inlineStyle": inlineStyle, "attributesStyle": null}});
1177            }} catch(e) {{
1178                return JSON.stringify({{"matchedCSSRules": [], "inlineStyle": null, "attributesStyle": null}});
1179            }}
1180        }})()"#,
1181        node_ref = node_ref
1182    );
1183    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1184    parse_js_result(&result)
1185}
1186
1187fn cmd_css_get_inline_styles(page: &PageHandle, node_id: i64) -> Result<Value, String> {
1188    let node_ref = resolve_node_by_id(page, node_id)?;
1189    let js = format!(
1190        r#"(function() {{
1191            var el = {node_ref};
1192            if (!el || !el.nodeType || el.nodeType !== 1) return JSON.stringify({{"inlineStyle": null}});
1193            try {{
1194                var inlineStyle = null;
1195                if (el.style && el.style.length > 0) {{
1196                    inlineStyle = {{ cssProperties: [], shorthandEntries: [] }};
1197                    for (var i = 0; i < el.style.length; i++) {{
1198                        inlineStyle.cssProperties.push({{
1199                            name: el.style[i],
1200                            value: el.style.getPropertyValue(el.style[i]),
1201                            important: el.style.getPropertyPriority(el.style[i]) === "important"
1202                        }});
1203                    }}
1204                }}
1205                var attributesStyle = null;
1206                if (el.getAttribute('style')) {{
1207                    attributesStyle = {{ cssProperties: [], shorthandEntries: [] }};
1208                    var styleText = el.getAttribute('style');
1209                    var pairs = styleText.split(';');
1210                    for (var p = 0; p < pairs.length; p++) {{
1211                        var kv = pairs[p].trim();
1212                        if (kv) {{
1213                            var colon = kv.indexOf(':');
1214                            if (colon > 0) {{
1215                                var name = kv.substring(0, colon).trim();
1216                                var value = kv.substring(colon + 1).trim();
1217                                var important = value.endsWith(' !important');
1218                                if (important) value = value.substring(0, value.length - 11).trim();
1219                                attributesStyle.cssProperties.push({{
1220                                    name: name, value: value, important: important
1221                                }});
1222                            }}
1223                        }}
1224                    }}
1225                }}
1226                return JSON.stringify({{"inlineStyle": inlineStyle, "attributesStyle": attributesStyle}});
1227            }} catch(e) {{
1228                return JSON.stringify({{"inlineStyle": null}});
1229            }}
1230        }})()"#,
1231        node_ref = node_ref
1232    );
1233    let result = page.evaluate_js(&js).map_err(to_browser_error)?;
1234    parse_js_result(&result)
1235}
1236
1237// ---------------------------------------------------------------------------
1238// Runtime domain commands — JS evaluate for object inspection and function calls
1239// ---------------------------------------------------------------------------
1240
1241/// JS prelude that installs the page-realm object registry backing the CDP
1242/// Runtime object protocol (evaluate-handle / callFunctionOn / getProperties /
1243/// releaseObject). Idempotent — a second run is a no-op.
1244///
1245/// The registry IS the CDP object reference table: every RemoteObject objectId
1246/// pins a strong reference until `Runtime.releaseObject`/`releaseObjectGroup`
1247/// drops it, which is what keeps handed-out objects GC-alive across
1248/// evaluations. DEVIATION (documented): Chrome keeps this table debugger-side;
1249/// the servo embedder exposes no JSObject egress through the evaluate bridge,
1250/// so the strong refs live in a page-realm `Object.create(null)` map and the
1251/// table is page-visible (acceptable for an automation-facing CDP face).
1252const CDP_REGISTRY_PRELUDE: &str = r#"(function() {
1253  if (window.__bao_cdp) return 'ok';
1254  var objs = Object.create(null);
1255  var seq = 0;
1256  function alloc(v, group) {
1257    seq++;
1258    var oid = 'obj-' + seq + '-' + Math.floor(Math.random() * 1e9).toString(36);
1259    objs[oid] = { v: v, g: group || '' };
1260    return oid;
1261  }
1262  function get(oid) { return (oid in objs) ? objs[oid].v : undefined; }
1263  function release(oid) { delete objs[oid]; }
1264  function releaseGroup(g) { for (var k in objs) { if (objs[k].g === g) delete objs[k]; } }
1265  function wrap(v, byValue, group) {
1266    var ro;
1267    if (v === null) {
1268      ro = { type: 'object', subtype: 'null', description: 'null' };
1269    } else if (v === undefined) {
1270      ro = { type: 'undefined' };
1271    } else {
1272      var t = typeof v;
1273      if (t === 'number') {
1274        var us = null;
1275        if (v !== v) us = 'NaN';
1276        else if (v === Infinity) us = 'Infinity';
1277        else if (v === -Infinity) us = '-Infinity';
1278        else if (v === 0 && 1 / v < 0) us = '-0';
1279        ro = us
1280          ? { type: 'number', unserializableValue: us, description: us }
1281          : { type: 'number', value: v, description: String(v) };
1282      } else if (t === 'string') {
1283        ro = { type: 'string', value: v };
1284      } else if (t === 'boolean') {
1285        ro = { type: 'boolean', value: v };
1286      } else if (t === 'bigint') {
1287        ro = { type: 'bigint', unserializableValue: String(v), description: String(v) + 'n' };
1288      } else if (t === 'symbol') {
1289        ro = { type: 'symbol', description: String(v) };
1290      } else if (t === 'function') {
1291        ro = { type: 'function', className: 'Function', description: (v.name ? 'function ' + v.name + '()' : 'function ()'), objectId: alloc(v, group) };
1292      } else {
1293        var sub;
1294        if (Array.isArray(v)) sub = 'array';
1295        else if (v instanceof Date) sub = 'date';
1296        else if (v instanceof RegExp) sub = 'regexp';
1297        else if (v instanceof Error) sub = 'error';
1298        else if (v === window) sub = 'window';
1299        else if (typeof Node !== 'undefined' && v instanceof Node) sub = 'node';
1300        var desc;
1301        if (sub === 'array') desc = 'Array(' + v.length + ')';
1302        else if (sub === 'date') desc = String(v);
1303        else if (sub === 'regexp') desc = String(v);
1304        else if (sub === 'error') desc = ((v.constructor && v.constructor.name) ? (v.constructor.name + ': ') : '') + (v.message || '');
1305        else if (sub === 'node') { try { desc = v.nodeName.toLowerCase() + (v.id ? '#' + v.id : ''); } catch (e2) { desc = String(v.nodeName); } }
1306        else { try { desc = String(v); } catch (e2) { desc = 'Object'; } }
1307        var cn = 'Object';
1308        try { if (v.constructor && v.constructor.name) cn = v.constructor.name; } catch (e2) {}
1309        ro = { type: 'object', className: cn, description: desc, objectId: alloc(v, group) };
1310        if (sub) ro.subtype = sub;
1311      }
1312    }
1313    if (byValue && v !== null && v !== undefined && (typeof v === 'object' || typeof v === 'function')) {
1314      try { ro.value = v; } catch (e) {}
1315    }
1316    return ro;
1317  }
1318  window.__bao_cdp = { alloc: alloc, get: get, release: release, releaseGroup: releaseGroup, wrap: wrap };
1319  return 'ok';
1320})()"#;
1321
1322/// Resolve a CDP objectId to a JS expression that references the object.
1323/// objectId formats:
1324/// "node-N" → DOM node reference (legacy DOM-domain mapping)
1325/// "obj-*"  → page-realm registry entry (CDP_REGISTRY_PRELUDE table)
1326fn resolve_object_by_id(object_id: &str) -> String {
1327    if object_id.starts_with("node-") {
1328        let idx: i64 = object_id[5..].parse().unwrap_or(0);
1329        match idx {
1330            0 | 1 => "document".to_string(),
1331            2 => "document.documentElement".to_string(),
1332            _ => format!("document.body.childNodes[{}]", idx - 3),
1333        }
1334    } else {
1335        format!(
1336            "window.__bao_cdp.get({})",
1337            serde_json::to_string(object_id).unwrap_or_default()
1338        )
1339    }
1340}
1341
1342fn cmd_runtime_get_properties(
1343    page: &PageHandle,
1344    object_id: &str,
1345    own_properties: Option<bool>,
1346) -> Result<Value, String> {
1347    page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
1348        .map_err(to_browser_error)?;
1349    let obj_ref = resolve_object_by_id(object_id);
1350    let own = own_properties.unwrap_or(true);
1351    // Property enumeration: own=true → getOwnPropertyNames (own properties,
1352    // data + accessors); own=false → for-in (own + inherited enumerables).
1353    // Every property value becomes a real RemoteObject — object/function
1354    // values are registered in the page registry, so the returned objectIds
1355    // roundtrip through callFunctionOn/getProperties (the previous code
1356    // fabricated ids it never stored, and resolving them always gave null).
1357    // Web-scope evaluation per REQ-SEC-002/003 (registry is page-realm).
1358    let js = format!(
1359        r#"(function() {{
1360            try {{
1361                var obj = {obj_ref};
1362                if (obj === null || obj === undefined) return JSON.stringify({{ "result": [] }});
1363                var names;
1364                if ({own}) {{
1365                    names = Object.getOwnPropertyNames(obj);
1366                }} else {{
1367                    names = [];
1368                    for (var n in obj) names.push(n);
1369                }}
1370                var result = [];
1371                for (var i = 0; i < names.length; i++) {{
1372                    var name = names[i];
1373                    try {{
1374                        var desc = Object.getOwnPropertyDescriptor(obj, name);
1375                        if (!desc) continue;
1376                        var entry = {{ name: name, configurable: !!desc.configurable, enumerable: !!desc.enumerable, isOwn: {own} }};
1377                        if ('value' in desc) {{
1378                            entry.value = window.__bao_cdp.wrap(desc.value, false, '');
1379                            entry.writable = !!desc.writable;
1380                        }} else {{
1381                            if (desc.get) entry.get = window.__bao_cdp.wrap(desc.get, false, '');
1382                            if (desc.set) entry.set = window.__bao_cdp.wrap(desc.set, false, '');
1383                        }}
1384                        result.push(entry);
1385                    }} catch (e2) {{
1386                        result.push({{ name: name, value: {{ type: 'undefined' }}, configurable: false, enumerable: false }});
1387                    }}
1388                }}
1389                return JSON.stringify({{ "result": result }});
1390            }} catch (e) {{
1391                return JSON.stringify({{ "result": [] }});
1392            }}
1393        }})()"#,
1394        obj_ref = obj_ref,
1395        own = own,
1396    );
1397    let result = page.evaluate_js_web(&js).map_err(to_browser_error)?;
1398    parse_js_result(&result)
1399}
1400
1401/// Materialize one CDP CallArgument as a JS expression: `{value}` (JSON
1402/// literal), `{unserializableValue}` (NaN/±Infinity/-0/BigInt) or `{objectId}`
1403/// resolved through the page-realm registry (or the DOM node mapping).
1404fn call_argument_expr(arg: &Value) -> Result<String, String> {
1405    let Some(obj) = arg.as_object() else {
1406        return Err("callFunctionOn arguments entries must be CallArgument objects".into());
1407    };
1408    if let Some(v) = obj.get("value") {
1409        return Ok(serde_json::to_string(v).unwrap_or_else(|_| "undefined".into()));
1410    }
1411    if let Some(us) = obj.get("unserializableValue").and_then(|v| v.as_str()) {
1412        return match us {
1413            "NaN" | "Infinity" | "-Infinity" | "-0" => Ok(us.to_string()),
1414            _ if us.ends_with('n') && us.len() >= 2 => {
1415                let digits = us[..us.len() - 1]
1416                    .strip_prefix('-')
1417                    .unwrap_or(&us[..us.len() - 1]);
1418                if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
1419                    // BigInt literal ("123n" / "-5n")
1420                    Ok(us.to_string())
1421                } else {
1422                    Err(format!("unsupported unserializableValue: {us}"))
1423                }
1424            }
1425            _ => Err(format!("unsupported unserializableValue: {us}")),
1426        };
1427    }
1428    if let Some(oid) = obj.get("objectId").and_then(|v| v.as_str()) {
1429        return Ok(resolve_object_by_id(oid));
1430    }
1431    Ok("undefined".to_string())
1432}
1433
1434fn cmd_runtime_call_function_on(
1435    page: &PageHandle,
1436    object_id: Option<&str>,
1437    execution_context_id: Option<i64>,
1438    function_declaration: &str,
1439    arguments: Option<&Value>,
1440    return_by_value: Option<bool>,
1441    await_promise: Option<bool>,
1442    object_group: Option<&str>,
1443) -> Result<Value, String> {
1444    // The page realm's security contract (REQ-SEC-002/003): page-facing CDP
1445    // evaluation runs web-scope — evaluate_js_web, never the Node-realm
1446    // privileged face. The registry must exist before any object reference
1447    // is resolved; installing is idempotent.
1448    page.evaluate_js_web(CDP_REGISTRY_PRELUDE)
1449        .map_err(to_browser_error)?;
1450
1451    // `this` for the call: objectId wins. executionContextId alone means
1452    // this=undefined (single page-realm context — DEVIATION: the servo
1453    // embedder exposes no isolated worlds, so all context ids evaluate
1454    // against the page realm).
1455    let _ctx = execution_context_id; // single-realm: routing is the page itself
1456    let this_expr = match object_id {
1457        Some(oid) => resolve_object_by_id(oid),
1458        None => "undefined".to_string(),
1459    };
1460
1461    // CDP CallArgument materialization ({value} / {unserializableValue} /
1462    // {objectId}).
1463    let mut args_js = String::from("[");
1464    if let Some(Value::Array(arr)) = arguments {
1465        let parts: Vec<String> = arr
1466            .iter()
1467            .map(call_argument_expr)
1468            .collect::<Result<_, _>>()?;
1469        args_js.push_str(&parts.join(", "));
1470    } else if let Some(other) = arguments {
1471        return Err(format!(
1472            "callFunctionOn arguments must be an array, got: {other:.200}"
1473        ));
1474    }
1475    args_js.push(']');
1476
1477    let rbv = return_by_value.unwrap_or(false);
1478    let await_js = await_promise.unwrap_or(false);
1479    let group_json =
1480        serde_json::to_string(object_group.unwrap_or("")).unwrap_or_else(|_| "\"..\"".into());
1481    let func_json = serde_json::to_string(function_declaration).unwrap_or_default();
1482
1483    // functionDeclaration is a stringized function ("function(a, b) { ... }");
1484    // it is called with the materialized arguments on the resolved `this`.
1485    let js = format!(
1486        r#"(function() {{
1487            try {{
1488                var fn = Function('return (' + {func_json} + ')')();
1489                if (typeof fn !== 'function') {{
1490                    return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: 'functionDeclaration did not evaluate to a function', exceptionId: 0 }} }});
1491                }}
1492                var r = fn.apply({this_expr}, {args_js});
1493                if ({await_js} && r !== null && typeof r === 'object' && typeof r.then === 'function') {{
1494                    window.__bao_async = {{ state: 'pending' }};
1495                    Promise.resolve(r).then(
1496                        function(v) {{ window.__bao_async = {{ state: 'ok', v: v }}; }},
1497                        function(e) {{ window.__bao_async = {{ state: 'err', e: e }}; }}
1498                    );
1499                    return JSON.stringify({{ __baoAsync: true }});
1500                }}
1501                return JSON.stringify({{ result: window.__bao_cdp.wrap(r, {rbv}, {group_json}), exceptionDetails: null }});
1502            }} catch (e) {{
1503                var exObj = (e !== null && typeof e === 'object') ? window.__bao_cdp.wrap(e, false, {group_json}) : undefined;
1504                return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((e && e.message) || e), exception: exObj, exceptionId: 0 }} }});
1505            }}
1506        }})()"#,
1507        this_expr = this_expr,
1508        args_js = args_js,
1509        func_json = func_json,
1510        rbv = rbv,
1511        await_js = await_js,
1512        group_json = group_json,
1513    );
1514    let result = page.evaluate_js_web(&js).map_err(to_browser_error)?;
1515    // The wrapper always returns JSON.stringify({result/exceptionDetails}) —
1516    // an unparseable output is a real failure, never a silent {}.
1517    let parsed: Value = serde_json::from_str(&result).map_err(|e| {
1518        format!("Runtime.callFunctionOn: page did not return the wrapper JSON: {e} (got: {result:.200})")
1519    })?;
1520    if parsed.get("__baoAsync").and_then(|v| v.as_bool()) == Some(true) {
1521        return wait_bao_async_promise(page, rbv, &group_json);
1522    }
1523    Ok(parsed)
1524}
1525
1526/// awaitPromise resolution: the call wrapper parked a pending Promise's
1527/// continuation in `window.__bao_async`; poll it. Every `evaluate_js_web`
1528/// spins the servo event loop itself, so microtask/timer/fetch chains keep
1529/// making progress between polls. Bounded — a never-settling promise
1530/// surfaces as an error instead of hanging the bridge worker.
1531fn wait_bao_async_promise(
1532    page: &PageHandle,
1533    return_by_value: bool,
1534    group_json: &str,
1535) -> Result<Value, String> {
1536    let poll = format!(
1537        r#"(function() {{
1538            var a = window.__bao_async;
1539            if (!a || a.state === 'pending') return JSON.stringify({{ pending: true }});
1540            if (a.state === 'ok') return JSON.stringify({{ result: window.__bao_cdp.wrap(a.v, {rbv}, {group_json}), exceptionDetails: null }});
1541            var exObj = (a.e !== null && typeof a.e === 'object') ? window.__bao_cdp.wrap(a.e, false, {group_json}) : undefined;
1542            return JSON.stringify({{ result: {{ type: 'undefined' }}, exceptionDetails: {{ text: String((a.e && a.e.message) || a.e), exception: exObj, exceptionId: 0 }} }});
1543        }})()"#,
1544        rbv = return_by_value,
1545        group_json = group_json,
1546    );
1547    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
1548    loop {
1549        let out = page.evaluate_js_web(&poll).map_err(to_browser_error)?;
1550        let parsed: Value = serde_json::from_str(&out).map_err(|e| {
1551            format!("Runtime.callFunctionOn: async poll wrapper unparseable: {e} (got: {out:.200})")
1552        })?;
1553        if parsed.get("pending").and_then(|v| v.as_bool()) != Some(true) {
1554            return Ok(parsed);
1555        }
1556        if std::time::Instant::now() >= deadline {
1557            return Err(
1558                "Runtime.callFunctionOn: awaitPromise did not settle within 20s".into(),
1559            );
1560        }
1561        std::thread::sleep(std::time::Duration::from_millis(10));
1562    }
1563}
1564
1565/// Runtime.releaseObject — drop one registry entry (frees the strong ref the
1566/// handed-out objectId pinned; guards against evaluateHandle leak loops).
1567fn cmd_runtime_release_object(page: &PageHandle, object_id: &str) -> Result<Value, String> {
1568    let oid_json = serde_json::to_string(object_id).unwrap_or_default();
1569    let js = format!(
1570        r#"(function() {{ if (window.__bao_cdp) window.__bao_cdp.release({oid_json}); return 'ok'; }})()"#
1571    );
1572    page.evaluate_js_web(&js).map_err(to_browser_error)?;
1573    Ok(serde_json::json!({}))
1574}
1575
1576/// Runtime.releaseObjectGroup — drop every registry entry minted under the
1577/// objectGroup (Playwright releases its "utility"/"console" groups on
1578/// context teardown with exactly this call).
1579fn cmd_runtime_release_object_group(
1580    page: &PageHandle,
1581    object_group: &str,
1582) -> Result<Value, String> {
1583    let group_json = serde_json::to_string(object_group).unwrap_or_default();
1584    let js = format!(
1585        r#"(function() {{ if (window.__bao_cdp) window.__bao_cdp.releaseGroup({group_json}); return 'ok'; }})()"#
1586    );
1587    page.evaluate_js_web(&js).map_err(to_browser_error)?;
1588    Ok(serde_json::json!({}))
1589}
1590
1591fn json_type(v: &Value) -> &'static str {
1592    match v {
1593        Value::Null => "undefined",
1594        Value::Bool(_) => "boolean",
1595        Value::Number(_) => "number",
1596        Value::String(_) => "string",
1597        Value::Array(_) => "object",
1598        Value::Object(_) => "object",
1599    }
1600}
1601
1602/// Parse a JS evaluate_js string result into a serde_json::Value.
1603fn parse_js_result(result: &str) -> Result<Value, String> {
1604    // Fail-closed: an unparseable wrapper output is a real failure, never a
1605    // silent {}. (The previous `.unwrap_or(Ok(json!({})))` had a type-level
1606    // bug — serde deserialized into an externally-tagged Result<Value,String>
1607    // envelope, which never matches normal JSON, so EVERY caller silently
1608    // got {} back. BCE: silent-fallback masquerading as delivery.)
1609    serde_json::from_str(result)
1610        .map_err(|e| format!("unparseable JS wrapper output: {e} (got: {result:.200})"))
1611}
1612
1613fn json_type_string(s: &str) -> &'static str {
1614    if s.is_empty() || s == "undefined" {
1615        "undefined"
1616    } else if s == "null" {
1617        "object"
1618    } else if s == "true" || s == "false" {
1619        "boolean"
1620    } else if s.parse::<f64>().is_ok() {
1621        "number"
1622    } else if s.starts_with('{') || s.starts_with('[') {
1623        "object"
1624    } else {
1625        "string"
1626    }
1627}
1628
1629// ─── Network / Cookie / Storage / Security domain handlers ──────────────
1630// Bridge servo's SiteDataManager and NetworkManager to CDP protocol.
1631
1632/// Convert a servo `Cookie<'static>` to CDP Cookie JSON object.
1633/// CDP Cookie spec: https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-Cookie
1634fn cookie_to_cdp(c: &cookie::Cookie) -> Value {
1635    let same_site = match c.same_site() {
1636        Some(cookie::SameSite::Strict) => "Strict",
1637        Some(cookie::SameSite::Lax) => "Lax",
1638        Some(cookie::SameSite::None) => "None",
1639        None => "None",
1640    };
1641    let expires = c
1642        .expires_datetime()
1643        .map(|dt| dt.unix_timestamp() as f64)
1644        .unwrap_or(-1.0);
1645    serde_json::json!({
1646        "name": c.name(),
1647        "value": c.value(),
1648        "domain": c.domain().unwrap_or(""),
1649        "path": c.path().unwrap_or("/"),
1650        "expires": expires,
1651        "size": c.name().len() + c.value().len(),
1652        "httpOnly": c.http_only().unwrap_or(false),
1653        "secure": c.secure().unwrap_or(false),
1654        "sameSite": same_site,
1655        "session": expires == -1.0,
1656    })
1657}
1658
1659/// Build a `cookie::Cookie<'static>` from CDP setCookie parameters.
1660fn cdp_params_to_cookie(
1661    name: &str,
1662    value: &str,
1663    _url: Option<&str>,
1664    domain: Option<&str>,
1665) -> cookie::Cookie<'static> {
1666    let mut builder = cookie::Cookie::build((name.to_string(), value.to_string()));
1667    if let Some(d) = domain {
1668        if d.starts_with('.') {
1669            builder = builder.domain(d.to_string());
1670        } else {
1671            builder = builder.domain(format!(".{d}"));
1672        }
1673    }
1674    builder = builder.path("/");
1675    builder.build()
1676}
1677
1678/// Network.getCookies — retrieve cookies for the given URLs (or current page URL).
1679fn cmd_get_cookies(page: &PageHandle, urls: &[String]) -> Result<Value, String> {
1680    let servo = page.servo();
1681    let sdm = servo.site_data_manager();
1682    let cookies: Vec<Value> = if urls.is_empty() {
1683        // No URLs specified — use the current page URL
1684        let current_url = page.current_url().unwrap_or_default();
1685        if current_url.is_empty() || current_url == "about:blank" {
1686            Vec::new()
1687        } else {
1688            match url::Url::parse(&current_url) {
1689                Ok(parsed) => {
1690                    let servo_cookies = sdm.cookies_for_url(parsed, CookieSource::HTTP);
1691                    servo_cookies.iter().map(cookie_to_cdp).collect()
1692                }
1693                Err(_) => Vec::new(),
1694            }
1695        }
1696    } else {
1697        // Collect cookies for each URL, deduplicating by (name, domain, path)
1698        let mut seen = HashSet::new();
1699        let mut result = Vec::new();
1700        for url_str in urls {
1701            if let Ok(parsed) = url::Url::parse(url_str) {
1702                for c in sdm.cookies_for_url(parsed, CookieSource::HTTP) {
1703                    let key = (
1704                        c.name().to_string(),
1705                        c.domain().unwrap_or("").to_string(),
1706                        c.path().unwrap_or("").to_string(),
1707                    );
1708                    if seen.insert(key) {
1709                        result.push(cookie_to_cdp(&c));
1710                    }
1711                }
1712            }
1713        }
1714        result
1715    };
1716    Ok(serde_json::json!({ "cookies": cookies }))
1717}
1718
1719/// Network.getAllCookies — retrieve all cookies from the cookie jar.
1720fn cmd_get_all_cookies(page: &PageHandle) -> Result<Value, String> {
1721    let servo = page.servo();
1722    let sdm = servo.site_data_manager();
1723    // Get all sites that have cookies, then collect cookies for each
1724    let site_data = sdm.site_data(StorageType::Cookies);
1725    let mut cookies: Vec<Value> = Vec::new();
1726    let mut seen = HashSet::new();
1727    for sd in site_data {
1728        let site_name = sd.name();
1729        // Construct a URL from the site name to query cookies
1730        let url_str = if site_name.starts_with("http://") || site_name.starts_with("https://") {
1731            site_name.clone()
1732        } else {
1733            format!("https://{site_name}")
1734        };
1735        if let Ok(parsed) = url::Url::parse(&url_str) {
1736            for c in sdm.cookies_for_url(parsed, CookieSource::HTTP) {
1737                let key = (
1738                    c.name().to_string(),
1739                    c.domain().unwrap_or("").to_string(),
1740                    c.path().unwrap_or("").to_string(),
1741                );
1742                if seen.insert(key) {
1743                    cookies.push(cookie_to_cdp(&c));
1744                }
1745            }
1746        }
1747    }
1748    Ok(serde_json::json!({ "cookies": cookies }))
1749}
1750
1751/// Network.setCookie — set a cookie via servo's SiteDataManager.
1752fn cmd_set_cookie(
1753    page: &PageHandle,
1754    name: &str,
1755    value: &str,
1756    url: Option<&str>,
1757    domain: Option<&str>,
1758) -> Result<Value, String> {
1759    let servo = page.servo();
1760    let sdm = servo.site_data_manager();
1761    let cookie = cdp_params_to_cookie(name, value, url, domain);
1762    // Determine the URL to associate the cookie with
1763    let fallback_url = page.current_url().unwrap_or_default();
1764    let url_str = url.unwrap_or_else(|| {
1765        if fallback_url.is_empty() || fallback_url == "about:blank" {
1766            "https://localhost/"
1767        } else {
1768            fallback_url.as_str()
1769        }
1770    });
1771    let parsed = url::Url::parse(url_str).map_err(|e| format!("invalid URL for setCookie: {e}"))?;
1772    sdm.set_cookie_for_url(parsed, cookie, None);
1773    Ok(serde_json::json!({ "success": true }))
1774}
1775
1776/// Network.deleteCookies — delete cookies matching name (and optionally url/domain).
1777fn cmd_delete_cookie(page: &PageHandle, name: &str, url: Option<&str>) -> Result<Value, String> {
1778    let servo = page.servo();
1779    let sdm = servo.site_data_manager();
1780    if let Some(url_str) = url {
1781        let parsed =
1782            url::Url::parse(url_str).map_err(|e| format!("invalid URL for deleteCookies: {e}"))?;
1783        // Get current cookies for this URL
1784        let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
1785        // Clear all cookies for this site, then re-set the ones that don't match the name
1786        let site = parsed.host_str().unwrap_or("");
1787        sdm.clear_site_data(&[site], StorageType::Cookies);
1788        // Re-set cookies that don't match the name to delete
1789        for c in current {
1790            if c.name() != name {
1791                sdm.set_cookie_for_url(parsed.clone(), c, None);
1792            }
1793        }
1794    } else {
1795        // No URL — clear cookies for all sites matching the name
1796        let site_data = sdm.site_data(StorageType::Cookies);
1797        for sd in site_data {
1798            let site_name = sd.name();
1799            let url_str = if site_name.starts_with("http://") || site_name.starts_with("https://") {
1800                site_name.clone()
1801            } else {
1802                format!("https://{site_name}")
1803            };
1804            if let Ok(parsed) = url::Url::parse(&url_str) {
1805                let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
1806                let has_match = current.iter().any(|c| c.name() == name);
1807                if has_match {
1808                    sdm.clear_site_data(&[&site_name], StorageType::Cookies);
1809                    for c in current {
1810                        if c.name() != name {
1811                            sdm.set_cookie_for_url(parsed.clone(), c, None);
1812                        }
1813                    }
1814                }
1815            }
1816        }
1817    }
1818    Ok(serde_json::json!({}))
1819}
1820
1821/// Network.setCacheDisabled — clear cache when cache_disabled is true.
1822fn cmd_network_set_cache_disabled(
1823    page: &PageHandle,
1824    cache_disabled: bool,
1825) -> Result<Value, String> {
1826    if cache_disabled {
1827        let servo = page.servo();
1828        let nm = servo.network_manager();
1829        nm.clear_cache();
1830    }
1831    Ok(serde_json::json!({}))
1832}
1833
1834/// Network.clearBrowserCache — clear the HTTP cache via servo's NetworkManager.
1835fn cmd_network_clear_browser_cache(page: &PageHandle) -> Result<Value, String> {
1836    let servo = page.servo();
1837    let nm = servo.network_manager();
1838    nm.clear_cache();
1839    Ok(serde_json::json!({}))
1840}
1841
1842/// Network.clearBrowserCookies — clear all cookies via servo's SiteDataManager.
1843fn cmd_network_clear_browser_cookies(page: &PageHandle) -> Result<Value, String> {
1844    let servo = page.servo();
1845    let sdm = servo.site_data_manager();
1846    sdm.clear_cookies(None);
1847    Ok(serde_json::json!({}))
1848}
1849
1850/// Storage.getStorageItemsForOrigin — list storage data for an origin.
1851fn cmd_storage_get_items(
1852    page: &PageHandle,
1853    origin: String,
1854    storage_type: String,
1855) -> Result<Value, String> {
1856    let servo = page.servo();
1857    let sdm = servo.site_data_manager();
1858    let st = parse_storage_type(&storage_type);
1859    let site_data = sdm.site_data(st);
1860    let items: Vec<Value> = site_data
1861        .iter()
1862        .filter(|sd| {
1863            let site_name = sd.name();
1864            origin.is_empty()
1865                || site_name == origin
1866                || site_name.ends_with(&format!(".{origin}"))
1867                || origin.ends_with(&format!(".{site_name}"))
1868        })
1869        .map(|sd| {
1870            serde_json::json!({
1871                "origin": sd.name(),
1872                "storageType": storage_type,
1873            })
1874        })
1875        .collect();
1876    Ok(serde_json::json!({ "storageItems": items }))
1877}
1878
1879/// Storage.clearDataForOrigin — clear storage data for a specific origin.
1880fn cmd_storage_clear_data(
1881    page: &PageHandle,
1882    origin: String,
1883    storage_type: String,
1884) -> Result<Value, String> {
1885    let servo = page.servo();
1886    let sdm = servo.site_data_manager();
1887    let st = parse_storage_type(&storage_type);
1888    if origin.is_empty() {
1889        sdm.clear_cookies(None);
1890    } else {
1891        sdm.clear_site_data(&[&origin], st);
1892    }
1893    Ok(serde_json::json!({}))
1894}
1895
1896/// Parse CDP storage type string to servo StorageType bitflags.
1897fn parse_storage_type(storage_type: &str) -> StorageType {
1898    match storage_type {
1899        "cookies" | "cookie" => StorageType::Cookies,
1900        "local_storage" | "local" => StorageType::Local,
1901        "session_storage" | "session" => StorageType::Session,
1902        "all" => StorageType::Cookies | StorageType::Local | StorageType::Session,
1903        _ => StorageType::Cookies | StorageType::Local | StorageType::Session,
1904    }
1905}
1906
1907fn ok_empty() -> Result<Value, String> {
1908    Ok(serde_json::json!({}))
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913    use crate::delegate::{ServiceWorkerHandle, ServiceWorkerRegistrationState};
1914    use serde_json::{json, Value};
1915
1916    #[test]
1917    fn json_type_null_returns_undefined() {
1918        assert_eq!(super::json_type(&json!(null)), "undefined");
1919    }
1920
1921    #[test]
1922    fn json_type_bool_returns_boolean() {
1923        assert_eq!(super::json_type(&json!(true)), "boolean");
1924        assert_eq!(super::json_type(&json!(false)), "boolean");
1925    }
1926
1927    #[test]
1928    fn json_type_number_returns_number() {
1929        assert_eq!(super::json_type(&json!(42)), "number");
1930        assert_eq!(super::json_type(&json!(3.14)), "number");
1931        assert_eq!(super::json_type(&json!(0)), "number");
1932        assert_eq!(super::json_type(&json!(-1)), "number");
1933    }
1934
1935    #[test]
1936    fn json_type_string_returns_string() {
1937        assert_eq!(super::json_type(&json!("hello")), "string");
1938        assert_eq!(super::json_type(&json!("")), "string");
1939    }
1940
1941    #[test]
1942    fn json_type_array_returns_object() {
1943        assert_eq!(super::json_type(&json!([1, 2, 3])), "object");
1944        assert_eq!(super::json_type(&json!([])), "object");
1945    }
1946
1947    #[test]
1948    fn json_type_object_returns_object() {
1949        assert_eq!(super::json_type(&json!({"a": 1})), "object");
1950        assert_eq!(super::json_type(&json!({})), "object");
1951    }
1952
1953    #[test]
1954    fn json_type_string_empty_returns_undefined() {
1955        assert_eq!(super::json_type_string(""), "undefined");
1956    }
1957
1958    #[test]
1959    fn json_type_string_undefined_returns_undefined() {
1960        assert_eq!(super::json_type_string("undefined"), "undefined");
1961    }
1962
1963    #[test]
1964    fn json_type_string_null_returns_object() {
1965        assert_eq!(super::json_type_string("null"), "object");
1966    }
1967
1968    #[test]
1969    fn json_type_string_true_returns_boolean() {
1970        assert_eq!(super::json_type_string("true"), "boolean");
1971    }
1972
1973    #[test]
1974    fn json_type_string_false_returns_boolean() {
1975        assert_eq!(super::json_type_string("false"), "boolean");
1976    }
1977
1978    #[test]
1979    fn json_type_string_integer_returns_number() {
1980        assert_eq!(super::json_type_string("42"), "number");
1981        assert_eq!(super::json_type_string("0"), "number");
1982        assert_eq!(super::json_type_string("-7"), "number");
1983    }
1984
1985    #[test]
1986    fn json_type_string_float_returns_number() {
1987        assert_eq!(super::json_type_string("3.14"), "number");
1988        assert_eq!(super::json_type_string("-0.5"), "number");
1989    }
1990
1991    #[test]
1992    fn json_type_string_object_brace_returns_object() {
1993        assert_eq!(super::json_type_string("{\"a\":1}"), "object");
1994    }
1995
1996    #[test]
1997    fn json_type_string_array_bracket_returns_object() {
1998        assert_eq!(super::json_type_string("[1,2,3]"), "object");
1999    }
2000
2001    #[test]
2002    fn json_type_string_regular_text_returns_string() {
2003        assert_eq!(super::json_type_string("hello world"), "string");
2004        assert_eq!(super::json_type_string("some result"), "string");
2005    }
2006
2007    // ─── json_type edge cases ─────────────────────────────────────
2008    // @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
2009
2010    #[test]
2011    fn json_type_large_number() {
2012        assert_eq!(super::json_type(&json!(i64::MAX)), "number");
2013        assert_eq!(super::json_type(&json!(f64::MAX)), "number");
2014    }
2015
2016    #[test]
2017    fn json_type_nested_object() {
2018        assert_eq!(super::json_type(&json!({"a": {"b": 1}})), "object");
2019    }
2020
2021    #[test]
2022    fn json_type_nested_array() {
2023        assert_eq!(super::json_type(&json!([[1, 2], [3, 4]])), "object");
2024    }
2025
2026    // ─── json_type_string edge cases ──────────────────────────────
2027    // @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
2028
2029    #[test]
2030    fn json_type_string_scientific_notation() {
2031        assert_eq!(super::json_type_string("1e10"), "number");
2032        assert_eq!(super::json_type_string("-2.5e-3"), "number");
2033    }
2034
2035    #[test]
2036    fn json_type_string_whitespace_is_string() {
2037        assert_eq!(super::json_type_string("  "), "string");
2038        assert_eq!(super::json_type_string(" 42"), "string");
2039    }
2040
2041    #[test]
2042    fn json_type_string_special_strings() {
2043        // NaN and Infinity parse as f64, so they're "number"
2044        assert_eq!(super::json_type_string("NaN"), "number");
2045        assert_eq!(super::json_type_string("Infinity"), "number");
2046        assert_eq!(super::json_type_string("[object Object]"), "object");
2047    }
2048
2049    #[test]
2050    fn json_type_string_negative_zero() {
2051        assert_eq!(super::json_type_string("-0"), "number");
2052        assert_eq!(super::json_type_string("0.0"), "number");
2053    }
2054
2055    // ─── to_browser_error edge cases ───────────────────────────────────
2056    // @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
2057
2058    #[test]
2059    fn to_browser_error_init_variant() {
2060        let err = crate::error::BrowserError::Init("failed to start".into());
2061        let msg = super::to_browser_error(err);
2062        assert!(msg.contains("browser init error"));
2063        assert!(msg.contains("failed to start"));
2064    }
2065
2066    #[test]
2067    fn to_browser_error_navigation_variant() {
2068        let err = crate::error::BrowserError::Navigation("invalid url".into());
2069        let msg = super::to_browser_error(err);
2070        assert!(msg.contains("navigation error"));
2071        assert!(msg.contains("invalid url"));
2072    }
2073
2074    #[test]
2075    fn to_browser_error_rendering_variant() {
2076        let err = crate::error::BrowserError::Rendering("gpu lost".into());
2077        let msg = super::to_browser_error(err);
2078        assert!(msg.contains("rendering error"));
2079        assert!(msg.contains("gpu lost"));
2080    }
2081
2082    #[test]
2083    fn to_browser_error_javascript_variant() {
2084        let err = crate::error::BrowserError::JavaScript("syntax error".into());
2085        let msg = super::to_browser_error(err);
2086        assert!(msg.contains("javascript error"));
2087        assert!(msg.contains("syntax error"));
2088    }
2089
2090    #[test]
2091    fn to_browser_error_cdp_variant() {
2092        let err = crate::error::BrowserError::CDP("connection refused".into());
2093        let msg = super::to_browser_error(err);
2094        assert!(msg.contains("cdp error"));
2095        assert!(msg.contains("connection refused"));
2096    }
2097
2098    #[test]
2099    fn to_browser_error_empty_message() {
2100        let err = crate::error::BrowserError::Init(String::new());
2101        let msg = super::to_browser_error(err);
2102        assert!(msg.contains("browser init error"));
2103    }
2104
2105    #[test]
2106    fn to_browser_error_unicode_message() {
2107        let err = crate::error::BrowserError::Navigation("页面加载失败".into());
2108        let msg = super::to_browser_error(err);
2109        assert!(msg.contains("页面加载失败"));
2110    }
2111
2112    // ─── cmd_navigate/cmd_reload id generation (pure logic) ────────────
2113    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2114
2115    #[test]
2116    fn next_cdp_id_is_unique_and_prefixed() {
2117        // loaderId/script identifiers are generated from a monotonic counter —
2118        // never the hardcoded "0"/"1" constants (Chrome semantics: fresh id
2119        // per load / per added script).
2120        let a = super::next_cdp_id("loader");
2121        let b = super::next_cdp_id("loader");
2122        assert!(a.starts_with("loader-"), "id must carry its prefix: {a}");
2123        assert_ne!(a, b, "ids must be unique per call");
2124        let s = super::next_cdp_id("script");
2125        assert!(s.starts_with("script-"), "id must carry its prefix: {s}");
2126    }
2127
2128    #[test]
2129    fn cmd_navigate_uses_page_id_frame_and_generated_loader() {
2130        // frameId = real page id (stable across navigations), loaderId =
2131        // generated per load — no hardcoded "0" constants remain.
2132        let source = include_str!("cdp_handler.rs");
2133        assert!(
2134            source.contains("\"frameId\": page.id().to_string()"),
2135            "cmd_navigate/cmd_reload must return the real page id as frameId"
2136        );
2137        assert!(
2138            source.contains("\"loaderId\": next_cdp_id(\"loader\")"),
2139            "cmd_navigate/cmd_reload must generate a fresh loaderId per load"
2140        );
2141        assert!(
2142            !source.contains("\"loaderId\": \"0\""),
2143            "no canned loaderId \"0\" may remain"
2144        );
2145    }
2146
2147    // ─── cmd_evaluate response structure (pure logic) ──────────────────
2148    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2149
2150    #[test]
2151    fn cmd_evaluate_return_by_value_true_json_parse() {
2152        // When return_by_value is true and result is valid JSON, it's parsed
2153        let result_str = r#"{"a":1}"#;
2154        let parsed: Result<Value, _> = serde_json::from_str(result_str);
2155        assert!(parsed.is_ok());
2156        assert_eq!(super::json_type(&parsed.unwrap()), "object");
2157    }
2158
2159    #[test]
2160    fn cmd_evaluate_return_by_value_true_non_json_falls_back() {
2161        // When return_by_value is true but result is not valid JSON, falls back to json_type_string
2162        let result_str = "hello world";
2163        let parsed: Result<Value, _> = serde_json::from_str(result_str);
2164        assert!(parsed.is_err());
2165        assert_eq!(super::json_type_string(result_str), "string");
2166    }
2167
2168    #[test]
2169    fn cmd_evaluate_return_by_value_true_null_json() {
2170        let parsed: Result<Value, _> = serde_json::from_str("null");
2171        assert!(parsed.is_ok());
2172        assert_eq!(super::json_type(&parsed.unwrap()), "undefined");
2173    }
2174
2175    #[test]
2176    fn cmd_evaluate_return_by_value_true_number_json() {
2177        let parsed: Result<Value, _> = serde_json::from_str("42");
2178        assert!(parsed.is_ok());
2179        assert_eq!(super::json_type(&parsed.unwrap()), "number");
2180    }
2181
2182    #[test]
2183    fn cmd_evaluate_return_by_value_true_boolean_json() {
2184        let parsed: Result<Value, _> = serde_json::from_str("true");
2185        assert!(parsed.is_ok());
2186        assert_eq!(super::json_type(&parsed.unwrap()), "boolean");
2187    }
2188
2189    #[test]
2190    fn cmd_evaluate_return_by_value_false_uses_description() {
2191        // When return_by_value is false, result uses json_type_string for type
2192        let result_str = "some JS output";
2193        assert_eq!(super::json_type_string(result_str), "string");
2194    }
2195
2196    // ─── cmd_screenshot format mapping (pure logic) ────────────────────
2197    // @trace REQ-CDP-007 [req:REQ-CDP-007] [level:unit]
2198
2199    #[test]
2200    fn cmd_screenshot_format_jpeg_mapping() {
2201        // "jpeg" -> ScreenshotFormat::Jpeg, anything else -> Png
2202        let fmt = match "jpeg" {
2203            "jpeg" => "Jpeg",
2204            _ => "Png",
2205        };
2206        assert_eq!(fmt, "Jpeg");
2207    }
2208
2209    #[test]
2210    fn cmd_screenshot_format_png_mapping() {
2211        let fmt = match "png" {
2212            "jpeg" => "Jpeg",
2213            _ => "Png",
2214        };
2215        assert_eq!(fmt, "Png");
2216    }
2217
2218    #[test]
2219    fn cmd_screenshot_format_unknown_defaults_to_png() {
2220        let fmt = match "bmp" {
2221            "jpeg" => "Jpeg",
2222            "webp" => "WebP",
2223            _ => "Png",
2224        };
2225        assert_eq!(fmt, "Png");
2226    }
2227
2228    #[test]
2229    fn cmd_screenshot_format_webp_mapping() {
2230        let fmt = match "webp" {
2231            "jpeg" => "Jpeg",
2232            "webp" => "WebP",
2233            _ => "Png",
2234        };
2235        assert_eq!(fmt, "WebP");
2236    }
2237
2238    #[test]
2239    fn cmd_screenshot_format_empty_defaults_to_png() {
2240        let fmt = match "" {
2241            "jpeg" => "Jpeg",
2242            _ => "Png",
2243        };
2244        assert_eq!(fmt, "Png");
2245    }
2246
2247    #[test]
2248    fn cmd_screenshot_base64_encoding() {
2249        // Verify base64 encoding produces valid output
2250        let bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes
2251        let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
2252        assert!(!b64.is_empty());
2253        // Base64 should be decodable back
2254        let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64);
2255        assert!(decoded.is_ok());
2256        assert_eq!(decoded.unwrap(), bytes);
2257    }
2258
2259    #[test]
2260    fn cmd_screenshot_base64_empty_bytes() {
2261        let bytes: Vec<u8> = vec![];
2262        let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
2263        assert_eq!(b64, ""); // empty input -> empty base64
2264    }
2265
2266    // ─── cmd_query_selector JS construction (pure logic) ────────────────
2267    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2268
2269    #[test]
2270    fn cmd_query_selector_js_construction_valid_selector() {
2271        let selector = "div.main";
2272        let js = format!(
2273            "(function() {{ var e = document.querySelector({}); return e ? 1 : 0; }})()",
2274            serde_json::to_string(selector).unwrap_or_default()
2275        );
2276        assert!(js.contains("document.querySelector"));
2277        assert!(js.contains("\"div.main\""));
2278    }
2279
2280    #[test]
2281    fn cmd_query_selector_js_construction_empty_selector() {
2282        let selector = "";
2283        let json_str = serde_json::to_string(selector).unwrap_or_default();
2284        assert_eq!(json_str, "\"\"");
2285    }
2286
2287    #[test]
2288    fn cmd_query_selector_js_construction_special_chars() {
2289        let selector = "div[data-attr='value']";
2290        let json_str = serde_json::to_string(selector).unwrap_or_default();
2291        // serde_json should escape the single quotes properly
2292        assert!(json_str.contains("div[data-attr"));
2293    }
2294
2295    #[test]
2296    fn cmd_query_selector_js_construction_unicode() {
2297        let selector = "div.中文类名";
2298        let json_str = serde_json::to_string(selector).unwrap_or_default();
2299        assert!(json_str.contains("中文类名"));
2300    }
2301
2302    // ─── cmd_query_selector_all JS construction (pure logic) ────────────
2303    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2304
2305    #[test]
2306    fn cmd_query_selector_all_js_construction() {
2307        let selector = "li.item";
2308        let js = format!(
2309            "(function() {{ return document.querySelectorAll({}).length; }})()",
2310            serde_json::to_string(selector).unwrap_or_default()
2311        );
2312        assert!(js.contains("document.querySelectorAll"));
2313        assert!(js.contains(".length"));
2314    }
2315
2316    #[test]
2317    fn cmd_query_selector_all_count_to_node_ids() {
2318        // When count is 3, nodeIds should be [1, 2, 3]
2319        let count: i64 = 3;
2320        let ids: Vec<i64> = (1..=count).collect();
2321        assert_eq!(ids, vec![1, 2, 3]);
2322    }
2323
2324    #[test]
2325    fn cmd_query_selector_all_zero_count() {
2326        let count: i64 = 0;
2327        let ids: Vec<i64> = (1..=count).collect();
2328        assert!(ids.is_empty());
2329    }
2330
2331    #[test]
2332    fn cmd_query_selector_all_large_count() {
2333        let count: i64 = 100;
2334        let ids: Vec<i64> = (1..=count).collect();
2335        assert_eq!(ids.len(), 100);
2336        assert_eq!(ids[0], 1);
2337        assert_eq!(ids[99], 100);
2338    }
2339
2340    // ─── cmd_set_attribute JS construction (pure logic) ─────────────────
2341    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2342
2343    #[test]
2344    fn cmd_set_attribute_js_construction() {
2345        let name = "class";
2346        let value = "active";
2347        let js = format!(
2348            "(function() {{ document.querySelector('[data-cdp]')?.setAttribute({}, {}); }})()",
2349            serde_json::to_string(name).unwrap_or_default(),
2350            serde_json::to_string(value).unwrap_or_default(),
2351        );
2352        assert!(js.contains("setAttribute"));
2353        assert!(js.contains("\"class\""));
2354        assert!(js.contains("\"active\""));
2355    }
2356
2357    #[test]
2358    fn cmd_set_attribute_js_with_quotes_in_value() {
2359        let name = "data-info";
2360        let value = r#"he said "hello""#;
2361        let _json_name = serde_json::to_string(name).unwrap_or_default();
2362        let json_value = serde_json::to_string(value).unwrap_or_default();
2363        // The double quotes should be escaped in JSON
2364        assert!(json_value.contains("\\\""));
2365    }
2366
2367    // ─── cmd_insert_text JS construction (pure logic) ──────────────────
2368    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2369
2370    #[test]
2371    fn cmd_insert_text_js_construction() {
2372        let text = "hello";
2373        let js = format!(
2374            "(function() {{ var el = document.activeElement; if (el && 'value' in el) el.value += {}; }})()",
2375            serde_json::to_string(text).unwrap_or_default(),
2376        );
2377        assert!(js.contains("document.activeElement"));
2378        assert!(js.contains("el.value"));
2379    }
2380
2381    #[test]
2382    fn cmd_insert_text_js_empty_string() {
2383        let text = "";
2384        let json_str = serde_json::to_string(text).unwrap_or_default();
2385        assert_eq!(json_str, "\"\"");
2386    }
2387
2388    #[test]
2389    fn cmd_insert_text_js_newline_escaped() {
2390        let text = "line1\nline2";
2391        let json_str = serde_json::to_string(text).unwrap_or_default();
2392        assert!(json_str.contains("\\n"));
2393    }
2394
2395    // ─── cmd_set_user_agent JS construction (pure logic) ───────────────
2396    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2397
2398    #[test]
2399    fn cmd_set_user_agent_js_construction() {
2400        let ua = "Mozilla/5.0 Test";
2401        let js = format!(
2402            "Object.defineProperty(navigator, 'userAgent', {{ get: function() {{ return {}; }} }});",
2403            serde_json::to_string(ua).unwrap_or_default(),
2404        );
2405        assert!(js.contains("Object.defineProperty"));
2406        assert!(js.contains("navigator"));
2407        assert!(js.contains("userAgent"));
2408    }
2409
2410    #[test]
2411    fn cmd_set_user_agent_js_empty_string() {
2412        let ua = "";
2413        let json_str = serde_json::to_string(ua).unwrap_or_default();
2414        assert_eq!(json_str, "\"\"");
2415    }
2416
2417    // ─── cmd_get_document JS template (pure logic) ─────────────────────
2418    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2419
2420    #[test]
2421    fn cmd_get_document_js_template_structure() {
2422        let js = r#"
2423            (function() {
2424                function walk(node, id) {
2425                    var result = {
2426                        nodeId: id,
2427                        backendNodeId: id,
2428                        nodeType: node.nodeType,
2429                        nodeName: node.nodeName,
2430                        localName: node.localName || '',
2431                        nodeValue: node.nodeValue || '',
2432                    };
2433                    if (node.childNodes && node.childNodes.length > 0) {
2434                        result.childNodeCount = node.childNodes.length;
2435                        result.children = [];
2436                        for (var i = 0; i < Math.min(node.childNodes.length, 20); i++) {
2437                            result.children.push(walk(node.childNodes[i], id * 100 + i + 1));
2438                        }
2439                    }
2440                    return result;
2441                }
2442                return JSON.stringify(walk(document, 1));
2443            })()
2444        "#;
2445        assert!(js.contains("walk"));
2446        assert!(js.contains("nodeId"));
2447        assert!(js.contains("nodeType"));
2448        assert!(js.contains("nodeName"));
2449        assert!(js.contains("childNodeCount"));
2450        assert!(js.contains("Math.min"));
2451        assert!(js.contains("JSON.stringify"));
2452    }
2453
2454    #[test]
2455    fn cmd_get_document_js_limits_children_to_20() {
2456        // The JS template caps children to 20 via Math.min(node.childNodes.length, 20)
2457        let _js = r#"(function() { return Math.min(50, 20); })()"#;
2458        // This is just verifying the logic — 50 children would be capped to 20
2459        assert_eq!(50usize.min(20), 20);
2460    }
2461
2462    // ─── cmd_get_outer_html JS expression (pure logic) ─────────────────
2463    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2464
2465    #[test]
2466    fn cmd_get_outer_html_js_is_simple_expression() {
2467        let js = "document.documentElement.outerHTML";
2468        assert!(js.contains("document.documentElement"));
2469        assert!(js.contains("outerHTML"));
2470    }
2471
2472    // ─── cmd_add_script response structure (pure logic) ────────────────
2473    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2474
2475    #[test]
2476    fn cmd_add_script_response_has_generated_identifier() {
2477        // identifier comes from the monotonic counter — never "1".
2478        let resp = json!({ "identifier": super::next_cdp_id("script") });
2479        assert!(resp["identifier"].as_str().unwrap().starts_with("script-"));
2480    }
2481
2482    // ─── cmd_reload response structure (pure logic) ────────────────────
2483    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2484
2485    #[test]
2486    fn cmd_reload_response_structure() {
2487        // cmd_reload uses the real servo reload path (WebView::reload), not a
2488        // re-navigate of the current URL.
2489        let source = include_str!("cdp_handler.rs");
2490        assert!(
2491            source.contains("page.reload().map_err(to_browser_error)?;"),
2492            "cmd_reload must call PageHandle::reload"
2493        );
2494    }
2495
2496    // ─── handle_bridge_command wildcard commands (pure logic) ──────────
2497    // @trace REQ-CDP-001 [req:REQ-CDP-001] [level:unit]
2498
2499    #[test]
2500    fn handle_bridge_command_go_back_forward_wired_stop_loading_errors() {
2501        // GoBack/GoForward dispatch to real servo traversal; StopLoading is an
2502        // explicit error (servo WebView has no stop-loading API).
2503        let source = include_str!("cdp_handler.rs");
2504        assert!(
2505            source.contains("BridgeCommand::GoBack { target_id } => with_page"),
2506            "GoBack must be dispatched to a page handler"
2507        );
2508        assert!(
2509            source.contains("BridgeCommand::GoForward { target_id } => with_page"),
2510            "GoForward must be dispatched to a page handler"
2511        );
2512        assert!(
2513            source.contains("Page.stopLoading not supported"),
2514            "StopLoading must return an explicit error, never a fake ok"
2515        );
2516    }
2517
2518    // ─── canned-response eradication (source-level guarantees) ─────────
2519    // @trace REQ-CDP-003 [req:REQ-CDP-003] [level:unit]
2520
2521    #[test]
2522    fn no_canned_successes_remain_in_dispatch() {
2523        // Every audited canned success is replaced by either a real path or an
2524        // explicit error. The literals below must never reappear.
2525        let source = include_str!("cdp_handler.rs");
2526        assert!(!source.contains("\"snapshot\": {}"));
2527        assert!(!source.contains("\"profile\": {}"));
2528        assert!(!source.contains("\"jsEventListeners\": 0"));
2529        assert!(!source.contains("\"body\": \"\""));
2530        assert!(!source.contains("\"identifier\": \"1\""));
2531    }
2532
2533    #[test]
2534    fn profiler_and_heapprofiler_report_explicit_errors() {
2535        let source = include_str!("cdp_handler.rs");
2536        assert!(source.contains("Profiler not supported"));
2537        assert!(source.contains("HeapProfiler snapshot/tracking not supported"));
2538        assert!(source.contains("Memory.getDOMCounters not supported"));
2539        assert!(source.contains("Network.getResponseBody not supported"));
2540        assert!(source.contains("Network.setExtraHTTPHeaders not supported"));
2541        assert!(source.contains("Security.setOverrideCertificateErrors not supported"));
2542    }
2543
2544    #[test]
2545    fn collect_garbage_uses_servo_gc_api() {
2546        // Real GC path: navigator.servo.GarbageCollectAllContexts() →
2547        // TriggerGarbageCollection → JS_GC on the script thread.
2548        let source = include_str!("cdp_handler.rs");
2549        assert!(
2550            source.contains("navigator.servo.GarbageCollectAllContexts()"),
2551            "GC must go through servo's real GC DOM API"
2552        );
2553    }
2554
2555    #[test]
2556    fn handle_bridge_command_close_page_returns_empty() {
2557        let expected = json!({});
2558        assert_eq!(expected, json!({}));
2559    }
2560
2561    #[test]
2562    fn handle_bridge_command_unsupported_returns_error() {
2563        // The wildcard `_` match returns Err("unsupported bridge command")
2564        let err_msg = "unsupported bridge command";
2565        assert!(!err_msg.is_empty());
2566    }
2567
2568    // ─── json_type_string additional edge cases ────────────────────────
2569    // @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
2570
2571    #[test]
2572    fn json_type_string_leading_dot_is_string() {
2573        // ".5" is not a valid f64 parse in some contexts, but Rust's parse handles it
2574        let result = ".5".parse::<f64>();
2575        if result.is_ok() {
2576            assert_eq!(super::json_type_string(".5"), "number");
2577        } else {
2578            assert_eq!(super::json_type_string(".5"), "string");
2579        }
2580    }
2581
2582    #[test]
2583    fn json_type_string_positive_infinity() {
2584        assert_eq!(super::json_type_string("inf"), "number");
2585    }
2586
2587    #[test]
2588    fn json_type_string_negative_infinity() {
2589        assert_eq!(super::json_type_string("-inf"), "number");
2590    }
2591
2592    #[test]
2593    fn json_type_string_hex_string_is_string() {
2594        // "0x1A" is not a valid f64 parse, so it's "string"
2595        assert_eq!(super::json_type_string("0x1A"), "string");
2596    }
2597
2598    #[test]
2599    fn json_type_string_very_long_number() {
2600        let long_num = "123456789012345678901234567890";
2601        // This parses as f64 (with precision loss), so it's "number"
2602        assert_eq!(super::json_type_string(long_num), "number");
2603    }
2604
2605    #[test]
2606    fn json_type_string_mixed_alphanumeric_is_string() {
2607        assert_eq!(super::json_type_string("abc123"), "string");
2608    }
2609
2610    #[test]
2611    fn json_type_string_empty_object_string() {
2612        assert_eq!(super::json_type_string("{}"), "object");
2613    }
2614
2615    #[test]
2616    fn json_type_string_empty_array_string() {
2617        assert_eq!(super::json_type_string("[]"), "object");
2618    }
2619
2620    // ─── json_type additional edge cases ───────────────────────────────
2621    // @trace REQ-CDP-005 [req:REQ-CDP-005] [level:unit]
2622
2623    #[test]
2624    fn json_type_negative_number() {
2625        assert_eq!(super::json_type(&json!(-999)), "number");
2626    }
2627
2628    #[test]
2629    fn json_type_large_float() {
2630        assert_eq!(super::json_type(&json!(f64::MIN)), "number");
2631    }
2632
2633    #[test]
2634    fn json_type_deeply_nested_value() {
2635        let deep = json!({"a": {"b": {"c": {"d": [1, 2, {"e": true}]}}}});
2636        assert_eq!(super::json_type(&deep), "object");
2637    }
2638
2639    #[test]
2640    fn json_type_string_with_special_chars() {
2641        assert_eq!(super::json_type(&json!("\n\t\r")), "string");
2642        assert_eq!(super::json_type(&json!("\0")), "string");
2643    }
2644
2645    #[test]
2646    fn json_type_mixed_array() {
2647        assert_eq!(
2648            super::json_type(&json!([1, "two", null, true, {}])),
2649            "object"
2650        );
2651    }
2652
2653    // ─── ServiceWorker registration JSON serialization (REQ-BRW-4 C6/C19, DF-WK-8) ───
2654    // @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:6] [criterion:19]
2655
2656    #[test]
2657    fn sw_registration_id_parses_double_colon_format() {
2658        // @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
2659        let id = super::parse_sw_registration_id("sw.js::/").unwrap();
2660        assert_eq!(id.script_url, "sw.js");
2661        assert_eq!(id.scope, "/");
2662    }
2663
2664    #[test]
2665    fn sw_registration_id_parses_complex_scope() {
2666        let id = super::parse_sw_registration_id("https://example.com/sw.js::/app/").unwrap();
2667        assert_eq!(id.script_url, "https://example.com/sw.js");
2668        assert_eq!(id.scope, "/app/");
2669    }
2670
2671    #[test]
2672    fn sw_registration_id_rejects_missing_separator() {
2673        assert!(super::parse_sw_registration_id("sw.js").is_err());
2674        assert!(super::parse_sw_registration_id("sw.js/").is_err());
2675    }
2676
2677    #[test]
2678    fn sw_registration_to_json_activated() {
2679        // @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:6] [criterion:19] DF-WK-8
2680        let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
2681        handle.transition_state(ServiceWorkerRegistrationState::Activated);
2682        handle.enable_fetch_interception();
2683
2684        let json_val = super::sw_registration_to_json(handle);
2685        assert_eq!(json_val["registrationId"], "sw.js::/");
2686        assert_eq!(json_val["scriptURL"], "sw.js");
2687        assert_eq!(json_val["scope"], "/");
2688        assert_eq!(json_val["state"], "activated");
2689        assert_eq!(json_val["isActive"], true);
2690        // Per DEC-WK-008: fetch interception mode tracked in registry even
2691        // though servo upstream does not dispatch FetchEvent yet.
2692        assert_eq!(json_val["isFetchIntercepting"], true);
2693    }
2694
2695    #[test]
2696    fn sw_registration_to_json_installing_state() {
2697        let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
2698        // Default state is Installing
2699        let json_val = super::sw_registration_to_json(handle);
2700        assert_eq!(json_val["state"], "installing");
2701        assert_eq!(json_val["isActive"], false);
2702        assert_eq!(json_val["isFetchIntercepting"], false);
2703    }
2704
2705    #[test]
2706    fn sw_registration_to_json_all_states() {
2707        // @trace REQ-BRW-4 [entity:ServiceWorker] DF-WK-8
2708        let states_and_expected = vec![
2709            (ServiceWorkerRegistrationState::Idle, "idle"),
2710            (ServiceWorkerRegistrationState::Installing, "installing"),
2711            (ServiceWorkerRegistrationState::Installed, "installed"),
2712            (ServiceWorkerRegistrationState::Activating, "activating"),
2713            (ServiceWorkerRegistrationState::Activated, "activated"),
2714            (ServiceWorkerRegistrationState::Redundant, "redundant"),
2715        ];
2716        for (state, expected) in states_and_expected {
2717            let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
2718            handle.transition_state(state.clone());
2719            let json_val = super::sw_registration_to_json(handle);
2720            assert_eq!(json_val["state"], expected, "state mapping for {:?}", state);
2721        }
2722    }
2723
2724    #[test]
2725    fn sw_registration_terminate_disables_fetch_interception() {
2726        // @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:19] DF-WK-8
2727        // SPEC criterion #19: "terminate 后正确注销"
2728        let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
2729        handle.enable_fetch_interception();
2730        assert!(handle.is_intercepting_fetch());
2731
2732        handle.terminate();
2733
2734        assert!(handle.is_closing());
2735        // terminate() must disable fetch interception
2736        assert!(!handle.is_intercepting_fetch());
2737    }
2738
2739    #[test]
2740    fn sw_registration_terminate_is_idempotent() {
2741        let handle = ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), None);
2742        handle.terminate();
2743        handle.terminate();
2744        handle.terminate();
2745        assert!(handle.is_closing());
2746    }
2747
2748    #[test]
2749    fn sw_registration_stealth_profile_inherited() {
2750        // @trace REQ-BRW-4 [entity:ServiceWorker] [criterion:19] DF-WK-10
2751        // SPEC: SW must inherit registering page's stealth profile.
2752        let profile = bao_stealth::StealthProfile::chrome_default();
2753        let handle =
2754            ServiceWorkerHandle::new("sw.js".to_string(), "/".to_string(), Some(profile.clone()));
2755        assert!(handle.stealth_profile.is_some());
2756        // Stealth profile is preserved across registry lookups
2757        let json_val = super::sw_registration_to_json(handle.clone());
2758        // Note: stealth profile is internal state, not serialized to CDP JSON
2759        // (it's used for stealth consistency verification, not CDP reporting)
2760        assert_eq!(json_val["scriptURL"], "sw.js");
2761        assert!(handle.stealth_profile.is_some());
2762    }
2763
2764    #[test]
2765    fn sw_registration_id_format_with_colon_in_url() {
2766        // Edge case: URL containing colon (not double-colon) should parse correctly
2767        let id = super::parse_sw_registration_id("https://a.io/sw.js::/scope").unwrap();
2768        assert_eq!(id.script_url, "https://a.io/sw.js");
2769        assert_eq!(id.scope, "/scope");
2770    }
2771}