Skip to main content

bao_browser/
page.rs

1// @trace REQ-BRW-001 [entity:PageHandle]  REQ-BRW-002: Page lifecycle management (navigate, evaluate, screenshot)
2// @trace REQ-LIB-001 REQ-LIB-004: PageHandle high-level API (waitForSelector, click, type, fill, etc.)
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::sync::mpsc;
7use std::time::{Duration, Instant};
8
9use dpi::PhysicalSize;
10use servo::{
11    CSSPixel, Code, CookieSource, InputEvent, Key, KeyState, KeyboardEvent, Location, Modifiers,
12    MouseButton, MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NamedKey, RenderingContext,
13    Servo, SoftwareRenderingContext, StorageType, WebView, WebViewBuilder, WebViewPoint,
14};
15
16use crate::config::PageConfig;
17use crate::delegate::{BaoWebViewDelegate, BaoWebViewState};
18use crate::error::BrowserError;
19use crate::permission::PermissionGuard;
20use crate::screenshot::{encode_image, ScreenshotFormat};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PageState {
24    Created,
25    Navigating,
26    Interactive,
27    Idle,
28    /// Intermediate cleanup state (SM PageLifecycle, SPEC 03-PROCESS).
29    /// Entered on: close_during_load / idle_ttl_expired / close.
30    /// Exited on: cleanup_complete → Closed.
31    /// @trace REQ-BRW-001 [sm:PageLifecycle]
32    Closing,
33    Closed,
34}
35
36pub struct PageInner {
37    pub id: usize,
38    pub webview: WebView,
39    pub servo: Rc<Servo>,
40    pub rendering_context: Rc<SoftwareRenderingContext>,
41    pub delegate: Rc<BaoWebViewDelegate>,
42    pub state: Rc<RefCell<PageState>>,
43    pub webview_state: Rc<RefCell<BaoWebViewState>>,
44    /// Monotonic navigation counter (navigate/reload/go_back/go_forward).
45    /// `wait_for_pipeline_ready` Phase 2 keys on it: only a page that has
46    /// actually navigated keeps being driven until the load completes —
47    /// never-navigated callers keep the old first-frame contract.
48    pub nav_seq: ::std::cell::Cell<u64>,
49    pub viewport: PhysicalSize<u32>,
50    pub stealth_profile: Option<bao_stealth::StealthProfile>,
51    pub permission: PermissionGuard,
52    /// Per-page servo UserContentManager — the real navigation-replay
53    /// facility for scripts injected via CDP
54    /// Page.addScriptToEvaluateOnNewDocument (servo replays user scripts on
55    /// every new document load of the owning WebView).
56    pub user_content_manager: Option<Rc<servo::UserContentManager>>,
57    pub last_active_at: RefCell<Instant>,
58    pub created_at: Instant,
59    /// Node Realm global object pointer for privileged evaluate_js (REQ-SEC-002).
60    /// Created via JS_NewGlobalObject in its own Compartment — physically
61    /// isolated from Page Realm (Window). Page JS cannot discover this.
62    pub node_realm_global: RefCell<*mut mozjs::jsapi::JSObject>,
63    /// Page Realm global pointer (servo's Window object) — used as key
64    /// to look up this page's Node Realm from the per-page HashMap.
65    pub page_global: RefCell<*mut mozjs::jsapi::JSObject>,
66}
67
68impl PageInner {
69    pub fn touch(&self) {
70        *self.last_active_at.borrow_mut() = Instant::now();
71    }
72
73    /// WebViewId of this page's servo WebView. Stable across navigation
74    /// (servo ties the WebViewId to the WebView, not the pipeline).
75    /// Used for all WebViewId-keyed runtime_bridge lookups (BCE-20260621-001).
76    pub fn webview_id_opt(&self) -> Option<servo::WebViewId> {
77        Some(self.webview.id())
78    }
79
80    pub fn navigate(&self, url: &str) -> Result<(), BrowserError> {
81        let parsed = url::Url::parse(url)
82            .map_err(|e| BrowserError::Navigation(format!("invalid URL: {e}")))?;
83        self.webview.load(parsed);
84        self.touch();
85        *self.state.borrow_mut() = PageState::Navigating;
86        // BCE (stale Complete race): a second navigation to the same page
87        // leaves the previous load's `Complete` in webview_state until
88        // servo's async Started arrives — get_state's projection below would
89        // report Interactive for a load that just began. Reset to Started
90        // (identical to the event servo is about to deliver; idempotent).
91        self.webview_state.borrow_mut().load_status = servo::LoadStatus::Started;
92        self.nav_seq.set(self.nav_seq.get() + 1);
93        Ok(())
94    }
95
96    /// Drain pending servo script thread callbacks by evaluating a minimal script.
97    ///
98    /// When `register_script_thread_callback` is called, the callback is queued
99    /// but only executes during `handle_evaluate_javascript` on servo's script
100    /// thread. This method triggers that drain by evaluating `";"` (minimal valid JS).
101    ///
102    /// If the pipeline isn't ready yet (WebView just created, constellation hasn't
103    /// finished setup), servo returns InternalError. This method spins the event
104    /// loop and retries until the pipeline is ready or the timeout expires.
105    ///
106    /// Returns the result of the drain evaluation (typically "undefined").
107    // @trace REQ-BRW-001 [entity:PageHandle]
108    pub fn drain_callbacks(&self) -> Result<String, BrowserError> {
109        let max_attempts = 100;
110
111        for attempt in 0..max_attempts {
112            match self.evaluate_js_web(";") {
113                Ok(result) => return Ok(result),
114                Err(BrowserError::JavaScript(msg)) if msg.contains("InternalError") => {
115                    // Pipeline not ready — spin servo event loop and retry.
116                    // Yield after every few attempts to avoid CPU spinning.
117                    if attempt % 5 == 4 {
118                        self.servo.spin_event_loop();
119                        self.webview.paint();
120                    }
121                    continue;
122                }
123                Err(other) => return Err(other),
124            }
125        }
126
127        Err(BrowserError::Init(
128            "callback drain failed: pipeline not ready after timeout".into(),
129        ))
130    }
131
132    /// Evaluate JavaScript in privileged mode (REQ-SEC-002).
133    ///
134    /// Scripts run via this method have full Node.js/Bun runtime access:
135    /// require, fs, crypto, Bun, process, Buffer, etc. These APIs are
136    /// injected by `runtime_bridge::inject_node_apis_with_stealth` as
137    /// engine-layer host functions on the page global, plus NODE_POLYFILLS
138    /// JS polyfill for require/Buffer/process.
139    ///
140    /// Security model (REQ-SEC-002):
141    /// - Node APIs are scoped via IIFE — injected as function parameters,
142    ///   not written to Window globalThis.
143    /// - After evaluate_js returns, page JS (via evaluate_js_web) cannot
144    ///   see Node APIs because they were IIFE parameters, not global vars.
145    /// - evaluate_js_web sees only Web APIs — typeof require === 'undefined'.
146    /// Evaluate JS with full Node.js/Bun API access via Node Realm (REQ-SEC-002).
147    ///
148    /// The script executes in the Node Realm — an independent SpiderMonkey
149    /// Compartment that has require/process/Buffer/Bun/fs/crypto installed
150    /// on its global. The Page Realm physically cannot see the Node Realm.
151    ///
152    /// Flow: register callback → drain_callbacks → read EvaluateResult
153    //
154    // @trace REQ-BRW-003 [req:REQ-BRW-003] [criterion:C2,C4,C10]
155    // BCE-20260621-001: page_global/node_realm_global are no longer looked up
156    // by raw *mut JSObject — they are looked up by WebViewId. PageInner's
157    // stored fields remain as opaque addresses for close()/cleanup, but the
158    // evaluate path uses WebViewId-keyed access exclusively.
159    pub fn evaluate_js(&self, script: &str) -> Result<String, BrowserError> {
160        let webview_id = self.webview.id();
161
162        // Refresh stale DOM proxies after navigation (REQ-SEC-002 safety).
163        // servo replaces Window/Document/Navigator on navigation; the per-WebViewId
164        // page_global mapping must be updated so lazy getters find the new
165        // Page Realm. The Node Realm itself survives navigation (same WebViewId).
166        if self.webview_state.borrow().dom_proxies_dirty {
167            let old_pg = *self.page_global.borrow();
168            crate::runtime_bridge::register_refresh_dom_proxies(webview_id, old_pg);
169            self.drain_callbacks()?;
170            // After drain, read the refreshed pointers via WebViewId.
171            let new_pg = crate::runtime_bridge::get_page_global(webview_id);
172            let new_node = crate::runtime_bridge::get_node_realm_global(webview_id);
173            *self.page_global.borrow_mut() = new_pg;
174            *self.node_realm_global.borrow_mut() = new_node;
175            self.webview_state.borrow_mut().dom_proxies_dirty = false;
176        }
177
178        // Verify Node Realm exists for THIS page (via WebViewId, REQ-SEC-002).
179        let node_global = crate::runtime_bridge::get_node_realm_global(webview_id);
180        if node_global.is_null() {
181            // Node Realm must be initialized at page creation (PagePool::create_page).
182            // If we reach here, it's a programming error, not a lazy-init scenario.
183            return Err(BrowserError::JavaScript(
184                "Node Realm not initialized — this is a bug, eager init failed".into(),
185            ));
186        }
187
188        // Execute via Node Realm (servo routes the callback by WebViewId →
189        // this page's ScriptThread, where node_global was created).
190        let result = crate::runtime_bridge::evaluate_js_via_node_realm(webview_id, script);
191        self.drain_callbacks()?;
192
193        let eval_result = result.get().expect("evaluate result not set after drain");
194        match (&eval_result.value, &eval_result.error) {
195            (Some(val), _) => Ok(val.clone()),
196            (_, Some(err)) => Err(BrowserError::JavaScript(err.clone())),
197            (None, None) => Ok(String::new()),
198        }
199    }
200
201    /// Evaluate JavaScript without Node API injection — web-only mode.
202    ///
203    /// Executes directly in the Page Realm (Window global).
204    /// Page JS has only Web API access — typeof require === 'undefined'.
205    pub fn evaluate_js_web(&self, script: &str) -> Result<String, BrowserError> {
206        let saved = Rc::new(RefCell::new(None));
207        let cb_saved = saved.clone();
208        self.webview
209            .evaluate_javascript(script.to_string(), move |result| {
210                *cb_saved.borrow_mut() = Some(result);
211            });
212
213        self.spin_servo(Duration::from_secs(15), || saved.borrow().is_none())?;
214
215        let result = saved
216            .borrow()
217            .clone()
218            .ok_or_else(|| BrowserError::JavaScript("no evaluation result".into()))?
219            .map_err(|e| BrowserError::JavaScript(format!("{e:?}")))?;
220
221        self.touch();
222        Ok(format_js_value(&result))
223    }
224
225    pub fn take_screenshot(&self, format: ScreenshotFormat) -> Result<Vec<u8>, BrowserError> {
226        self.webview.paint();
227
228        let saved = Rc::new(RefCell::new(None));
229        let cb_saved = saved.clone();
230        self.webview.take_screenshot(None, move |result| {
231            *cb_saved.borrow_mut() = Some(result);
232        });
233
234        self.spin_servo(Duration::from_secs(15), || saved.borrow().is_none())?;
235
236        let image = saved
237            .borrow()
238            .clone()
239            .ok_or_else(|| BrowserError::Rendering("no screenshot result".into()))?
240            .map_err(|e| BrowserError::Rendering(format!("{e:?}")))?;
241
242        self.touch();
243        encode_image(&image, format)
244    }
245
246    /// Reload the page via servo's WebView::reload().
247    pub fn reload(&self) -> Result<(), BrowserError> {
248        self.webview.reload();
249        self.touch();
250        *self.state.borrow_mut() = PageState::Navigating;
251        // BCE (stale Complete race) — see navigate().
252        self.webview_state.borrow_mut().load_status = servo::LoadStatus::Started;
253        self.nav_seq.set(self.nav_seq.get() + 1);
254        Ok(())
255    }
256
257    /// Navigate back in history via servo's WebView::go_back().
258    pub fn go_back(&self) -> Result<(), BrowserError> {
259        self.webview.go_back(1);
260        self.touch();
261        *self.state.borrow_mut() = PageState::Navigating;
262        // BCE (stale Complete race) — see navigate().
263        self.webview_state.borrow_mut().load_status = servo::LoadStatus::Started;
264        self.nav_seq.set(self.nav_seq.get() + 1);
265        Ok(())
266    }
267
268    /// Navigate forward in history via servo's WebView::go_forward().
269    pub fn go_forward(&self) -> Result<(), BrowserError> {
270        self.webview.go_forward(1);
271        self.touch();
272        *self.state.borrow_mut() = PageState::Navigating;
273        // BCE (stale Complete race) — see navigate().
274        self.webview_state.borrow_mut().load_status = servo::LoadStatus::Started;
275        self.nav_seq.set(self.nav_seq.get() + 1);
276        Ok(())
277    }
278
279    /// Check if back navigation is possible.
280    pub fn can_go_back(&self) -> bool {
281        self.webview.can_go_back()
282    }
283
284    /// Check if forward navigation is possible.
285    pub fn can_go_forward(&self) -> bool {
286        self.webview.can_go_forward()
287    }
288
289    /// Set viewport size via servo's WebView::resize().
290    pub fn set_viewport(&self, width: u32, height: u32) {
291        let new_size = PhysicalSize::new(width, height);
292        self.webview.resize(new_size);
293        self.touch();
294    }
295
296    /// Focus the WebView window.
297    pub fn focus(&self) {
298        self.webview.focus();
299    }
300
301    /// Dispatch a mouse button event at the given page coordinates.
302    pub fn dispatch_mouse_event(
303        &self,
304        action: MouseButtonAction,
305        button: MouseButton,
306        x: f32,
307        y: f32,
308    ) {
309        let point = WebViewPoint::Page(euclid::Point2D::<f32, CSSPixel>::new(x, y));
310        let event = InputEvent::MouseButton(MouseButtonEvent::new(action, button, point));
311        self.webview.notify_input_event(event);
312        self.touch();
313    }
314
315    /// Dispatch a mouse move event at the given page coordinates.
316    pub fn dispatch_mouse_move(&self, x: f32, y: f32) {
317        let point = WebViewPoint::Page(euclid::Point2D::<f32, CSSPixel>::new(x, y));
318        let event = InputEvent::MouseMove(MouseMoveEvent::new(point));
319        self.webview.notify_input_event(event);
320        self.touch();
321    }
322
323    /// Dispatch a keyboard event.
324    pub fn dispatch_key_event(&self, state: KeyState, key: Key, code: Code) {
325        let keyboard_event = KeyboardEvent::new_without_event(
326            state,
327            key,
328            code,
329            Location::Standard,
330            Modifiers::empty(),
331            false,
332            false,
333        );
334        let event = InputEvent::Keyboard(keyboard_event);
335        self.webview.notify_input_event(event);
336        self.touch();
337    }
338
339    /// Dispatch a keyboard event with full parameters.
340    pub fn dispatch_key_event_full(
341        &self,
342        state: KeyState,
343        key: Key,
344        code: Code,
345        location: Location,
346        modifiers: Modifiers,
347        repeat: bool,
348    ) {
349        let keyboard_event =
350            KeyboardEvent::new_without_event(state, key, code, location, modifiers, repeat, false);
351        let event = InputEvent::Keyboard(keyboard_event);
352        self.webview.notify_input_event(event);
353        self.touch();
354    }
355
356    /// Get cookies for the given URLs (or current page URL if empty).
357    pub fn cookies(&self, urls: &[String]) -> Result<Vec<cookie::Cookie<'static>>, BrowserError> {
358        let sdm = self.servo.site_data_manager();
359        if urls.is_empty() {
360            let current_url = self.current_url().unwrap_or_default();
361            if current_url.is_empty() || current_url == "about:blank" {
362                return Ok(Vec::new());
363            }
364            match url::Url::parse(&current_url) {
365                Ok(parsed) => Ok(sdm.cookies_for_url(parsed, CookieSource::HTTP)),
366                Err(_) => Ok(Vec::new()),
367            }
368        } else {
369            let mut seen = std::collections::HashSet::new();
370            let mut result = Vec::new();
371            for url_str in urls {
372                if let Ok(parsed) = url::Url::parse(url_str) {
373                    for c in sdm.cookies_for_url(parsed, CookieSource::HTTP) {
374                        let key = (
375                            c.name().to_string(),
376                            c.domain().unwrap_or("").to_string(),
377                            c.path().unwrap_or("").to_string(),
378                        );
379                        if seen.insert(key) {
380                            result.push(c);
381                        }
382                    }
383                }
384            }
385            Ok(result)
386        }
387    }
388
389    /// Set a cookie for the given URL.
390    pub fn set_cookie(
391        &self,
392        url: &str,
393        cookie: cookie::Cookie<'static>,
394    ) -> Result<(), BrowserError> {
395        let sdm = self.servo.site_data_manager();
396        let parsed = url::Url::parse(url)
397            .map_err(|e| BrowserError::Navigation(format!("invalid URL for setCookie: {e}")))?;
398        sdm.set_cookie_for_url(parsed, cookie, None);
399        self.touch();
400        Ok(())
401    }
402
403    /// Delete cookies matching the given name for the given URL.
404    /// If url is None, deletes cookies matching the name across all sites.
405    pub fn delete_cookie(&self, name: &str, url: Option<&str>) -> Result<(), BrowserError> {
406        let sdm = self.servo.site_data_manager();
407        if let Some(url_str) = url {
408            let parsed = url::Url::parse(url_str).map_err(|e| {
409                BrowserError::Navigation(format!("invalid URL for deleteCookie: {e}"))
410            })?;
411            let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
412            let site = parsed.host_str().unwrap_or("");
413            sdm.clear_site_data(&[site], StorageType::Cookies);
414            for c in current {
415                if c.name() != name {
416                    sdm.set_cookie_for_url(parsed.clone(), c, None);
417                }
418            }
419        } else {
420            let site_data = sdm.site_data(StorageType::Cookies);
421            for sd in site_data {
422                let site_name = sd.name();
423                let url_str =
424                    if site_name.starts_with("http://") || site_name.starts_with("https://") {
425                        site_name.clone()
426                    } else {
427                        format!("https://{site_name}")
428                    };
429                if let Ok(parsed) = url::Url::parse(&url_str) {
430                    let current = sdm.cookies_for_url(parsed.clone(), CookieSource::HTTP);
431                    let has_match = current.iter().any(|c| c.name() == name);
432                    if has_match {
433                        sdm.clear_site_data(&[&site_name], StorageType::Cookies);
434                        for c in current {
435                            if c.name() != name {
436                                sdm.set_cookie_for_url(parsed.clone(), c, None);
437                            }
438                        }
439                    }
440                }
441            }
442        }
443        self.touch();
444        Ok(())
445    }
446
447    /// Wait for an element matching the selector to appear in the DOM.
448    /// Polls via JS evaluate until the element is found or timeout expires.
449    pub fn wait_for_selector(&self, selector: &str, timeout: Duration) -> Result<(), BrowserError> {
450        let js = format!(
451            "(function() {{ return document.querySelector({}) !== null; }})()",
452            serde_json::to_string(selector).unwrap_or_default()
453        );
454        let start = Instant::now();
455        while start.elapsed() < timeout {
456            match self.evaluate_js_web(&js) {
457                Ok(ref result) if result == "true" => {
458                    self.touch();
459                    return Ok(());
460                }
461                Ok(_) => {}
462                Err(BrowserError::JavaScript(ref msg)) if msg.contains("InternalError") => {
463                    // Pipeline not ready — spin and retry
464                    self.servo.spin_event_loop();
465                    self.webview.paint();
466                    continue;
467                }
468                Err(e) => return Err(e),
469            }
470            self.servo.spin_event_loop();
471            self.webview.paint();
472            std::thread::yield_now();
473        }
474        Err(BrowserError::Init(format!(
475            "waitForSelector timed out after {}ms for: {selector}",
476            timeout.as_millis()
477        )))
478    }
479
480    /// Wait for a JS function/condition to return a truthy value.
481    /// Polls via JS evaluate until the condition is met or timeout expires.
482    pub fn wait_for_function(
483        &self,
484        fn_expression: &str,
485        timeout: Duration,
486    ) -> Result<(), BrowserError> {
487        let js = format!("(function() {{ return !!({fn_expression}); }})()");
488        let start = Instant::now();
489        while start.elapsed() < timeout {
490            match self.evaluate_js_web(&js) {
491                Ok(ref result) if result == "true" => {
492                    self.touch();
493                    return Ok(());
494                }
495                Ok(_) => {}
496                Err(BrowserError::JavaScript(ref msg)) if msg.contains("InternalError") => {
497                    self.servo.spin_event_loop();
498                    self.webview.paint();
499                    continue;
500                }
501                Err(e) => return Err(e),
502            }
503            self.servo.spin_event_loop();
504            self.webview.paint();
505            std::thread::yield_now();
506        }
507        Err(BrowserError::Init(format!(
508            "waitForFunction timed out after {}ms",
509            timeout.as_millis()
510        )))
511    }
512
513    /// Wait for page navigation to complete (load status transitions to Complete).
514    ///
515    /// Tracks navigation via `LoadStatus` transitions rather than URL changes,
516    /// which correctly handles same-URL navigation (reload, pushState to current URL).
517    /// Detects when `load_status` transitions from Started/HeadParsed to Complete.
518    pub fn wait_for_navigation(&self, timeout: Duration) -> Result<(), BrowserError> {
519        let start = Instant::now();
520        // Record the initial load_status. Navigation begins with Started,
521        // so if we're already at Complete, we wait for a new Started first.
522        let initial_status = self.webview_state.borrow().load_status;
523        let mut saw_new_navigation = initial_status != servo::LoadStatus::Started;
524
525        while start.elapsed() < timeout {
526            let current_status = self.webview_state.borrow().load_status;
527
528            if current_status == servo::LoadStatus::Started {
529                // A new navigation has begun — we now wait for it to complete.
530                saw_new_navigation = true;
531            }
532
533            if saw_new_navigation && current_status == servo::LoadStatus::Complete {
534                self.touch();
535                return Ok(());
536            }
537
538            self.servo.spin_event_loop();
539            self.webview.paint();
540            std::thread::yield_now();
541        }
542        Err(BrowserError::Init(format!(
543            "waitForNavigation timed out after {}ms",
544            timeout.as_millis()
545        )))
546    }
547
548    /// Click an element matching the selector.
549    /// Uses JS evaluate to find the element and get its position,
550    /// then dispatches mouse events (down + up) via servo InputEvent.
551    pub fn click_element(&self, selector: &str) -> Result<(), BrowserError> {
552        // Get element center position via JS
553        let js = format!(
554            "(function() {{ var e = document.querySelector({}); if (!e) return null; var r = e.getBoundingClientRect(); return JSON.stringify({{x: r.x + r.width/2, y: r.y + r.height/2}}); }})()",
555            serde_json::to_string(selector).unwrap_or_default()
556        );
557        let pos_str = self.evaluate_js_web(&js)?;
558        if pos_str == "null" || pos_str.is_empty() {
559            return Err(BrowserError::JavaScript(format!(
560                "element not found for click: {selector}"
561            )));
562        }
563        let pos: serde_json::Value = serde_json::from_str(&pos_str)
564            .map_err(|e| BrowserError::JavaScript(format!("invalid position JSON: {e}")))?;
565        let x = pos["x"].as_f64().unwrap_or(0.0) as f32;
566        let y = pos["y"].as_f64().unwrap_or(0.0) as f32;
567
568        // Dispatch mouseDown then mouseUp
569        self.dispatch_mouse_event(MouseButtonAction::Down, MouseButton::Left, x, y);
570        self.servo.spin_event_loop();
571        self.webview.paint();
572        self.dispatch_mouse_event(MouseButtonAction::Up, MouseButton::Left, x, y);
573        Ok(())
574    }
575
576    /// Type text into the currently focused element by dispatching key events.
577    /// Each character generates a keyDown + keyUp pair.
578    pub fn type_text(&self, text: &str) -> Result<(), BrowserError> {
579        for ch in text.chars() {
580            let key = match ch {
581                '\n' => Key::Named(NamedKey::Enter),
582                '\t' => Key::Named(NamedKey::Tab),
583                '\u{8}' => Key::Named(NamedKey::Backspace),
584                '\u{7f}' => Key::Named(NamedKey::Delete),
585                ' ' => Key::Character(" ".into()),
586                c => Key::Character(c.to_string()),
587            };
588            let code = key_code_for_char(ch);
589            self.dispatch_key_event_full(
590                KeyState::Down,
591                key.clone(),
592                code.clone(),
593                Location::Standard,
594                Modifiers::empty(),
595                false,
596            );
597            self.servo.spin_event_loop();
598            self.webview.paint();
599            self.dispatch_key_event_full(
600                KeyState::Up,
601                key,
602                code,
603                Location::Standard,
604                Modifiers::empty(),
605                false,
606            );
607        }
608        Ok(())
609    }
610
611    /// Fill a form field identified by selector with the given value.
612    /// Sets the value property via JS and dispatches input/change events.
613    pub fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError> {
614        let js = format!(
615            "(function() {{ var e = document.querySelector({}); if (!e) return false; e.value = {}; e.dispatchEvent(new Event('input', {{bubbles: true}})); e.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }})()",
616            serde_json::to_string(selector).unwrap_or_default(),
617            serde_json::to_string(value).unwrap_or_default(),
618        );
619        let result = self.evaluate_js_web(&js)?;
620        if result == "false" {
621            return Err(BrowserError::JavaScript(format!(
622                "element not found for fill: {selector}"
623            )));
624        }
625        Ok(())
626    }
627
628    /// Set the page HTML content via document.open/write/close.
629    pub fn set_content(&self, html: &str) -> Result<(), BrowserError> {
630        let js = format!(
631            "(function() {{ document.open(); document.write({}); document.close(); }})()",
632            serde_json::to_string(html).unwrap_or_default(),
633        );
634        self.evaluate_js_web(&js)?;
635        Ok(())
636    }
637
638    /// Get the page HTML content via document.documentElement.outerHTML.
639    pub fn content(&self) -> Result<String, BrowserError> {
640        self.evaluate_js_web("document.documentElement.outerHTML")
641    }
642
643    /// Select options in a <select> element identified by selector.
644    /// Values are the option values to select.
645    pub fn select(&self, selector: &str, values: &[&str]) -> Result<(), BrowserError> {
646        let values_json = serde_json::to_string(&values).unwrap_or_default();
647        let js = format!(
648            "(function() {{ var e = document.querySelector({}); if (!e) return false; var vals = {values_json}; Array.from(e.options).forEach(function(o) {{ o.selected = vals.indexOf(o.value) !== -1; }}); e.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }})()",
649            serde_json::to_string(selector).unwrap_or_default(),
650        );
651        let result = self.evaluate_js_web(&js)?;
652        if result == "false" {
653            return Err(BrowserError::JavaScript(format!(
654                "element not found for select: {selector}"
655            )));
656        }
657        Ok(())
658    }
659
660    /// Press a key (e.g. Enter, Tab, ArrowDown) by dispatching keyboard events.
661    pub fn press(&self, key: &str) -> Result<(), BrowserError> {
662        let (key_val, code_val) = parse_key_name(key);
663        self.dispatch_key_event_full(
664            KeyState::Down,
665            key_val.clone(),
666            code_val.clone(),
667            Location::Standard,
668            Modifiers::empty(),
669            false,
670        );
671        self.servo.spin_event_loop();
672        self.webview.paint();
673        self.dispatch_key_event_full(
674            KeyState::Up,
675            key_val,
676            code_val,
677            Location::Standard,
678            Modifiers::empty(),
679            false,
680        );
681        Ok(())
682    }
683
684    /// Hover over an element matching the selector.
685    /// Gets element position via JS, then dispatches mouseMove.
686    pub fn hover(&self, selector: &str) -> Result<(), BrowserError> {
687        let js = format!(
688            "(function() {{ var e = document.querySelector({}); if (!e) return null; var r = e.getBoundingClientRect(); return JSON.stringify({{x: r.x + r.width/2, y: r.y + r.height/2}}); }})()",
689            serde_json::to_string(selector).unwrap_or_default()
690        );
691        let pos_str = self.evaluate_js_web(&js)?;
692        if pos_str == "null" || pos_str.is_empty() {
693            return Err(BrowserError::JavaScript(format!(
694                "element not found for hover: {selector}"
695            )));
696        }
697        let pos: serde_json::Value = serde_json::from_str(&pos_str)
698            .map_err(|e| BrowserError::JavaScript(format!("invalid position JSON: {e}")))?;
699        let x = pos["x"].as_f64().unwrap_or(0.0) as f32;
700        let y = pos["y"].as_f64().unwrap_or(0.0) as f32;
701        self.dispatch_mouse_move(x, y);
702        Ok(())
703    }
704
705    /// Focus an element matching the selector via JS.
706    pub fn focus_element(&self, selector: &str) -> Result<(), BrowserError> {
707        let js = format!(
708            "(function() {{ var e = document.querySelector({}); if (!e) return false; e.focus(); return true; }})()",
709            serde_json::to_string(selector).unwrap_or_default()
710        );
711        let result = self.evaluate_js_web(&js)?;
712        if result == "false" {
713            return Err(BrowserError::JavaScript(format!(
714                "element not found for focus: {selector}"
715            )));
716        }
717        Ok(())
718    }
719
720    /// Take screenshot with optional clip region, selector, or fullPage mode.
721    pub fn take_screenshot_advanced(
722        &self,
723        format: ScreenshotFormat,
724        clip: Option<(f64, f64, f64, f64)>,
725        full_page: bool,
726    ) -> Result<Vec<u8>, BrowserError> {
727        let original_viewport = self.viewport;
728
729        if full_page {
730            // Resize viewport to full page height to capture everything.
731            let height_js = "document.documentElement.scrollHeight";
732            let height_str = self.evaluate_js_web(height_js).unwrap_or_default();
733            let full_height: u32 = height_str
734                .trim()
735                .parse()
736                .unwrap_or(original_viewport.height);
737            let capped_height = full_height.max(original_viewport.height);
738            if capped_height != original_viewport.height {
739                self.set_viewport(original_viewport.width, capped_height);
740                // Allow servo to re-layout at the new viewport size.
741                self.servo.spin_event_loop();
742                self.webview.paint();
743            }
744        }
745
746        let result = self.take_screenshot(format);
747
748        // Restore original viewport after full_page capture.
749        if full_page && original_viewport != self.viewport {
750            self.set_viewport(original_viewport.width, original_viewport.height);
751        }
752
753        let image_bytes = result?;
754
755        // Apply clip region by decoding, cropping, and re-encoding.
756        if let Some((x, y, w, h)) = clip {
757            let mut img = image::load_from_memory(&image_bytes).map_err(|e| {
758                BrowserError::Rendering(format!("failed to decode screenshot for clip: {e}"))
759            })?;
760            let crop_x = x.max(0.0) as u32;
761            let crop_y = y.max(0.0) as u32;
762            let crop_w = (w as u32).min(img.width().saturating_sub(crop_x));
763            let crop_h = (h as u32).min(img.height().saturating_sub(crop_y));
764            if crop_w == 0 || crop_h == 0 {
765                return Err(BrowserError::Rendering(
766                    "clip region has zero dimensions".into(),
767                ));
768            }
769            let cropped = img.crop(crop_x, crop_y, crop_w, crop_h);
770            let rgba = cropped.to_rgba8();
771            // Re-determine format from the original bytes (PNG by default).
772            let fmt = if image_bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
773                ScreenshotFormat::Png
774            } else {
775                ScreenshotFormat::Jpeg
776            };
777            return encode_image(&rgba, fmt);
778        }
779
780        Ok(image_bytes)
781    }
782
783    pub fn page_title(&self) -> Option<String> {
784        self.webview_state.borrow().title.clone()
785    }
786
787    pub fn current_url(&self) -> Option<String> {
788        self.webview_state
789            .borrow()
790            .url
791            .as_ref()
792            .map(|u| u.to_string())
793    }
794
795    pub fn get_state(&self) -> PageState {
796        // BCE (PageState never left Navigating, 2026-08-19): the stored
797        // state machine had no writer for the SPEC 02-SYSTEM PageLifecycle
798        // `Navigating → Interactive on load_complete` transition, so
799        // get_state() reported Navigating forever while the page was fully
800        // loaded (title/DOM/evaluate all ready — the v-w0 smoke finding).
801        // servo's LoadStatus (written by notify_load_status_changed in the
802        // delegate) is the single source of truth for load completion —
803        // project it here instead of adding a second writer:
804        //   LoadStatus::Complete == document.readyState "complete" → Interactive.
805        // (navigate/reload/go_back/go_forward reset to Started so a second
806        // navigation can't read the previous load's stale Complete.)
807        let stored = *self.state.borrow();
808        if stored == PageState::Navigating
809            && self.webview_state.borrow().load_status == servo::LoadStatus::Complete
810        {
811            return PageState::Interactive;
812        }
813        stored
814    }
815
816    /// Spin servo's event loop until the callback returns false or timeout.
817    /// Uses yield_now instead of sleep to avoid blocking the thread.
818    // @trace REQ-BRW-001 [entity:PageHandle]
819    fn spin_servo(
820        &self,
821        timeout: Duration,
822        callback: impl Fn() -> bool,
823    ) -> Result<(), BrowserError> {
824        let start = Instant::now();
825        while callback() {
826            self.servo.spin_event_loop();
827            self.webview.paint();
828            if start.elapsed() > timeout {
829                return Err(BrowserError::Init("operation timed out".into()));
830            }
831            // Yield instead of sleep — servo event loop is non-blocking,
832            // and we want to check callback as soon as possible.
833            std::thread::yield_now();
834        }
835        Ok(())
836    }
837}
838
839#[derive(Clone)]
840pub struct PageHandle {
841    inner: Rc<RefCell<Option<PageInner>>>,
842    id: usize,
843    servo: Rc<Servo>,
844    delegate: Rc<crate::delegate::BaoServoDelegate>,
845}
846
847impl PageHandle {
848    pub(crate) fn new(
849        servo: Rc<Servo>,
850        servo_delegate: Rc<crate::delegate::BaoServoDelegate>,
851        config: &PageConfig,
852        default_viewport: PhysicalSize<u32>,
853        id: usize,
854    ) -> Result<Self, BrowserError> {
855        let viewport = PhysicalSize::new(
856            config.viewport_width.unwrap_or(default_viewport.width),
857            config.viewport_height.unwrap_or(default_viewport.height),
858        );
859
860        let rendering_context = Rc::new(
861            SoftwareRenderingContext::new(viewport)
862                .map_err(|e| BrowserError::Init(format!("rendering context failed: {e:?}")))?,
863        );
864
865        let webview_state = Rc::new(RefCell::new(BaoWebViewState::default()));
866        // Propagate console log channel from servo delegate to per-webview state
867        if let Some(tx) = servo_delegate.console_log_tx() {
868            webview_state.borrow_mut().console_log_tx = Some(tx);
869        }
870        // @trace REQ-BRW-004 [criterion:12..17] CRIT-STL-WK stealth consistency
871        // Auto-populate worker_scope_config from the page's StealthProfile so that
872        // Workers spawned from this page inherit identical navigator/Canvas/WebGL/Audio
873        // fingerprints. Without this, WorkerScopeConfig defaults to stealth_profile: None
874        // and Workers would see servo's native fingerprint values instead.
875        if let Some(ref profile) = config.stealth_profile {
876            webview_state.borrow_mut().set_worker_scope_config(
877                crate::delegate::WorkerScopeConfig::from(profile as &bao_stealth::StealthProfile),
878            );
879        }
880        let webview_delegate =
881            Rc::new(BaoWebViewDelegate::new(Rc::clone(&webview_state), viewport));
882        let state = Rc::new(RefCell::new(PageState::Created));
883
884        let mut builder = WebViewBuilder::new(
885            &servo,
886            rendering_context.clone() as Rc<dyn RenderingContext>,
887        )
888        .delegate(Rc::clone(&webview_delegate) as Rc<dyn servo::WebViewDelegate>);
889
890        // Wire a per-page UserContentManager so CDP
891        // Page.addScriptToEvaluateOnNewDocument can register scripts that
892        // servo replays on every future document load (navigation replay —
893        // the servo-native facility, not a bao-side re-injection loop).
894        let user_content_manager = Rc::new(servo::UserContentManager::new(&servo));
895        builder = builder.user_content_manager(Rc::clone(&user_content_manager));
896
897        if let Some(ref url_str) = config.url {
898            let url = url::Url::parse(url_str)
899                .map_err(|e| BrowserError::Init(format!("invalid URL: {e}")))?;
900            builder = builder.url(url);
901        }
902
903        let webview = builder.build();
904
905        let inner = PageInner {
906            id,
907            webview,
908            servo: Rc::clone(&servo),
909            rendering_context,
910            delegate: webview_delegate,
911            state,
912            webview_state,
913            nav_seq: ::std::cell::Cell::new(0),
914            viewport,
915            stealth_profile: config.stealth_profile.clone(),
916            permission: match &config.permission {
917                Some(perm) => PermissionGuard::new(perm.clone()),
918                None => PermissionGuard::none(),
919            },
920            user_content_manager: Some(user_content_manager),
921            last_active_at: RefCell::new(Instant::now()),
922            created_at: Instant::now(),
923            node_realm_global: RefCell::new(std::ptr::null_mut()),
924            page_global: RefCell::new(std::ptr::null_mut()),
925        };
926
927        Ok(PageHandle {
928            inner: Rc::new(RefCell::new(Some(inner))),
929            id,
930            servo,
931            delegate: servo_delegate,
932        })
933    }
934
935    pub fn id(&self) -> usize {
936        self.id
937    }
938
939    pub fn navigate(&self, url: &str) -> Result<(), BrowserError> {
940        self.with_inner(|inner| inner.navigate(url))
941    }
942
943    /// Wait for servo's WebView pipeline to be ready for script evaluation.
944    ///
945    /// After `pool.create_page()`, servo's constellation hasn't finished setting
946    /// up the script thread pipeline. Calling `evaluate_js_web` too early causes
947    /// SIGSEGV. This method waits for `frame_ready` callback from servo, then
948    /// verifies pipeline readiness via drain_callbacks.
949    ///
950    /// Uses event-driven notification via `notify_new_frame_ready` callback
951    /// instead of sleep polling.
952    // @trace REQ-BRW-001 [entity:PageHandle] [sm:PageLifecycle]
953    pub fn wait_for_pipeline_ready(&self, timeout: Duration) -> Result<(), BrowserError> {
954        let start = Instant::now();
955
956        // Event-driven wait: spin event loop until frame_ready callback fires.
957        // The callback sets frame_ready = true via notify_new_frame_ready.
958        while start.elapsed() < timeout {
959            // Check frame_ready flag first (fast path, no sleep needed)
960            let ready = self
961                .with_inner_opt(|inner| Some(inner.webview_state.borrow().frame_ready))
962                .unwrap_or(false);
963            if ready {
964                // Frame ready — verify pipeline by draining callbacks.
965                // BCE (pump-contract restore, 2026-08-19): the first frame is
966                // the INITIAL page's (about:blank); returning here left a
967                // pending navigation with no event-loop driver, so the
968                // verbatim README path1 (navigate → wait → evaluate) read a
969                // permanently blank page (the #48-era green was a race —
970                // archaeology shows no historical build ever drove the load
971                // to completion either). Phase 2 below keeps driving servo
972                // until the navigation's load completes (LoadStatus::Complete)
973                // or the shared timeout expires. Never-navigated pages
974                // (nav_seq == 0) keep the old first-frame contract, so
975                // create_page→wait callers are unaffected.
976                let nav_seq = self
977                    .with_inner_opt(|inner| Some(inner.nav_seq.get()))
978                    .unwrap_or(0);
979                if nav_seq > 0 {
980                    while start.elapsed() < timeout {
981                        let loaded = self
982                            .with_inner_opt(|inner| {
983                                Some(
984                                    inner.webview_state.borrow().load_status
985                                        == servo::LoadStatus::Complete,
986                                )
987                            })
988                            .unwrap_or(false);
989                        if loaded {
990                            break;
991                        }
992                        self.with_inner(|inner| {
993                            inner.servo.spin_event_loop();
994                            Ok(())
995                        })?;
996                        std::thread::yield_now();
997                    }
998                }
999                return self.drain_callbacks().map(|_| ());
1000            }
1001
1002            // Not ready yet — spin event loop to process servo messages.
1003            self.with_inner(|inner| {
1004                inner.servo.spin_event_loop();
1005                Ok(())
1006            })?;
1007
1008            // Yield briefly to avoid CPU spinning (servo event loop is non-blocking).
1009            std::thread::yield_now();
1010        }
1011
1012        Err(BrowserError::Init(
1013            "pipeline not ready after timeout".into(),
1014        ))
1015    }
1016
1017    pub fn drain_callbacks(&self) -> Result<String, BrowserError> {
1018        self.with_inner(|inner| inner.drain_callbacks())
1019    }
1020
1021    /// Evaluate JS with Node API injection (trusted context).
1022    /// Node Realm is eagerly initialized at page creation time (REQ-SEC-002).
1023    pub fn evaluate_js(&self, script: &str) -> Result<String, BrowserError> {
1024        self.with_inner(|inner| inner.evaluate_js(script))
1025    }
1026
1027    /// Evaluate JS without Node API injection — web-only mode.
1028    ///
1029    /// Public for security verification: tests need to confirm that
1030    /// page-level JS cannot access Node APIs (REQ-SEC-002/003).
1031    pub fn evaluate_js_web(&self, script: &str) -> Result<String, BrowserError> {
1032        self.with_inner(|inner| inner.evaluate_js_web(script))
1033    }
1034
1035    /// Register a script that servo replays on every future document load of
1036    /// this page (CDP Page.addScriptToEvaluateOnNewDocument backing).
1037    ///
1038    /// Real navigation replay through servo's UserContentManager: the script
1039    /// is added to the page's user-content set and executed by the script
1040    /// thread when each new document is created. Takes effect from the next
1041    /// navigation (servo applies user-content updates on reload/navigation).
1042    pub fn add_script_to_evaluate_on_new_document(&self, source: &str) -> Result<(), BrowserError> {
1043        self.with_inner(|inner| {
1044            let ucm = inner
1045                .user_content_manager
1046                .clone()
1047                .ok_or_else(|| BrowserError::Init("page has no UserContentManager".into()))?;
1048            ucm.add_script(Rc::new(servo::UserScript::new(source.to_string(), None)));
1049            Ok(())
1050        })
1051    }
1052
1053    pub fn take_screenshot(&self, format: ScreenshotFormat) -> Result<Vec<u8>, BrowserError> {
1054        self.with_inner(|inner| inner.take_screenshot(format))
1055    }
1056
1057    pub fn page_title(&self) -> Option<String> {
1058        self.with_inner_opt(|inner| inner.page_title())
1059    }
1060
1061    pub fn current_url(&self) -> Option<String> {
1062        self.with_inner_opt(|inner| inner.current_url())
1063    }
1064
1065    /// Access the Servo instance for this page (e.g. SiteDataManager, NetworkManager).
1066    /// Used by CDP domain handlers to access cookie/cache/network APIs.
1067    pub fn servo(&self) -> &Rc<Servo> {
1068        &self.servo
1069    }
1070
1071    pub fn get_state(&self) -> PageState {
1072        self.inner
1073            .borrow()
1074            .as_ref()
1075            .map_or(PageState::Closed, |inner| inner.get_state())
1076    }
1077
1078    pub fn is_alive(&self) -> bool {
1079        self.inner.borrow().is_some()
1080    }
1081
1082    pub(crate) fn webview_id(&self) -> Option<servo::WebViewId> {
1083        self.inner.borrow().as_ref().map(|inner| inner.webview.id())
1084    }
1085
1086    pub fn permission(&self) -> PermissionGuard {
1087        let borrow = self.inner.borrow();
1088        match borrow.as_ref() {
1089            Some(inner) => inner.permission.clone(),
1090            None => PermissionGuard::none(),
1091        }
1092    }
1093
1094    pub fn stealth_profile(&self) -> Option<bao_stealth::StealthProfile> {
1095        self.inner
1096            .borrow()
1097            .as_ref()
1098            .and_then(|inner| inner.stealth_profile.clone())
1099    }
1100
1101    /// Access the page's BaoWebViewState for Worker lifecycle management.
1102    ///
1103    /// Used by BaoRuntime::create_worker to access Worker tracking,
1104    /// channel bridges, and scope states.
1105    ///
1106    /// @trace REQ-BRW-004 [entity:Worker] [criterion:10]
1107    pub fn webview_state(&self) -> Rc<RefCell<BaoWebViewState>> {
1108        self.inner
1109            .borrow()
1110            .as_ref()
1111            .map(|inner| inner.webview_state.clone())
1112            .unwrap_or_else(|| Rc::new(RefCell::new(BaoWebViewState::default())))
1113    }
1114
1115    // ── High-level PageHandle API (REQ-LIB-001, REQ-LIB-004) ──────────────
1116
1117    /// Wait for an element matching the selector to appear in the DOM.
1118    pub fn wait_for_selector(&self, selector: &str, timeout: Duration) -> Result<(), BrowserError> {
1119        self.with_inner(|inner| inner.wait_for_selector(selector, timeout))
1120    }
1121
1122    /// Wait for a JS function/condition to return a truthy value.
1123    pub fn wait_for_function(
1124        &self,
1125        fn_expression: &str,
1126        timeout: Duration,
1127    ) -> Result<(), BrowserError> {
1128        self.with_inner(|inner| inner.wait_for_function(fn_expression, timeout))
1129    }
1130
1131    /// Wait for page navigation to complete (URL change + load complete).
1132    pub fn wait_for_navigation(&self, timeout: Duration) -> Result<(), BrowserError> {
1133        self.with_inner(|inner| inner.wait_for_navigation(timeout))
1134    }
1135
1136    /// Click an element matching the selector.
1137    pub fn click(&self, selector: &str) -> Result<(), BrowserError> {
1138        self.with_inner(|inner| inner.click_element(selector))
1139    }
1140
1141    /// Type text into the currently focused element by dispatching key events.
1142    pub fn type_text(&self, text: &str) -> Result<(), BrowserError> {
1143        self.with_inner(|inner| inner.type_text(text))
1144    }
1145
1146    /// Fill a form field identified by selector with the given value.
1147    pub fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError> {
1148        self.with_inner(|inner| inner.fill(selector, value))
1149    }
1150
1151    /// Set the page HTML content via document.open/write/close.
1152    pub fn set_content(&self, html: &str) -> Result<(), BrowserError> {
1153        self.with_inner(|inner| inner.set_content(html))
1154    }
1155
1156    /// Get the page HTML content via document.documentElement.outerHTML.
1157    pub fn content(&self) -> Result<String, BrowserError> {
1158        self.with_inner(|inner| inner.content())
1159    }
1160
1161    /// Set viewport size.
1162    pub fn set_viewport(&self, width: u32, height: u32) -> Result<(), BrowserError> {
1163        self.with_inner(|inner| {
1164            inner.set_viewport(width, height);
1165            Ok(())
1166        })
1167    }
1168
1169    /// Get cookies for the given URLs (or current page URL if empty).
1170    pub fn cookies(&self, urls: &[String]) -> Result<Vec<cookie::Cookie<'static>>, BrowserError> {
1171        self.with_inner(|inner| inner.cookies(urls))
1172    }
1173
1174    /// Set a cookie for the given URL.
1175    pub fn set_cookie(
1176        &self,
1177        url: &str,
1178        cookie: cookie::Cookie<'static>,
1179    ) -> Result<(), BrowserError> {
1180        self.with_inner(|inner| inner.set_cookie(url, cookie))
1181    }
1182
1183    /// Delete cookies matching the given name for the given URL.
1184    pub fn delete_cookie(&self, name: &str, url: Option<&str>) -> Result<(), BrowserError> {
1185        self.with_inner(|inner| inner.delete_cookie(name, url))
1186    }
1187
1188    /// Select options in a <select> element identified by selector.
1189    pub fn select(&self, selector: &str, values: &[&str]) -> Result<(), BrowserError> {
1190        self.with_inner(|inner| inner.select(selector, values))
1191    }
1192
1193    /// Press a key (e.g. "Enter", "Tab", "ArrowDown").
1194    pub fn press(&self, key: &str) -> Result<(), BrowserError> {
1195        self.with_inner(|inner| inner.press(key))
1196    }
1197
1198    /// Hover over an element matching the selector.
1199    pub fn hover(&self, selector: &str) -> Result<(), BrowserError> {
1200        self.with_inner(|inner| inner.hover(selector))
1201    }
1202
1203    /// Focus an element matching the selector.
1204    pub fn focus_element(&self, selector: &str) -> Result<(), BrowserError> {
1205        self.with_inner(|inner| inner.focus_element(selector))
1206    }
1207
1208    /// Reload the page.
1209    pub fn reload(&self) -> Result<(), BrowserError> {
1210        self.with_inner(|inner| inner.reload())
1211    }
1212
1213    /// Navigate back in history.
1214    pub fn go_back(&self) -> Result<(), BrowserError> {
1215        self.with_inner(|inner| inner.go_back())
1216    }
1217
1218    /// Navigate forward in history.
1219    pub fn go_forward(&self) -> Result<(), BrowserError> {
1220        self.with_inner(|inner| inner.go_forward())
1221    }
1222
1223    /// Check if back navigation is possible.
1224    pub fn can_go_back(&self) -> bool {
1225        self.with_inner_opt(|inner| Some(inner.can_go_back()))
1226            .unwrap_or(false)
1227    }
1228
1229    /// Check if forward navigation is possible.
1230    pub fn can_go_forward(&self) -> bool {
1231        self.with_inner_opt(|inner| Some(inner.can_go_forward()))
1232            .unwrap_or(false)
1233    }
1234
1235    /// Take screenshot with optional clip region and fullPage mode.
1236    pub fn take_screenshot_advanced(
1237        &self,
1238        format: ScreenshotFormat,
1239        clip: Option<(f64, f64, f64, f64)>,
1240        full_page: bool,
1241    ) -> Result<Vec<u8>, BrowserError> {
1242        self.with_inner(|inner| inner.take_screenshot_advanced(format, clip, full_page))
1243    }
1244
1245    /// Dispatch a mouse button event at the given page coordinates.
1246    pub fn dispatch_mouse_event(
1247        &self,
1248        action: MouseButtonAction,
1249        button: MouseButton,
1250        x: f32,
1251        y: f32,
1252    ) -> Result<(), BrowserError> {
1253        self.with_inner(|inner| {
1254            inner.dispatch_mouse_event(action, button, x, y);
1255            Ok(())
1256        })
1257    }
1258
1259    /// Dispatch a mouse move event at the given page coordinates.
1260    pub fn dispatch_mouse_move(&self, x: f32, y: f32) -> Result<(), BrowserError> {
1261        self.with_inner(|inner| {
1262            inner.dispatch_mouse_move(x, y);
1263            Ok(())
1264        })
1265    }
1266
1267    /// Dispatch a keyboard event.
1268    pub fn dispatch_key_event(
1269        &self,
1270        state: KeyState,
1271        key: Key,
1272        code: Code,
1273    ) -> Result<(), BrowserError> {
1274        self.with_inner(|inner| {
1275            inner.dispatch_key_event(state, key, code);
1276            Ok(())
1277        })
1278    }
1279
1280    pub fn close(&self) -> Result<(), BrowserError> {
1281        let mut borrow = self.inner.borrow_mut();
1282        if let Some(inner) = borrow.take() {
1283            // SM PageLifecycle (SPEC 03-PROCESS): transition to Closing FIRST,
1284            // before any cleanup. Covers: close_during_load / idle_ttl_expired / close.
1285            // @trace REQ-BRW-001 [sm:PageLifecycle] criterion: Closing state
1286            *inner.state.borrow_mut() = PageState::Closing;
1287            // @trace REQ-BRW-004 [entity:Worker] [criterion:10]
1288            // SPEC criterion #10: "页面卸载时自动终止所有 Worker
1289            // (GlobalScope::track_worker + AutoCloseWorker)".
1290            // Explicitly terminate all Workers BEFORE dropping PageInner,
1291            // ensuring correct teardown order:
1292            //   1. Set closing flags + unregister stealth profiles (while JSContext alive)
1293            //   2. Drop WebWorker instances (join threads)
1294            //   3. AutoCloseWorker::Drop runs as idempotent cleanup
1295            // Without this, BaoWebViewState field Drop order would drop web_workers
1296            // (which joins threads) BEFORE active_workers (AutoCloseWorker), causing
1297            // stealth profile unregistration to happen after thread exit.
1298            {
1299                let mut ws = inner.webview_state.borrow_mut();
1300                if ws.active_worker_count() > 0 {
1301                    log::debug!(
1302                        "[page] close: terminating {} active workers",
1303                        ws.active_worker_count()
1304                    );
1305                    ws.terminate_all_workers();
1306                }
1307            }
1308            let pg = *inner.page_global.borrow();
1309            let ng = *inner.node_realm_global.borrow();
1310            // BCE-20260621-001: remove per-page Node Realm entries via WebViewId
1311            // (NOT raw *mut JSObject). The raw pointers are kept locally only to
1312            // drop the stealth profile mappings.
1313            if let Some(wid) = inner.webview_id_opt() {
1314                crate::runtime_bridge::remove_node_realm_by_id(wid);
1315            }
1316            if !pg.is_null() {
1317                // BUG-ENG-366: drop the per-Realm stealth profiles so the next
1318                // page reusing the same global address does not inherit a stale
1319                // fingerprint. @trace REQ-SEC-002 [req:REQ-SEC-002] [req:BUG-ENG-366]
1320                bao_stealth::engine_props::remove_profile_for_global(pg as usize);
1321            }
1322            if !ng.is_null() {
1323                bao_stealth::engine_props::remove_profile_for_global(ng as usize);
1324            }
1325            // SM PageLifecycle: cleanup_complete → Closed
1326            // @trace REQ-BRW-001 [sm:PageLifecycle] criterion: cleanup_complete transition
1327            *inner.state.borrow_mut() = PageState::Closed;
1328            drop(inner);
1329        }
1330        Ok(())
1331    }
1332
1333    fn with_inner<F, R>(&self, f: F) -> Result<R, BrowserError>
1334    where
1335        F: FnOnce(&PageInner) -> Result<R, BrowserError>,
1336    {
1337        let borrow = self.inner.borrow();
1338        match borrow.as_ref() {
1339            Some(inner) => f(inner),
1340            None => Err(BrowserError::Init("page is closed".into())),
1341        }
1342    }
1343
1344    /// Store page_global and node_realm_global pointers in PageInner (REQ-SEC-002).
1345    /// Called by runtime_bridge after drain_callbacks populates the per-page HashMap.
1346    pub fn set_page_global(
1347        &self,
1348        page_global: *mut mozjs::jsapi::JSObject,
1349        node_global: *mut mozjs::jsapi::JSObject,
1350    ) {
1351        let borrow = self.inner.borrow();
1352        if let Some(inner) = borrow.as_ref() {
1353            *inner.page_global.borrow_mut() = page_global;
1354            *inner.node_realm_global.borrow_mut() = node_global;
1355        }
1356    }
1357
1358    /// Check whether the Node Realm was successfully created for this page.
1359    /// Returns (page_global_set, node_realm_set) — both should be true after
1360    /// `inject_node_apis_with_stealth` completes successfully.
1361    pub fn has_node_realm(&self) -> (bool, bool) {
1362        let borrow = self.inner.borrow();
1363        if let Some(inner) = borrow.as_ref() {
1364            let pg = *inner.page_global.borrow();
1365            let ng = *inner.node_realm_global.borrow();
1366            return (!pg.is_null(), !ng.is_null());
1367        }
1368        (false, false)
1369    }
1370
1371    fn with_inner_opt<F, R>(&self, f: F) -> Option<R>
1372    where
1373        F: FnOnce(&PageInner) -> Option<R>,
1374    {
1375        let borrow = self.inner.borrow();
1376        borrow.as_ref().and_then(f)
1377    }
1378}
1379
1380fn format_js_value(v: &servo::JSValue) -> String {
1381    match v {
1382        servo::JSValue::String(s) => s.clone(),
1383        servo::JSValue::Number(n) => n.to_string(),
1384        servo::JSValue::Boolean(b) => b.to_string(),
1385        servo::JSValue::Null => "null".into(),
1386        servo::JSValue::Undefined => "undefined".into(),
1387        servo::JSValue::Element(id) => format!("[Element: {id}]"),
1388        servo::JSValue::ShadowRoot(id) => format!("[ShadowRoot: {id}]"),
1389        servo::JSValue::Frame(id) => format!("[Frame: {id}]"),
1390        servo::JSValue::Window(id) => format!("[Window: {id}]"),
1391        servo::JSValue::Array(items) => {
1392            let formatted: Vec<String> = items.iter().map(format_js_value).collect();
1393            format!("[{}]", formatted.join(", "))
1394        }
1395        servo::JSValue::Object(map) => {
1396            let formatted: Vec<String> = map
1397                .iter()
1398                .map(|(k, val)| format!("{}: {}", k, format_js_value(val)))
1399                .collect();
1400            format!("{{{}}}", formatted.join(", "))
1401        }
1402    }
1403}
1404
1405/// Map a character to its keyboard Code value for type_text dispatch.
1406fn key_code_for_char(ch: char) -> Code {
1407    match ch {
1408        'a' => Code::KeyA,
1409        'b' => Code::KeyB,
1410        'c' => Code::KeyC,
1411        'd' => Code::KeyD,
1412        'e' => Code::KeyE,
1413        'f' => Code::KeyF,
1414        'g' => Code::KeyG,
1415        'h' => Code::KeyH,
1416        'i' => Code::KeyI,
1417        'j' => Code::KeyJ,
1418        'k' => Code::KeyK,
1419        'l' => Code::KeyL,
1420        'm' => Code::KeyM,
1421        'n' => Code::KeyN,
1422        'o' => Code::KeyO,
1423        'p' => Code::KeyP,
1424        'q' => Code::KeyQ,
1425        'r' => Code::KeyR,
1426        's' => Code::KeyS,
1427        't' => Code::KeyT,
1428        'u' => Code::KeyU,
1429        'v' => Code::KeyV,
1430        'w' => Code::KeyW,
1431        'x' => Code::KeyX,
1432        'y' => Code::KeyY,
1433        'z' => Code::KeyZ,
1434        'A' => Code::KeyA,
1435        'B' => Code::KeyB,
1436        'C' => Code::KeyC,
1437        'D' => Code::KeyD,
1438        'E' => Code::KeyE,
1439        'F' => Code::KeyF,
1440        'G' => Code::KeyG,
1441        'H' => Code::KeyH,
1442        'I' => Code::KeyI,
1443        'J' => Code::KeyJ,
1444        'K' => Code::KeyK,
1445        'L' => Code::KeyL,
1446        'M' => Code::KeyM,
1447        'N' => Code::KeyN,
1448        'O' => Code::KeyO,
1449        'P' => Code::KeyP,
1450        'Q' => Code::KeyQ,
1451        'R' => Code::KeyR,
1452        'S' => Code::KeyS,
1453        'T' => Code::KeyT,
1454        'U' => Code::KeyU,
1455        'V' => Code::KeyV,
1456        'W' => Code::KeyW,
1457        'X' => Code::KeyX,
1458        'Y' => Code::KeyY,
1459        'Z' => Code::KeyZ,
1460        '0' => Code::Digit0,
1461        '1' => Code::Digit1,
1462        '2' => Code::Digit2,
1463        '3' => Code::Digit3,
1464        '4' => Code::Digit4,
1465        '5' => Code::Digit5,
1466        '6' => Code::Digit6,
1467        '7' => Code::Digit7,
1468        '8' => Code::Digit8,
1469        '9' => Code::Digit9,
1470        '\n' => Code::Enter,
1471        '\t' => Code::Tab,
1472        '\u{8}' => Code::Backspace,
1473        '\u{7f}' => Code::Delete,
1474        ' ' => Code::Space,
1475        ';' => Code::Semicolon,
1476        '=' => Code::Equal,
1477        ',' => Code::Comma,
1478        '-' => Code::Minus,
1479        '.' => Code::Period,
1480        '/' => Code::Slash,
1481        '`' => Code::Backquote,
1482        '[' => Code::BracketLeft,
1483        '\\' => Code::Backslash,
1484        ']' => Code::BracketRight,
1485        '\'' => Code::Quote,
1486        _ => Code::Unidentified,
1487    }
1488}
1489
1490/// Parse a key name string (e.g. "Enter", "ArrowDown", "a") into (Key, Code).
1491fn parse_key_name(name: &str) -> (Key, Code) {
1492    match name {
1493        "Enter" => (Key::Named(NamedKey::Enter), Code::Enter),
1494        "Tab" => (Key::Named(NamedKey::Tab), Code::Tab),
1495        "Escape" | "Esc" => (Key::Named(NamedKey::Escape), Code::Escape),
1496        "Backspace" => (Key::Named(NamedKey::Backspace), Code::Backspace),
1497        "Delete" => (Key::Named(NamedKey::Delete), Code::Delete),
1498        "Space" => (Key::Character(" ".into()), Code::Space),
1499        "ArrowUp" => (Key::Named(NamedKey::ArrowUp), Code::ArrowUp),
1500        "ArrowDown" => (Key::Named(NamedKey::ArrowDown), Code::ArrowDown),
1501        "ArrowLeft" => (Key::Named(NamedKey::ArrowLeft), Code::ArrowLeft),
1502        "ArrowRight" => (Key::Named(NamedKey::ArrowRight), Code::ArrowRight),
1503        "Home" => (Key::Named(NamedKey::Home), Code::Home),
1504        "End" => (Key::Named(NamedKey::End), Code::End),
1505        "PageUp" => (Key::Named(NamedKey::PageUp), Code::PageUp),
1506        "PageDown" => (Key::Named(NamedKey::PageDown), Code::PageDown),
1507        "Insert" => (Key::Named(NamedKey::Insert), Code::Insert),
1508        "F1" => (Key::Named(NamedKey::F1), Code::F1),
1509        "F2" => (Key::Named(NamedKey::F2), Code::F2),
1510        "F3" => (Key::Named(NamedKey::F3), Code::F3),
1511        "F4" => (Key::Named(NamedKey::F4), Code::F4),
1512        "F5" => (Key::Named(NamedKey::F5), Code::F5),
1513        "F6" => (Key::Named(NamedKey::F6), Code::F6),
1514        "F7" => (Key::Named(NamedKey::F7), Code::F7),
1515        "F8" => (Key::Named(NamedKey::F8), Code::F8),
1516        "F9" => (Key::Named(NamedKey::F9), Code::F9),
1517        "F10" => (Key::Named(NamedKey::F10), Code::F10),
1518        "F11" => (Key::Named(NamedKey::F11), Code::F11),
1519        "F12" => (Key::Named(NamedKey::F12), Code::F12),
1520        "ControlLeft" | "Control" => (Key::Named(NamedKey::Control), Code::ControlLeft),
1521        "ControlRight" => (Key::Named(NamedKey::Control), Code::ControlRight),
1522        "ShiftLeft" | "Shift" => (Key::Named(NamedKey::Shift), Code::ShiftLeft),
1523        "ShiftRight" => (Key::Named(NamedKey::Shift), Code::ShiftRight),
1524        "AltLeft" | "Alt" => (Key::Named(NamedKey::Alt), Code::AltLeft),
1525        "AltRight" => (Key::Named(NamedKey::Alt), Code::AltRight),
1526        "MetaLeft" | "Meta" => (Key::Named(NamedKey::Meta), Code::MetaLeft),
1527        "MetaRight" => (Key::Named(NamedKey::Meta), Code::MetaRight),
1528        "CapsLock" => (Key::Named(NamedKey::CapsLock), Code::CapsLock),
1529        "NumLock" => (Key::Named(NamedKey::NumLock), Code::NumLock),
1530        "ScrollLock" => (Key::Named(NamedKey::ScrollLock), Code::ScrollLock),
1531        // Single character
1532        s if s.chars().count() == 1 => {
1533            let ch = s.chars().next().unwrap();
1534            let key = Key::Character(ch.to_string());
1535            let code = key_code_for_char(ch);
1536            (key, code)
1537        }
1538        // Fallback: treat as character key
1539        s => (Key::Character(s.to_string()), Code::Unidentified),
1540    }
1541}
1542
1543// @trace REQ-BRW-001 REQ-BRW-002 [req:REQ-BRW-001,REQ-BRW-002] [level:unit]
1544#[cfg(test)]
1545mod tests {
1546    use super::*;
1547
1548    #[test]
1549    fn page_state_variants_equal_to_themselves() {
1550        assert_eq!(PageState::Created, PageState::Created);
1551        assert_eq!(PageState::Navigating, PageState::Navigating);
1552        assert_eq!(PageState::Interactive, PageState::Interactive);
1553        assert_eq!(PageState::Idle, PageState::Idle);
1554        assert_eq!(PageState::Closing, PageState::Closing);
1555        assert_eq!(PageState::Closed, PageState::Closed);
1556    }
1557
1558    #[test]
1559    fn page_state_clone_works() {
1560        let state = PageState::Navigating;
1561        let cloned = state.clone();
1562        assert_eq!(state, cloned);
1563    }
1564
1565    #[test]
1566    fn page_state_copy_works() {
1567        let state = PageState::Interactive;
1568        let copied: PageState = state;
1569        assert_eq!(state, copied);
1570    }
1571
1572    #[test]
1573    fn page_state_debug_format_includes_variant_name() {
1574        assert!(format!("{:?}", PageState::Created).contains("Created"));
1575        assert!(format!("{:?}", PageState::Navigating).contains("Navigating"));
1576        assert!(format!("{:?}", PageState::Interactive).contains("Interactive"));
1577        assert!(format!("{:?}", PageState::Idle).contains("Idle"));
1578        assert!(format!("{:?}", PageState::Closing).contains("Closing"));
1579        assert!(format!("{:?}", PageState::Closed).contains("Closed"));
1580    }
1581
1582    #[test]
1583    fn page_state_closing_distinct_from_neighbors() {
1584        // SM PageLifecycle: Closing is a distinct intermediate state, not equal
1585        // to its entry (Idle/Interactive/Navigating) or exit (Closed) neighbors.
1586        assert_ne!(PageState::Closing, PageState::Idle);
1587        assert_ne!(PageState::Closing, PageState::Interactive);
1588        assert_ne!(PageState::Closing, PageState::Closed);
1589    }
1590
1591    #[test]
1592    fn page_state_created_not_equal_closed() {
1593        assert_ne!(PageState::Created, PageState::Closed);
1594    }
1595
1596    #[test]
1597    fn format_js_value_string() {
1598        let value = servo::JSValue::String("hello".into());
1599        assert_eq!(format_js_value(&value), "hello");
1600    }
1601
1602    #[test]
1603    fn format_js_value_number() {
1604        let value = servo::JSValue::Number(42.5);
1605        assert_eq!(format_js_value(&value), "42.5");
1606    }
1607
1608    #[test]
1609    fn format_js_value_boolean_true() {
1610        let value = servo::JSValue::Boolean(true);
1611        assert_eq!(format_js_value(&value), "true");
1612    }
1613
1614    #[test]
1615    fn format_js_value_null() {
1616        let value = servo::JSValue::Null;
1617        assert_eq!(format_js_value(&value), "null");
1618    }
1619
1620    #[test]
1621    fn format_js_value_undefined() {
1622        let value = servo::JSValue::Undefined;
1623        assert_eq!(format_js_value(&value), "undefined");
1624    }
1625
1626    #[test]
1627    fn format_js_value_array() {
1628        let value = servo::JSValue::Array(vec![
1629            servo::JSValue::Number(1.0),
1630            servo::JSValue::Number(2.0),
1631            servo::JSValue::Number(3.0),
1632        ]);
1633        assert_eq!(format_js_value(&value), "[1, 2, 3]");
1634    }
1635
1636    #[test]
1637    fn format_js_value_object() {
1638        let mut map = HashMap::new();
1639        map.insert("name".into(), servo::JSValue::String("test".into()));
1640        map.insert("count".into(), servo::JSValue::Number(5.0));
1641        let value = servo::JSValue::Object(map);
1642        let result = format_js_value(&value);
1643        assert!(result.starts_with('{') && result.ends_with('}'));
1644        assert!(result.contains("name: test"));
1645        assert!(result.contains("count: 5"));
1646    }
1647
1648    #[test]
1649    fn format_js_value_element() {
1650        let value = servo::JSValue::Element("div#main".into());
1651        assert_eq!(format_js_value(&value), "[Element: div#main]");
1652    }
1653
1654    #[test]
1655    fn format_js_value_shadow_root() {
1656        let value = servo::JSValue::ShadowRoot("host-element".into());
1657        assert_eq!(format_js_value(&value), "[ShadowRoot: host-element]");
1658    }
1659
1660    #[test]
1661    fn format_js_value_frame() {
1662        let value = servo::JSValue::Frame("iframe-123".into());
1663        assert_eq!(format_js_value(&value), "[Frame: iframe-123]");
1664    }
1665
1666    #[test]
1667    fn format_js_value_window() {
1668        let value = servo::JSValue::Window("window-456".into());
1669        assert_eq!(format_js_value(&value), "[Window: window-456]");
1670    }
1671
1672    // ── REQ-SEC-002/003: IIFE-scoped Node API isolation verification ──
1673    // @trace TEST-SEC-002 [req:REQ-SEC-002,REQ-SEC-003] [level:unit]
1674    // Security model: evaluate_js wraps scripts in IIFE with Node API parameters.
1675    // Node APIs (require, process, Buffer, etc.) are IIFE parameters, not global vars.
1676    // After IIFE returns, the parameters are gone — page JS cannot see them.
1677
1678    /// Verify evaluate_js uses Node Realm execution when available (REQ-SEC-002).
1679    /// Falls back to IIFE injection when Node Realm is not initialized.
1680    #[test]
1681    fn evaluate_js_uses_node_realm_or_iife_fallback() {
1682        let source = include_str!("page.rs");
1683        let func_start = source
1684            .find("pub fn evaluate_js(&self, script: &str)")
1685            .expect("evaluate_js function not found");
1686        let func_body = &source[func_start..func_start + 2800.min(source.len() - func_start)];
1687        // Must check Node Realm availability
1688        assert!(
1689            func_body.contains("get_node_realm_global"),
1690            "REQ-SEC-002 REGRESSION: evaluate_js must check Node Realm global"
1691        );
1692        // Must use Node Realm execution path
1693        assert!(
1694            func_body.contains("evaluate_js_via_node_realm"),
1695            "REQ-SEC-002 REGRESSION: evaluate_js must use Node Realm execution"
1696        );
1697        // Must detect null Node Realm as programming error (eager init at create_page)
1698        assert!(
1699            func_body.contains("eager init failed"),
1700            "REQ-SEC-002 REGRESSION: evaluate_js must detect uninitialized Node Realm"
1701        );
1702    }
1703
1704    /// Verify evaluate_js drain callbacks after Node Realm execution.
1705    /// REQ-SEC-002: Results must be read after servo script thread callback.
1706    #[test]
1707    fn evaluate_js_drains_callbacks_for_result() {
1708        let source = include_str!("page.rs");
1709        let func_start = source
1710            .find("pub fn evaluate_js(&self, script: &str)")
1711            .expect("evaluate_js function not found");
1712        let func_body = &source[func_start..func_start + 2800.min(source.len() - func_start)];
1713        assert!(
1714            func_body.contains("drain_callbacks"),
1715            "REQ-SEC-002 REGRESSION: evaluate_js must drain callbacks after Node Realm execution"
1716        );
1717    }
1718
1719    /// Verify evaluate_js reads result from shared EvaluateResult.
1720    /// REQ-SEC-002: Result must come from Arc<OnceLock<EvaluateResult>>.
1721    #[test]
1722    fn evaluate_js_reads_evaluate_result() {
1723        let source = include_str!("page.rs");
1724        let func_start = source
1725            .find("pub fn evaluate_js(&self, script: &str)")
1726            .expect("evaluate_js function not found");
1727        let func_body = &source[func_start..func_start + 2800.min(source.len() - func_start)];
1728        assert!(
1729            func_body.contains("eval_result"),
1730            "REQ-SEC-002 REGRESSION: evaluate_js must read EvaluateResult"
1731        );
1732    }
1733
1734    /// Verify Node APIs are NOT installed on page global by install_all_native.
1735    /// REQ-SEC-003: install_all_native must NOT call install_node_apis or install_all.
1736    #[test]
1737    fn page_global_has_no_node_apis() {
1738        let source = include_str!("runtime_bridge.rs");
1739        let func_start = source
1740            .find("unsafe fn install_all_native")
1741            .expect("install_all_native function not found");
1742        let func_body = &source[func_start..func_start + 5000.min(source.len() - func_start)];
1743
1744        assert!(
1745            func_body.contains("bun_runtime::fetch_api::install_fetch_global"),
1746            "REQ-SEC-003 REGRESSION: install_all_native must install Web APIs (fetch)"
1747        );
1748        assert!(
1749            func_body.contains("bun_runtime::timers::install_timer_globals"),
1750            "REQ-SEC-003 REGRESSION: install_all_native must install Web APIs (timers)"
1751        );
1752        assert!(
1753            !func_body.contains("globals::install_all("),
1754            "REQ-SEC-003 REGRESSION: install_all_native must NOT call install_all()"
1755        );
1756        assert!(
1757            !func_body.contains("globals::install_node_apis("),
1758            "REQ-SEC-003 REGRESSION: install_all_native must NOT call install_node_apis() on page global"
1759        );
1760    }
1761
1762    /// Verify Node APIs are installed on Node Realm global (not page global).
1763    /// REQ-SEC-002: Node Realm has both Node + Web APIs for privileged scripts.
1764    #[test]
1765    fn node_realm_has_node_apis() {
1766        let source = include_str!("runtime_bridge.rs");
1767        let func_start = source
1768            .find("unsafe fn create_node_realm_native")
1769            .expect("create_node_realm_native function not found");
1770        let func_end = source[func_start..]
1771            .find("pub fn inject_node_apis")
1772            .or_else(|| source[func_start..].find("/// Inject Node.js APIs as native"))
1773            .expect("end boundary not found");
1774        let func_body = &source[func_start..func_start + func_end];
1775
1776        assert!(
1777            func_body.contains("bun_runtime::globals::install_node_apis"),
1778            "REQ-SEC-002 REGRESSION: create_node_realm_native must install Node APIs on Node Realm global"
1779        );
1780        assert!(
1781            func_body.contains("bun_runtime::globals::install_web_apis"),
1782            "REQ-SEC-002: Node Realm must also have Web APIs for trusted scripts"
1783        );
1784    }
1785
1786    /// Verify Node Realm is in its own Compartment (NewCompartmentAndZone).
1787    /// REQ-SEC-002: Physical isolation via SpiderMonkey Compartment boundary.
1788    #[test]
1789    fn node_realm_uses_new_compartment() {
1790        let source = include_str!("runtime_bridge.rs");
1791        let func_start = source
1792            .find("unsafe fn create_node_realm_native")
1793            .expect("create_node_realm_native function not found");
1794        let func_body = &source[func_start..func_start + 3000.min(source.len() - func_start)];
1795        assert!(
1796            func_body.contains("NewCompartmentAndZone"),
1797            "REQ-SEC-002 REGRESSION: Node Realm must use NewCompartmentAndZone"
1798        );
1799        assert!(
1800            func_body.contains("SIMPLE_GLOBAL_CLASS"),
1801            "REQ-SEC-002 REGRESSION: Node Realm must use SIMPLE_GLOBAL_CLASS"
1802        );
1803    }
1804
1805    /// Verify evaluate_in_node_realm uses AutoRealm for Compartment isolation.
1806    #[test]
1807    fn evaluate_in_node_realm_uses_auto_realm() {
1808        let source = include_str!("runtime_bridge.rs");
1809
1810        // Locate the evaluate_in_node_realm function body specifically.
1811        let func_start = source
1812            .find("pub unsafe fn evaluate_in_node_realm")
1813            .expect("evaluate_in_node_realm function not found");
1814        let func_body_start = source[func_start..]
1815            .find("{")
1816            .expect("function body start not found");
1817        let search_limit = source[func_start + func_body_start..]
1818            .find("unsafe fn create_node_realm_native")
1819            .unwrap_or(3000)
1820            .min(3000);
1821        let func_body =
1822            &source[func_start + func_body_start..func_start + func_body_start + search_limit];
1823
1824        assert!(
1825            func_body.contains("AutoRealm::new"),
1826            "REQ-SEC-002 REGRESSION: evaluate_in_node_realm must use AutoRealm"
1827        );
1828    }
1829
1830    /// Verify per-page Node Realm storage exists (REQ-SEC-002).
1831    /// BCE-20260621-001: WebViewId-keyed storage (NOT *mut JSObject-keyed).
1832    #[test]
1833    fn node_realm_global_stored_per_page() {
1834        let source = include_str!("runtime_bridge.rs");
1835        assert!(
1836            source.contains("NODE_REALM_BY_WEBVIEW"),
1837            "REQ-SEC-002 REGRESSION: must have NODE_REALM_BY_WEBVIEW per-page storage"
1838        );
1839        assert!(
1840            source.contains("store_node_realm"),
1841            "REQ-SEC-002 REGRESSION: must have store_node_realm accessor"
1842        );
1843        assert!(
1844            source.contains("get_node_realm_by_id"),
1845            "REQ-SEC-002 REGRESSION: must have get_node_realm_by_id accessor"
1846        );
1847        assert!(
1848            source.contains("get_node_realm_global"),
1849            "REQ-SEC-002 REGRESSION: must have get_node_realm_global accessor"
1850        );
1851    }
1852
1853    /// Verify PageInner stores node_realm_global pointer for Node Realm lifecycle.
1854    #[test]
1855    fn page_inner_has_node_realm_global_field() {
1856        let source = include_str!("page.rs");
1857        assert!(
1858            source.contains("node_realm_global: RefCell<*mut mozjs::jsapi::JSObject>"),
1859            "REQ-SEC-002 REGRESSION: PageInner must have node_realm_global field"
1860        );
1861    }
1862
1863    /// Verify drain_callbacks method exists on PageInner.
1864    /// REQ-SEC-002: Callback drain must handle InternalError from pending pipeline.
1865    #[test]
1866    fn page_inner_has_drain_callbacks_method() {
1867        let source = include_str!("page.rs");
1868        assert!(
1869            source.contains("fn drain_callbacks(&self)"),
1870            "REQ-SEC-002 REGRESSION: PageInner must have drain_callbacks method"
1871        );
1872        assert!(
1873            source.contains("InternalError"),
1874            "REQ-SEC-002 REGRESSION: drain_callbacks must handle InternalError retry"
1875        );
1876    }
1877
1878    // ── Key mapping helper tests ──────────────────────────────────────────
1879    // @trace REQ-LIB-001 [req:REQ-LIB-001] [level:unit]
1880
1881    #[test]
1882    fn key_code_for_char_letters() {
1883        assert_eq!(super::key_code_for_char('a'), Code::KeyA);
1884        assert_eq!(super::key_code_for_char('Z'), Code::KeyZ);
1885    }
1886
1887    #[test]
1888    fn key_code_for_char_digits() {
1889        assert_eq!(super::key_code_for_char('0'), Code::Digit0);
1890        assert_eq!(super::key_code_for_char('9'), Code::Digit9);
1891    }
1892
1893    #[test]
1894    fn key_code_for_char_special() {
1895        assert_eq!(super::key_code_for_char('\n'), Code::Enter);
1896        assert_eq!(super::key_code_for_char('\t'), Code::Tab);
1897        assert_eq!(super::key_code_for_char(' '), Code::Space);
1898    }
1899
1900    #[test]
1901    fn parse_key_name_enter() {
1902        let (key, code) = super::parse_key_name("Enter");
1903        assert!(matches!(key, Key::Named(NamedKey::Enter)));
1904        assert_eq!(code, Code::Enter);
1905    }
1906
1907    #[test]
1908    fn parse_key_name_arrow_keys() {
1909        let (key, code) = super::parse_key_name("ArrowDown");
1910        assert!(matches!(key, Key::Named(NamedKey::ArrowDown)));
1911        assert_eq!(code, Code::ArrowDown);
1912
1913        let (key, code) = super::parse_key_name("ArrowUp");
1914        assert!(matches!(key, Key::Named(NamedKey::ArrowUp)));
1915        assert_eq!(code, Code::ArrowUp);
1916    }
1917
1918    #[test]
1919    fn parse_key_name_single_char() {
1920        let (key, code) = super::parse_key_name("a");
1921        assert!(matches!(key, Key::Character(s) if s == "a"));
1922        assert_eq!(code, Code::KeyA);
1923    }
1924
1925    #[test]
1926    fn parse_key_name_function_keys() {
1927        let (key, code) = super::parse_key_name("F1");
1928        assert!(matches!(key, Key::Named(NamedKey::F1)));
1929        assert_eq!(code, Code::F1);
1930    }
1931
1932    #[test]
1933    fn parse_key_name_escape_aliases() {
1934        let (key, code) = super::parse_key_name("Escape");
1935        assert!(matches!(key, Key::Named(NamedKey::Escape)));
1936        assert_eq!(code, Code::Escape);
1937
1938        let (key, code) = super::parse_key_name("Esc");
1939        assert!(matches!(key, Key::Named(NamedKey::Escape)));
1940        assert_eq!(code, Code::Escape);
1941    }
1942
1943    // ── PageHandle high-level API existence tests ───────────────────────
1944    // @trace REQ-LIB-001 [req:REQ-LIB-001] [level:unit]
1945
1946    #[test]
1947    fn page_inner_has_wait_for_selector() {
1948        let source = include_str!("page.rs");
1949        assert!(
1950            source.contains("pub fn wait_for_selector("),
1951            "REQ-LIB-001: PageInner must have wait_for_selector method"
1952        );
1953    }
1954
1955    #[test]
1956    fn page_inner_has_wait_for_navigation() {
1957        let source = include_str!("page.rs");
1958        assert!(
1959            source.contains("pub fn wait_for_navigation("),
1960            "REQ-LIB-001: PageInner must have wait_for_navigation method"
1961        );
1962    }
1963
1964    #[test]
1965    fn page_inner_has_wait_for_function() {
1966        let source = include_str!("page.rs");
1967        assert!(
1968            source.contains("pub fn wait_for_function("),
1969            "REQ-LIB-001: PageInner must have wait_for_function method"
1970        );
1971    }
1972
1973    #[test]
1974    fn page_inner_has_click_element() {
1975        let source = include_str!("page.rs");
1976        assert!(
1977            source.contains("pub fn click_element("),
1978            "REQ-LIB-001: PageInner must have click_element method"
1979        );
1980    }
1981
1982    #[test]
1983    fn page_inner_has_type_text() {
1984        let source = include_str!("page.rs");
1985        assert!(
1986            source.contains("pub fn type_text("),
1987            "REQ-LIB-001: PageInner must have type_text method"
1988        );
1989    }
1990
1991    #[test]
1992    fn page_inner_has_fill() {
1993        let source = include_str!("page.rs");
1994        assert!(
1995            source.contains("pub fn fill("),
1996            "REQ-LIB-001: PageInner must have fill method"
1997        );
1998    }
1999
2000    #[test]
2001    fn page_inner_has_set_content() {
2002        let source = include_str!("page.rs");
2003        assert!(
2004            source.contains("pub fn set_content("),
2005            "REQ-LIB-001: PageInner must have set_content method"
2006        );
2007    }
2008
2009    #[test]
2010    fn page_inner_has_content() {
2011        let source = include_str!("page.rs");
2012        assert!(
2013            source.contains("pub fn content("),
2014            "REQ-LIB-001: PageInner must have content method"
2015        );
2016    }
2017
2018    #[test]
2019    fn page_inner_has_reload_go_back_go_forward() {
2020        let source = include_str!("page.rs");
2021        assert!(
2022            source.contains("pub fn reload(&self)"),
2023            "REQ-LIB-001: PageInner must have reload method"
2024        );
2025        assert!(
2026            source.contains("pub fn go_back(&self)"),
2027            "REQ-LIB-001: PageInner must have go_back method"
2028        );
2029        assert!(
2030            source.contains("pub fn go_forward(&self)"),
2031            "REQ-LIB-001: PageInner must have go_forward method"
2032        );
2033    }
2034
2035    #[test]
2036    fn page_inner_has_dispatch_mouse_event() {
2037        let source = include_str!("page.rs");
2038        assert!(
2039            source.contains("pub fn dispatch_mouse_event("),
2040            "REQ-LIB-001: PageInner must have dispatch_mouse_event method"
2041        );
2042    }
2043
2044    #[test]
2045    fn page_inner_has_dispatch_key_event() {
2046        let source = include_str!("page.rs");
2047        assert!(
2048            source.contains("pub fn dispatch_key_event("),
2049            "REQ-LIB-001: PageInner must have dispatch_key_event method"
2050        );
2051    }
2052
2053    #[test]
2054    fn page_handle_has_high_level_api() {
2055        let source = include_str!("page.rs");
2056        // PageHandle delegates
2057        assert!(
2058            source.contains("pub fn click(&self"),
2059            "PageHandle must have click"
2060        );
2061        assert!(
2062            source.contains("pub fn type_text(&self"),
2063            "PageHandle must have type_text"
2064        );
2065        assert!(
2066            source.contains("pub fn fill(&self"),
2067            "PageHandle must have fill"
2068        );
2069        assert!(
2070            source.contains("pub fn set_content(&self"),
2071            "PageHandle must have set_content"
2072        );
2073        assert!(
2074            source.contains("pub fn content(&self"),
2075            "PageHandle must have content"
2076        );
2077        assert!(
2078            source.contains("pub fn press(&self"),
2079            "PageHandle must have press"
2080        );
2081        assert!(
2082            source.contains("pub fn hover(&self"),
2083            "PageHandle must have hover"
2084        );
2085        assert!(
2086            source.contains("pub fn focus_element(&self"),
2087            "PageHandle must have focus_element"
2088        );
2089        assert!(
2090            source.contains("pub fn reload(&self"),
2091            "PageHandle must have reload"
2092        );
2093        assert!(
2094            source.contains("pub fn go_back(&self"),
2095            "PageHandle must have go_back"
2096        );
2097        assert!(
2098            source.contains("pub fn go_forward(&self"),
2099            "PageHandle must have go_forward"
2100        );
2101        assert!(
2102            source.contains("pub fn select(&self"),
2103            "PageHandle must have select"
2104        );
2105        assert!(
2106            source.contains("pub fn set_viewport(&self"),
2107            "PageHandle must have set_viewport"
2108        );
2109        assert!(
2110            source.contains("pub fn cookies(&self"),
2111            "PageHandle must have cookies"
2112        );
2113        assert!(
2114            source.contains("pub fn set_cookie(&self"),
2115            "PageHandle must have set_cookie"
2116        );
2117        assert!(
2118            source.contains("pub fn delete_cookie(&self"),
2119            "PageHandle must have delete_cookie"
2120        );
2121    }
2122}