Skip to main content

orinium_browser/engine/js/
devtools.rs

1//! DevTools bridge for page JavaScript.
2//!
3//! Exposes `__orinium_devtools(method, paramsJson)`, returning a
4//! `Promise<string>` with a JSON envelope. Requests are handled by the
5//! browser UI and resolved via [`JsRuntime::resolve_devtools`].
6
7use pixi_byte::vm::VM;
8use pixi_byte::{JSError, JSResult, JSValue};
9
10use super::{JsRuntime, with_host_mut};
11
12/// An inspection request queued by page JavaScript via
13/// `__orinium_devtools`, waiting to be answered by the browser UI.
14#[derive(Debug)]
15pub struct JsDevToolsRequest {
16    pub(crate) id: u64,
17    pub(crate) method: String,
18    /// JSON-encoded parameters produced by the caller.
19    pub(crate) params: String,
20}
21
22/// A pending DevTools promise waiting for the browser UI's answer.
23pub(crate) struct JsDevToolsCapability {
24    resolve: JSValue,
25}
26
27const DEVTOOLS_GLOBAL: &str = "__orinium_devtools";
28
29/// Installs the DevTools inspection global on the engine.
30pub(super) fn install(engine: &mut pixi_byte::JSEngine) {
31    engine.global_mut().borrow_mut().set(
32        DEVTOOLS_GLOBAL.to_string(),
33        JSValue::from_native_function(inspect),
34    );
35}
36
37fn inspect(vm: &mut VM, args: Vec<JSValue>) -> JSResult<JSValue> {
38    let Some(method) = args.get(1).and_then(JSValue::as_string_owned) else {
39        return Err(JSError::TypeError(
40            "__orinium_devtools requires a method string".to_string(),
41        ));
42    };
43    let params = match args.get(2) {
44        Some(value) => {
45            if let Some(params) = value.as_string_owned() {
46                params
47            } else if value.is_undefined() || value.is_null() {
48                "{}".to_string()
49            } else {
50                return Err(JSError::TypeError(
51                    "__orinium_devtools params must be a JSON string".to_string(),
52                ));
53            }
54        }
55        None => "{}".to_string(),
56    };
57
58    let promise_constructor = vm.global_object.borrow().get("Promise");
59    let Some(constructor) = promise_constructor.as_object() else {
60        return Err(JSError::InternalError(
61            "Promise constructor is unavailable".to_string(),
62        ));
63    };
64    let construct = constructor.borrow().get("__construct__");
65    let _ = with_host_mut(vm, |host| host.constructing_devtools_capability = None);
66    let promise = vm.call(
67        construct,
68        promise_constructor,
69        vec![JSValue::from_native_function(capture_capability)],
70    )?;
71    let capability = with_host_mut(vm, |host| host.constructing_devtools_capability.take())
72        .flatten()
73        .ok_or_else(|| JSError::InternalError("Failed to create DevTools Promise".to_string()))?;
74
75    let _ = with_host_mut(vm, |host| {
76        host.next_devtools_id = host.next_devtools_id.wrapping_add(1);
77        let id = host.next_devtools_id;
78        host.devtools_capabilities.insert(id, capability);
79        host.devtools_requests
80            .push(JsDevToolsRequest { id, method, params });
81    });
82    Ok(promise)
83}
84
85fn capture_capability(vm: &mut VM, args: Vec<JSValue>) -> JSResult<JSValue> {
86    let resolve = args.get(1).cloned().unwrap_or(JSValue::undefined());
87    let Some(()) = with_host_mut(vm, |host| {
88        host.constructing_devtools_capability = Some(JsDevToolsCapability { resolve });
89    }) else {
90        return Err(JSError::InternalError(
91            "DevTools host state is unavailable".to_string(),
92        ));
93    };
94    Ok(JSValue::undefined())
95}
96
97impl JsRuntime {
98    /// Takes DevTools inspection requests queued by page JavaScript since the
99    /// previous call.
100    pub(crate) fn take_devtools_requests(&mut self) -> Vec<JsDevToolsRequest> {
101        with_host_mut(self.engine.vm(), |host| {
102            std::mem::take(&mut host.devtools_requests)
103        })
104        .unwrap_or_default()
105    }
106
107    /// Settles a pending DevTools inspection request with its JSON envelope
108    /// and runs the microtask checkpoint so `.then` callbacks observe it.
109    pub(crate) fn resolve_devtools(&mut self, id: u64, result: String) {
110        let Some(capability) = with_host_mut(self.engine.vm(), |host| {
111            host.devtools_capabilities.remove(&id)
112        })
113        .flatten() else {
114            return;
115        };
116        if let Err(err) = self.engine.call(
117            capability.resolve,
118            JSValue::undefined(),
119            vec![JSValue::from_string(result)],
120        ) {
121            log::info!("JS error while resolving devtools request: {}", err);
122        }
123        self.perform_microtask_checkpoint();
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use std::time::{Duration, Instant};
130
131    use super::super::{JsProcessor, JsTask};
132    use crate::engine::html::parser::Parser as HtmlParser;
133    use crate::engine::layouter::dom_snapshot::DomSnapshot;
134
135    fn wait_for_result(processor: &JsProcessor) -> crate::engine::js::JsTaskResult {
136        let deadline = Instant::now() + Duration::from_secs(5);
137        loop {
138            if let Some(result) = processor.try_receive() {
139                return result;
140            }
141            assert!(
142                Instant::now() < deadline,
143                "JS result did not arrive before the timeout"
144            );
145            std::thread::sleep(Duration::from_millis(1));
146        }
147    }
148
149    #[test]
150    fn devtools_request_round_trip_resolves_the_promise() {
151        let dom = HtmlParser::new("<html><body></body></html>").parse();
152        let (snapshot, _) = DomSnapshot::from_tree(&dom.root);
153        let processor = JsProcessor::new(snapshot);
154
155        processor.send(JsTask::RunScript {
156            source: r#"
157                globalThis.__result = null;
158                __orinium_devtools("getVersion").then(function (json) {
159                    globalThis.__result = json;
160                });
161            "#
162            .to_string(),
163        });
164
165        let result = wait_for_result(&processor);
166        assert_eq!(result.devtools_requests.len(), 1);
167        let request = &result.devtools_requests[0];
168        assert_eq!(request.method, "getVersion");
169        assert_eq!(request.params, "{}");
170
171        processor.send(JsTask::ResolveDevTools {
172            id: request.id,
173            result: r#"{"ok":true,"data":{"version":7}}"#.to_string(),
174        });
175        let _ = wait_for_result(&processor);
176
177        processor.send(JsTask::RunScript {
178            source: r#"
179                if (globalThis.__result !== '{"ok":true,"data":{"version":7}}') {
180                    throw new Error("unexpected result: " + globalThis.__result);
181                }
182                document.body.setAttribute("data-ok", "yes");
183            "#
184            .to_string(),
185        });
186        let final_result = wait_for_result(&processor);
187        assert!(
188            final_result.needs_redraw,
189            "the verification script must run without throwing"
190        );
191    }
192
193    #[test]
194    fn devtools_rejects_non_string_method() {
195        let dom = HtmlParser::new("<html><body></body></html>").parse();
196        let (snapshot, _) = DomSnapshot::from_tree(&dom.root);
197        let processor = JsProcessor::new(snapshot);
198
199        processor.send(JsTask::RunScript {
200            source: r#"
201                try {
202                    __orinium_devtools(42);
203                    throw new Error("expected __orinium_devtools to reject numbers");
204                } catch (error) {
205                    document.body.setAttribute("data-rejected", "yes");
206                }
207            "#
208            .to_string(),
209        });
210
211        let result = wait_for_result(&processor);
212        assert!(result.devtools_requests.is_empty());
213        assert!(
214            result.needs_redraw,
215            "the catch block must have run without throwing"
216        );
217    }
218}