Skip to main content

orinium_browser/engine/js/
mod.rs

1//! Minimal JS runtime backed by `pixi_byte`.
2//!
3//! Installs a small set of DOM bindings (`console`, `document.getElementById`,
4//! element properties). The engine never imports `platform`; DOM access goes
5//! through the shared host slot that `JsRuntime` registers on the VM. The
6//! runtime normally lives on a background thread (see [`processor`]), owning a
7//! private mirror of the DOM that is synced with the UI thread via
8//! [`DomSnapshot`] commits. It can also be used directly on any thread.
9
10use crate::engine::html::{DomTree, HtmlNodeType};
11use crate::engine::js::web_apis::dom::document::IframeDocument;
12use crate::engine::layouter::dom_snapshot::DomSnapshot;
13use crate::engine::tree::NodeRef;
14use pixi_byte::value::JSArray;
15use pixi_byte::value::jsobject::JSObject;
16use pixi_byte::{JSError, JSValue};
17use std::cell::{Cell, RefCell};
18use std::collections::{HashMap, HashSet};
19use std::rc::Rc;
20use std::time::{Duration, Instant};
21
22pub mod devtools;
23pub mod processor;
24pub use devtools::JsDevToolsRequest;
25pub use processor::{JsProcessor, JsTask, JsTaskResult};
26
27mod common;
28pub(crate) mod runtime;
29pub(crate) mod web_apis;
30
31// Re-export items needed by sibling modules.
32pub(crate) use common::{
33    host_read_only_property, is_callable, node_dom_id, with_host, with_host_mut,
34};
35pub(crate) use web_apis::dom::document::expose_node;
36pub(crate) use web_apis::dom::events::{event_flag, make_event};
37pub(crate) use web_apis::network::{make_fetch_response, resolve_xml_http_request};
38
39// ---------------------------------------------------------------------------
40// Core types
41// ---------------------------------------------------------------------------
42
43pub(crate) struct JsTimer {
44    id: u64,
45    callback: JSValue,
46    arguments: Vec<JSValue>,
47    deadline: Instant,
48    interval: Option<Duration>,
49}
50
51pub(crate) struct JsFetchCapability {
52    resolve: JSValue,
53    reject: JSValue,
54}
55
56/// A fetch request waiting to be dispatched by the browser network layer.
57#[derive(Debug)]
58pub struct JsFetchRequest {
59    pub(crate) id: u64,
60    pub(crate) url: String,
61    pub(crate) method: String,
62    pub(crate) headers: Vec<(String, String)>,
63    pub(crate) body: Vec<u8>,
64}
65
66/// An `<iframe src="...">` whose content has to be fetched and parsed.
67#[derive(Debug)]
68pub struct JsIframeFetchRequest {
69    /// The DOM id of the `<iframe>` element requesting the load.
70    pub dom_id: u64,
71    /// The absolute (resolved) URL of the iframe source.
72    pub url: String,
73}
74
75/// The serialized content document of a single `<iframe>`, carried from the JS
76/// thread so the layout can render it nested inside the host page.
77#[derive(Debug)]
78pub struct IframeContentSnapshot {
79    /// The JS-facing DOM id of the `<iframe>` element this content belongs to.
80    pub iframe_dom_id: u64,
81    /// The iframe's content document tree, serialized as a movable snapshot.
82    pub content: DomSnapshot,
83}
84
85/// A script element inserted by JavaScript after the initial HTML parse.
86#[derive(Debug)]
87pub(crate) struct JsDynamicScriptRequest {
88    pub(crate) node_id: u64,
89    pub(crate) source: JsDynamicScriptSource,
90}
91
92#[derive(Debug)]
93pub(crate) enum JsDynamicScriptSource {
94    Inline(String),
95    External(String),
96}
97
98#[derive(Debug)]
99pub(crate) struct JsDynamicStyleRequest {
100    pub(crate) node_id: u64,
101    pub(crate) url: String,
102}
103
104/// An image element created or populated after the initial HTML parse.
105#[derive(Debug)]
106// TODO: Track the owning image node so src changes can cancel/reload and dispatch load/error.
107pub(crate) struct JsDynamicImageRequest {
108    pub(crate) source: String,
109}
110
111/// Geometry produced by the committed layout tree for a live DOM element.
112#[derive(Debug, Clone, Copy, Default, PartialEq)]
113pub struct JsLayoutMetrics {
114    pub offset_left: f64,
115    pub offset_top: f64,
116    pub offset_width: f64,
117    pub offset_height: f64,
118    pub client_width: f64,
119    pub client_height: f64,
120    pub rect_left: f64,
121    pub rect_top: f64,
122    pub rect_width: f64,
123    pub rect_height: f64,
124}
125
126/// The response data exposed to a JavaScript `Response` object.
127#[derive(Debug)]
128pub struct JsFetchResponse {
129    pub(crate) url: String,
130    pub(crate) status: u16,
131    pub(crate) status_text: String,
132    pub(crate) redirected: bool,
133    pub(crate) body: Vec<u8>,
134    pub(crate) headers: Vec<(String, String)>,
135}
136
137/// A registered custom element definition.
138#[derive(Clone)]
139pub(crate) struct CustomElementDefinition {
140    pub(crate) constructor: JSValue,
141    pub(crate) connected_callback: Option<JSValue>,
142    pub(crate) disconnected_callback: Option<JSValue>,
143    pub(crate) attribute_changed_callback: Option<JSValue>,
144    pub(crate) observed_attributes: Vec<String>,
145    /// Resolve functions for pending `whenDefined()` promises.
146    pub(crate) when_defined_resolvers: Vec<JSValue>,
147}
148
149/// State shared between the JS natives and the browser side.
150///
151/// The JS-facing `u64` counter (`__orinium_dom_id`) maps to a live DOM node so
152/// element handles survive relayouts: `Rc` handles on the DOM nodes are stable,
153/// while snapshot ids are not.
154pub struct JsHost {
155    pub(crate) dom: Rc<DomTree>,
156    pub(crate) refs: HashMap<
157        u64,
158        std::rc::Weak<std::cell::RefCell<crate::engine::tree::TreeNode<HtmlNodeType>>>,
159    >,
160    /// Element JS objects per DOM id, kept alive so `onclick` handlers
161    /// registered on them survive and can be invoked on user clicks.
162    pub(crate) objects: HashMap<u64, Rc<RefCell<JSObject>>>,
163    /// Stable `CSSStyleDeclaration` wrappers for exposed elements.
164    pub(crate) styles: HashMap<u64, Rc<RefCell<JSObject>>>,
165    /// Stable 2D rendering contexts for canvas elements.
166    pub(crate) canvas_contexts: HashMap<u64, Rc<RefCell<JSObject>>>,
167    /// Explicit namespaces assigned through `document.createElementNS`.
168    pub(crate) namespaces: HashMap<u64, String>,
169    pub(crate) element_prototype: Rc<RefCell<JSObject>>,
170    pub(crate) element_constructor: Rc<RefCell<JSObject>>,
171    pub(crate) document: Option<Rc<RefCell<JSObject>>>,
172    pub(crate) document_implementation: Option<Rc<RefCell<JSObject>>>,
173    /// Independent document instances for `<iframe>` elements, keyed by the
174    /// iframe element's DOM id. Each iframe gets its own DOM tree.
175    pub(crate) iframe_documents: HashMap<u64, Rc<RefCell<IframeDocument>>>,
176    /// DOM ids of iframes whose content failed to load; a failed load is not
177    /// retried automatically (only a new `src` re-queues it).
178    pub(crate) failed_iframe_fetches: HashSet<u64>,
179    pub(crate) document_event_listeners: HashMap<String, Vec<JSValue>>,
180    /// Element event listeners keyed by dom id and event type. Each entry is a
181    /// `(callback, capture)` pair; the capture phase flag distinguishes
182    /// capturing listeners from bubbling ones.
183    pub(crate) element_event_listeners: HashMap<u64, HashMap<String, Vec<(JSValue, bool)>>>,
184    /// Inline event-handler content attributes mapped onto the Window per the
185    /// HTML spec (e.g. `<body onload="...">` registers a `load` event handler
186    /// on the Window). Keyed by event type; populated once when the DOM is
187    /// bound to the runtime so dispatching never re-scans the tree.
188    pub(crate) window_inline_event_handlers: HashMap<String, String>,
189    pub(crate) active_element: Option<u64>,
190    /// Keeps JS-created or removed nodes alive while their wrappers exist.
191    pub(crate) detached_nodes: HashMap<u64, NodeRef<HtmlNodeType>>,
192    pub(crate) timers: Vec<JsTimer>,
193    pub(crate) fetch_requests: Vec<JsFetchRequest>,
194    pub(crate) iframe_fetch_requests: Vec<JsIframeFetchRequest>,
195    /// DOM ids of iframes whose content is already queued for loading, to avoid
196    /// re-queuing a fetch on every `contentDocument` access.
197    pub(crate) pending_iframe_fetches: std::collections::HashSet<u64>,
198    pub(crate) dynamic_script_requests: Vec<JsDynamicScriptRequest>,
199    pub(crate) queued_dynamic_scripts: HashSet<u64>,
200    pub(crate) dynamic_style_requests: Vec<JsDynamicStyleRequest>,
201    pub(crate) queued_dynamic_styles: HashSet<u64>,
202    pub(crate) dynamic_image_requests: Vec<JsDynamicImageRequest>,
203    pub(crate) queued_dynamic_images: HashSet<u64>,
204    pub(crate) fetch_capabilities: HashMap<u64, JsFetchCapability>,
205    pub(crate) xhr_requests: HashMap<u64, Rc<RefCell<JSObject>>>,
206    pub(crate) constructing_fetch_capability: Option<JsFetchCapability>,
207    pub(crate) devtools_requests: Vec<JsDevToolsRequest>,
208    pub(crate) devtools_capabilities: HashMap<u64, devtools::JsDevToolsCapability>,
209    pub(crate) constructing_devtools_capability: Option<devtools::JsDevToolsCapability>,
210    pub(crate) next_devtools_id: u64,
211    /// Registered custom element definitions keyed by lowercase tag name.
212    pub(crate) custom_elements: HashMap<String, CustomElementDefinition>,
213    /// Shadow root associations: host_dom_id -> shadow_root_dom_id.
214    pub(crate) shadow_roots: HashMap<u64, u64>,
215    // TODO: Persist localStorage per origin and sessionStorage per top-level browsing context.
216    pub(crate) local_storage: HashMap<String, String>,
217    pub(crate) session_storage: HashMap<String, String>,
218    // TODO: Move cookies into a shared origin/path-aware jar with expiry and security attributes.
219    pub(crate) document_cookies: HashMap<String, String>,
220    pub(crate) document_url: String,
221    /// ASCII serialization of the document's origin (`"null"` when opaque).
222    pub(crate) origin: String,
223    pub(crate) viewport: (f64, f64),
224    /// Committed layout measurements keyed by the address of a live DOM node.
225    pub(crate) layout_metrics: HashMap<usize, JsLayoutMetrics>,
226    /// Committed layout measurements keyed by stable JS-facing DOM id.
227    pub(crate) layout_metrics_by_dom_id: HashMap<u64, JsLayoutMetrics>,
228    pub(crate) next_fetch_id: u64,
229    pub(crate) next_timer_id: u64,
230    pub(crate) time_origin: Instant,
231    pub(crate) dom_content_loaded_fired: bool,
232    pub(crate) window_load_fired: bool,
233    pub(crate) next_id: u64,
234    pub(crate) needs_redraw: Rc<Cell<bool>>,
235}
236
237impl JsHost {
238    /// Finds the JS-facing DOM id registered for a live DOM node, if any.
239    pub(crate) fn dom_id_for_node(&self, node: &NodeRef<HtmlNodeType>) -> Option<u64> {
240        self.refs.iter().find_map(|(&dom_id, weak)| {
241            if std::ptr::eq(weak.as_ptr(), Rc::as_ptr(node)) {
242                Some(dom_id)
243            } else {
244                None
245            }
246        })
247    }
248}
249
250/// A JS engine instance with DOM bindings installed.
251pub struct JsRuntime {
252    engine: pixi_byte::JSEngine,
253    needs_redraw: Rc<Cell<bool>>,
254}
255
256impl JsRuntime {
257    /// Creates a runtime sharing the given DOM tree with the browser side.
258    pub fn new(dom: Rc<DomTree>) -> Self {
259        let needs_redraw = Rc::new(Cell::new(false));
260        let (element_prototype, element_constructor) =
261            web_apis::dom::element::make_element_interface();
262        let mut host = JsHost {
263            dom,
264            refs: HashMap::new(),
265            objects: HashMap::new(),
266            styles: HashMap::new(),
267            canvas_contexts: HashMap::new(),
268            namespaces: HashMap::new(),
269            element_prototype,
270            element_constructor,
271            document: None,
272            document_implementation: None,
273            iframe_documents: HashMap::new(),
274            failed_iframe_fetches: HashSet::new(),
275            document_event_listeners: HashMap::new(),
276            element_event_listeners: HashMap::new(),
277            window_inline_event_handlers: HashMap::new(),
278            active_element: None,
279            detached_nodes: HashMap::new(),
280            timers: Vec::new(),
281            fetch_requests: Vec::new(),
282            iframe_fetch_requests: Vec::new(),
283            pending_iframe_fetches: std::collections::HashSet::new(),
284            dynamic_script_requests: Vec::new(),
285            queued_dynamic_scripts: HashSet::new(),
286            dynamic_style_requests: Vec::new(),
287            queued_dynamic_styles: HashSet::new(),
288            dynamic_image_requests: Vec::new(),
289            queued_dynamic_images: HashSet::new(),
290            fetch_capabilities: HashMap::new(),
291            xhr_requests: HashMap::new(),
292            constructing_fetch_capability: None,
293            devtools_requests: Vec::new(),
294            devtools_capabilities: HashMap::new(),
295            constructing_devtools_capability: None,
296            next_devtools_id: 0,
297            custom_elements: HashMap::new(),
298            shadow_roots: HashMap::new(),
299            local_storage: HashMap::new(),
300            session_storage: HashMap::new(),
301            document_cookies: HashMap::new(),
302            document_url: "about:blank".to_string(),
303            origin: "null".to_string(),
304            viewport: (800.0, 600.0),
305            layout_metrics: HashMap::new(),
306            layout_metrics_by_dom_id: HashMap::new(),
307            next_fetch_id: 0,
308            next_timer_id: 0,
309            time_origin: Instant::now(),
310            dom_content_loaded_fired: false,
311            window_load_fired: false,
312            next_id: 0,
313            needs_redraw: Rc::clone(&needs_redraw),
314        };
315        Self::register_window_event_handlers(&mut host);
316
317        let host = Rc::new(RefCell::new(host));
318
319        let mut engine = pixi_byte::JSEngine::new();
320        engine.set_host(host);
321
322        web_apis::console::install_console(&mut engine);
323        web_apis::dom::document::install_document(&mut engine);
324
325        web_apis::observers::install_mutation_observer(&mut engine);
326        web_apis::observers::install_resize_observer(&mut engine);
327        web_apis::observers::install_intersection_observer(&mut engine);
328        web_apis::timers::install_timers(&mut engine);
329        web_apis::performance::install_performance(&mut engine);
330        runtime::microtasks::install_microtasks(&mut engine);
331        web_apis::network::install_headers(&mut engine);
332        web_apis::network::install_request(&mut engine);
333        web_apis::network::install_fetch(&mut engine);
334        web_apis::network::install_xml_http_request(&mut engine);
335        devtools::install(&mut engine);
336        web_apis::url::install_url_apis(&mut engine);
337        web_apis::encoding::install_encoding_apis(&mut engine);
338        web_apis::browser_env::install_browser_environment(&mut engine);
339        web_apis::browser_env::install_global_aliases(&mut engine);
340        web_apis::dom::custom_elements::install_custom_elements(&mut engine);
341
342        Self {
343            engine,
344            needs_redraw,
345        }
346    }
347
348    /// Updates the CSS-pixel viewport exposed through the Window API.
349    pub fn set_viewport(&mut self, width: f32, height: f32) {
350        let width = width.max(0.0) as f64;
351        let height = height.max(0.0) as f64;
352        let mut global = self.engine.global_mut().borrow_mut();
353        global.set("innerWidth".to_string(), JSValue::from_number(width));
354        global.set("innerHeight".to_string(), JSValue::from_number(height));
355        global.set("outerWidth".to_string(), JSValue::from_number(width));
356        global.set("outerHeight".to_string(), JSValue::from_number(height));
357        drop(global);
358        with_host_mut(self.engine.vm(), |host| host.viewport = (width, height));
359    }
360
361    /// Replaces geometry exposed by DOM measurement APIs with the latest
362    /// committed layout result.
363    #[cfg(test)]
364    pub(crate) fn set_layout_metrics(&mut self, metrics: HashMap<usize, JsLayoutMetrics>) {
365        with_host_mut(self.engine.vm(), |host| host.layout_metrics = metrics);
366    }
367
368    /// Replaces geometry using stable DOM ids supplied by the browser UI
369    /// thread, whose live node addresses differ from this runtime's mirror.
370    pub(crate) fn set_layout_metrics_by_dom_id(&mut self, metrics: HashMap<u64, JsLayoutMetrics>) {
371        with_host_mut(self.engine.vm(), |host| {
372            host.layout_metrics_by_dom_id = metrics
373        });
374    }
375
376    /// Updates the language preferences exposed through `navigator`.
377    pub fn set_language(&mut self, language: &str) {
378        let language = language.trim();
379        if language.is_empty() {
380            return;
381        }
382        let mut languages = vec![JSValue::from_string(language.to_string())];
383        if let Some(base) = language.split('-').next()
384            && !base.eq_ignore_ascii_case(language)
385        {
386            languages.push(JSValue::from_string(base.to_string()));
387        }
388        if !language.eq_ignore_ascii_case("en-US") {
389            languages.push(JSValue::from_string("en-US".to_string()));
390        }
391
392        let global = self.engine.global_mut().borrow_mut();
393        let Some(navigator) = global.get("navigator").as_object() else {
394            return;
395        };
396        drop(global);
397        let mut navigator = navigator.borrow_mut();
398        navigator.define_property(
399            "language".to_string(),
400            host_read_only_property(JSValue::from_string(language.to_string())),
401        );
402        navigator.define_property(
403            "languages".to_string(),
404            host_read_only_property(JSArray::from_vec(languages).to_object()),
405        );
406    }
407
408    /// Evaluates a script, logging JS errors instead of crashing the page.
409    pub fn run_script(&mut self, source: &str) {
410        match self.engine.eval(source) {
411            Ok(_) => {}
412            Err(err) => {
413                if let JSError::Thrown(value) = &err
414                    && let Some(object) = value.as_object()
415                {
416                    let object = object.borrow();
417                    let details = object
418                        .keys()
419                        .into_iter()
420                        .map(|key| format!("{key}={}", object.get(&key).to_console_string()))
421                        .collect::<Vec<_>>()
422                        .join(", ");
423                    log::info!("JS error: uncaught object ({details})");
424                }
425                log::info!("JS error: {}", err);
426            }
427        }
428        self.perform_microtask_checkpoint();
429    }
430
431    /// Evaluates an expression and returns its value, or `undefined` on error.
432    pub fn eval_value(&mut self, source: &str) -> JSValue {
433        match self.engine.eval(source) {
434            Ok(value) => value,
435            Err(err) => {
436                log::info!("JS error evaluating {source:?}: {err}");
437                JSValue::undefined()
438            }
439        }
440    }
441
442    /// Updates the URL exposed through the window's `location` object.
443    pub fn set_document_url(&mut self, url: &str) {
444        let _ = with_host_mut(self.engine.vm(), |host| {
445            host.document_url = url.to_string();
446        });
447    }
448
449    /// Updates the serialized origin exposed through the window's
450    /// `location`/`window`/`document` objects.
451    pub fn set_page_origin(&mut self, origin: &str) {
452        let _ = with_host_mut(self.engine.vm(), |host| {
453            host.origin = origin.to_string();
454        });
455    }
456
457    /// Dispatches `DOMContentLoaded` to document listeners once.
458    ///
459    /// Returns `true` only for the first dispatch attempt. Listener errors are
460    /// logged and do not prevent the remaining listeners from running.
461    pub fn dispatch_dom_content_loaded(&mut self) -> bool {
462        let Some((document, listeners)) = with_host_mut(self.engine.vm(), |host| {
463            if host.dom_content_loaded_fired {
464                return None;
465            }
466
467            host.dom_content_loaded_fired = true;
468            Some((
469                host.document.as_ref().cloned(),
470                host.document_event_listeners
471                    .get("DOMContentLoaded")
472                    .cloned()
473                    .unwrap_or_default(),
474            ))
475        })
476        .flatten() else {
477            return false;
478        };
479
480        let Some(document) = document else {
481            return true;
482        };
483        for listener in listeners {
484            let event = make_event(
485                "DOMContentLoaded",
486                Rc::clone(&document),
487                Rc::clone(&document),
488            );
489            if let Err(err) = self.engine.call(
490                listener,
491                JSValue::from_object(Rc::clone(&document)),
492                vec![JSValue::from_object(event)],
493            ) {
494                log::info!("JS error on DOMContentLoaded: {}", err);
495            }
496        }
497        self.perform_microtask_checkpoint();
498        true
499    }
500
501    /// Dispatches the window `load` event once the page has finished loading.
502    ///
503    /// Supports the body `onload` inline handler, the `window.onload` property,
504    /// and `addEventListener("load", ...)` registrations in that order. Returns
505    /// `true` only for the first dispatch attempt.
506    pub fn dispatch_window_load(&mut self) -> bool {
507        let state = with_host_mut(self.engine.vm(), |host| {
508            if host.window_load_fired {
509                return None;
510            }
511            host.window_load_fired = true;
512            Some((
513                host.window_inline_event_handlers
514                    .get("load")
515                    .cloned()
516                    .unwrap_or_default(),
517                host.document_event_listeners
518                    .get("load")
519                    .cloned()
520                    .unwrap_or_default(),
521            ))
522        });
523        let Some((body_onload, listeners)) = state.flatten() else {
524            return false;
525        };
526
527        let window_object = Rc::clone(self.engine.global_mut());
528
529        // The body `onload` is a window-level inline event handler that fires
530        // first, as if it had been registered first by the parser.
531        if !body_onload.trim().is_empty() {
532            self.call_inline_load_handler(&window_object, &body_onload, "body onload");
533        }
534
535        let onload = window_object.borrow().get("onload");
536        if is_callable(&onload) {
537            self.call_load_handler(&window_object, onload, "window.onload");
538        }
539
540        for listener in listeners {
541            self.call_load_handler(&window_object, listener, "window load listener");
542        }
543
544        self.perform_microtask_checkpoint();
545        true
546    }
547
548    /// Compiles and runs an inline event-handler content attribute (e.g.
549    /// `<body onload="...">`) with `this` bound to the Window and an `event`
550    /// parameter, per the HTML spec's inline event handler activation.
551    fn call_inline_load_handler(
552        &mut self,
553        window: &Rc<RefCell<JSObject>>,
554        code: &str,
555        label: &str,
556    ) {
557        let event = make_event("load", Rc::clone(window), Rc::clone(window));
558        // Per HTML spec, the inline handler is compiled as a function whose
559        // `event` parameter and `this` (the Window) are set; invoking it runs
560        // the assigned code (e.g. Acid3's body onload="update()").
561        let wrapped = format!("(function(event) {{ {code} \n}})");
562        let handler = self.engine.eval(&wrapped).unwrap_or(JSValue::undefined());
563        if is_callable(&handler)
564            && let Err(err) = self.engine.call(
565                handler,
566                JSValue::from_object(Rc::clone(window)),
567                vec![JSValue::from_object(event)],
568            )
569        {
570            log::info!("JS error in {label}: {err}");
571        }
572    }
573
574    /// Invokes a registered window `load` listener with the Window as `this`.
575    fn call_load_handler(&mut self, window: &Rc<RefCell<JSObject>>, handler: JSValue, label: &str) {
576        let event = make_event("load", Rc::clone(window), Rc::clone(window));
577        if let Err(err) = self.engine.call(
578            handler,
579            JSValue::from_object(Rc::clone(window)),
580            vec![JSValue::from_object(event)],
581        ) {
582            log::info!("JS error in {label}: {err}");
583        }
584    }
585
586    /// Runs timer callbacks whose deadlines have elapsed.
587    ///
588    /// Returns whether at least one callback was invoked. Repeating timers are
589    /// rescheduled before invocation so they can cancel themselves.
590    pub fn run_due_timers(&mut self) -> bool {
591        let invocations = with_host_mut(self.engine.vm(), |host| {
592            let now = Instant::now();
593            let mut invocations = Vec::new();
594            let mut index = 0;
595            while index < host.timers.len() {
596                if host.timers[index].deadline > now {
597                    index += 1;
598                    continue;
599                }
600
601                let callback = host.timers[index].callback.clone();
602                let arguments = host.timers[index].arguments.clone();
603                if let Some(interval) = host.timers[index].interval {
604                    host.timers[index].deadline = now + interval;
605                    index += 1;
606                } else {
607                    host.timers.remove(index);
608                }
609                invocations.push((callback, arguments));
610            }
611            invocations
612        })
613        .unwrap_or_default();
614
615        let ran_callback = !invocations.is_empty();
616        for (callback, arguments) in invocations {
617            if let Err(err) = self.engine.call(callback, JSValue::undefined(), arguments) {
618                log::info!("JS error in timer callback: {}", err);
619            }
620            self.perform_microtask_checkpoint();
621        }
622        ran_callback
623    }
624
625    /// Returns whether a script mutated the DOM and a relayout is needed.
626    pub fn needs_redraw(&self) -> bool {
627        self.needs_redraw.get()
628    }
629
630    /// Clears and returns the redraw flag.
631    pub fn take_needs_redraw(&self) -> bool {
632        self.needs_redraw.replace(false)
633    }
634
635    /// Flags that the runtime produced visible state (e.g. an iframe content
636    /// document) that must be carried back to the browser side even though the
637    /// host DOM tree itself did not mutate.
638    pub(crate) fn mark_needs_redraw(&self) {
639        self.needs_redraw.set(true);
640    }
641
642    /// Takes fetch requests queued by JavaScript since the previous call.
643    pub(crate) fn take_fetch_requests(&mut self) -> Vec<JsFetchRequest> {
644        with_host_mut(self.engine.vm(), |host| {
645            std::mem::take(&mut host.fetch_requests)
646        })
647        .unwrap_or_default()
648    }
649
650    /// Takes iframe-loading requests queued by JavaScript since the previous
651    /// call. Each must be resolved via `resolve_iframe_fetch` once fetched.
652    pub fn take_iframe_fetch_requests(&mut self) -> Vec<JsIframeFetchRequest> {
653        with_host_mut(self.engine.vm(), |host| {
654            std::mem::take(&mut host.iframe_fetch_requests)
655        })
656        .unwrap_or_default()
657    }
658
659    /// Parses fetched iframe HTML, installs it as the iframe's `contentDocument`
660    /// and fires the iframe's `load` event.
661    pub fn resolve_iframe_fetch(&mut self, dom_id: u64, html: String) {
662        let installed = with_host_mut(self.engine.vm(), |host| {
663            host.pending_iframe_fetches.remove(&dom_id);
664            host.failed_iframe_fetches.remove(&dom_id);
665            web_apis::dom::document::install_parsed_iframe_document(host, dom_id, &html)
666        });
667        if installed.unwrap_or(false) {
668            self.dispatch_element_event(dom_id, "load");
669            // Installing a content document does not mutate the host tree, so
670            // nothing would otherwise flag this task's result: mark the runtime
671            // so the processor ships the snapshot with the iframe documents and
672            // the browser thread grafts them into layout.
673            self.mark_needs_redraw();
674        }
675    }
676
677    /// Marks an iframe load as failed so later `contentDocument` accesses do not
678    /// keep re-queuing a fetch.
679    pub fn reject_iframe_fetch(&mut self, dom_id: u64) {
680        with_host_mut(self.engine.vm(), |host| {
681            host.pending_iframe_fetches.remove(&dom_id);
682            host.failed_iframe_fetches.insert(dom_id);
683        });
684    }
685
686    /// Queues network loads for every `<iframe src>` in the bound DOM that has
687    /// not yet been queued, loaded, or failed.
688    ///
689    /// Markup-declared iframes (and frames inserted through fragment parsing
690    /// such as `innerHTML`) never pass through the `src` setter, so nothing
691    /// would otherwise request their content. The processor runs this after
692    /// each task so those frames load like any other subresource. Returns the
693    /// number of loads newly queued.
694    pub(crate) fn queue_markup_iframe_loads(&mut self) -> usize {
695        with_host_mut(self.engine.vm(), |host| {
696            let iframes = host.dom.find_all(|node| node.tag_name() == Some("iframe"));
697            let mut queued = 0;
698            for node in iframes {
699                let src = {
700                    let node_ref = node.borrow();
701                    node_ref
702                        .value
703                        .get_attr("src")
704                        .map(str::trim)
705                        .unwrap_or("")
706                        .to_string()
707                };
708                if src.is_empty() {
709                    continue;
710                }
711                let Some(dom_id) = host.dom_id_for_node(&node) else {
712                    continue;
713                };
714                let before = host.pending_iframe_fetches.len();
715                web_apis::dom::element::queue_iframe_fetch_if_needed(host, dom_id, &src);
716                if host.pending_iframe_fetches.len() > before {
717                    queued += 1;
718                }
719            }
720            queued
721        })
722        .unwrap_or(0)
723    }
724
725    pub(crate) fn take_dynamic_script_requests(&mut self) -> Vec<JsDynamicScriptRequest> {
726        with_host_mut(self.engine.vm(), |host| {
727            std::mem::take(&mut host.dynamic_script_requests)
728        })
729        .unwrap_or_default()
730    }
731
732    pub(crate) fn take_dynamic_style_requests(&mut self) -> Vec<JsDynamicStyleRequest> {
733        with_host_mut(self.engine.vm(), |host| {
734            std::mem::take(&mut host.dynamic_style_requests)
735        })
736        .unwrap_or_default()
737    }
738
739    pub(crate) fn take_dynamic_image_requests(&mut self) -> Vec<JsDynamicImageRequest> {
740        with_host_mut(self.engine.vm(), |host| {
741            std::mem::take(&mut host.dynamic_image_requests)
742        })
743        .unwrap_or_default()
744    }
745
746    /// Dispatches a non-bubbling event to a dynamically inserted element.
747    pub(crate) fn dispatch_element_event(&mut self, node_id: u64, event_type: &str) {
748        let Some((target, listeners)) = with_host(self.engine.vm(), |host| {
749            let target = host.objects.get(&node_id).cloned()?;
750            let listeners = host
751                .element_event_listeners
752                .get(&node_id)
753                .and_then(|events| events.get(event_type))
754                .cloned()
755                .unwrap_or_default();
756            Some((target, listeners))
757        })
758        .flatten() else {
759            return;
760        };
761        let handler = target.borrow().get(&format!("on{event_type}"));
762        let event = make_event(event_type, Rc::clone(&target), Rc::clone(&target));
763        if is_callable(&handler)
764            && let Err(error) = self.engine.call(
765                handler,
766                JSValue::from_object(Rc::clone(&target)),
767                vec![JSValue::from_object(Rc::clone(&event))],
768            )
769        {
770            log::info!("JS error in on{event_type}: {error}");
771        }
772        for listener in listeners {
773            if event_flag(&event, "__orinium_immediate_propagation_stopped") {
774                break;
775            }
776            if let Err(error) = self.engine.call(
777                listener.0,
778                JSValue::from_object(Rc::clone(&target)),
779                vec![JSValue::from_object(Rc::clone(&event))],
780            ) {
781                log::info!("JS error in {event_type} listener: {error}");
782            }
783        }
784    }
785
786    /// Resolves a pending JavaScript fetch and runs its microtask checkpoint.
787    pub(crate) fn resolve_fetch(&mut self, id: u64, response: JsFetchResponse) {
788        let capability =
789            with_host_mut(self.engine.vm(), |host| host.fetch_capabilities.remove(&id)).flatten();
790        if let Some(capability) = capability {
791            let response = make_fetch_response(response);
792            if let Err(err) = self.engine.call(
793                capability.resolve,
794                JSValue::undefined(),
795                vec![JSValue::from_object(response)],
796            ) {
797                log::info!("JS error while resolving fetch: {}", err);
798            }
799            self.perform_microtask_checkpoint();
800            return;
801        }
802        let xhr = with_host_mut(self.engine.vm(), |host| host.xhr_requests.remove(&id)).flatten();
803        let Some(xhr) = xhr else { return };
804        resolve_xml_http_request(&mut self.engine, xhr, response);
805        self.perform_microtask_checkpoint();
806    }
807
808    /// Rejects a pending JavaScript fetch and runs its microtask checkpoint.
809    pub(crate) fn reject_fetch(&mut self, id: u64, reason: String) {
810        let capability =
811            with_host_mut(self.engine.vm(), |host| host.fetch_capabilities.remove(&id)).flatten();
812        if let Some(capability) = capability {
813            if let Err(err) = self.engine.call(
814                capability.reject,
815                JSValue::undefined(),
816                vec![JSValue::from_string(reason)],
817            ) {
818                log::info!("JS error while rejecting fetch: {}", err);
819            }
820            self.perform_microtask_checkpoint();
821            return;
822        }
823        let xhr = with_host_mut(self.engine.vm(), |host| host.xhr_requests.remove(&id)).flatten();
824        let Some(xhr) = xhr else { return };
825        let handler = xhr.borrow().get("onerror");
826        if is_callable(&handler) {
827            let _ = self.engine.call(
828                handler,
829                JSValue::from_object(Rc::clone(&xhr)),
830                vec![JSValue::from_string(reason)],
831            );
832        }
833        self.perform_microtask_checkpoint();
834    }
835
836    /// Serializes the current mirror DOM for the browser side.
837    ///
838    /// Nodes exposed to scripts keep their stable `dom_id` so the UI thread can
839    /// rebuild the tree and re-register references on commit.
840    pub fn snapshot(&self) -> DomSnapshot {
841        let Some((root, dom_ids)) = with_host(self.engine.vm(), |host| {
842            let mut reverse = HashMap::new();
843            for (dom_id, weak) in &host.refs {
844                if let Some(node) = weak.upgrade() {
845                    reverse.insert(Rc::as_ptr(&node) as usize, *dom_id);
846                }
847            }
848            (Rc::clone(&host.dom.root), reverse)
849        }) else {
850            return DomSnapshot::default();
851        };
852        DomSnapshot::from_mirror(&root, &dom_ids)
853    }
854
855    /// Serializes every iframe's content document into movable snapshots so the
856    /// layout can render each `<iframe>`'s content nested inside the host page.
857    pub fn snapshot_iframe_documents(&self) -> Vec<IframeContentSnapshot> {
858        let Some(docs) = with_host(self.engine.vm(), |host| {
859            let mut out: Vec<(u64, Rc<DomTree>)> = Vec::new();
860            for (dom_id, doc) in &host.iframe_documents {
861                out.push((*dom_id, Rc::clone(&doc.borrow().tree)));
862            }
863            Some(out)
864        })
865        .flatten() else {
866            return Vec::new();
867        };
868        let mut snapshots = Vec::with_capacity(docs.len());
869        for (dom_id, tree) in docs {
870            let (content, _refs) = DomSnapshot::from_tree(&tree.root);
871            snapshots.push(IframeContentSnapshot {
872                iframe_dom_id: dom_id,
873                content,
874            });
875        }
876        snapshots
877    }
878
879    /// Replaces the mirror DOM with a snapshot produced by the browser side.
880    ///
881    /// The mirror is rebuilt from `snapshot` and node references are re-registered
882    /// so existing JS element handles keep resolving. JS-created (detached) nodes
883    /// are preserved; they are not part of the committed DOM but may still be
884    /// referenced from scripts.
885    pub fn apply_dom(&mut self, snapshot: &DomSnapshot) {
886        let (tree, dom_ids) = snapshot.into_tree();
887        with_host_mut(self.engine.vm(), |host| {
888            host.dom = Rc::new(tree);
889            let mut refs = std::mem::take(&mut host.refs);
890            host.dom.traverse(|node| {
891                if let Some(&dom_id) = dom_ids.get(&(Rc::as_ptr(node) as usize)) {
892                    refs.insert(dom_id, Rc::downgrade(node));
893                }
894            });
895            for (&dom_id, node) in &host.detached_nodes {
896                refs.entry(dom_id).or_insert_with(|| Rc::downgrade(node));
897            }
898            host.refs = refs;
899            if let Some(max_id) = dom_ids.values().max() {
900                host.next_id = host.next_id.max(*max_id);
901            }
902            Self::register_window_event_handlers(host);
903        });
904    }
905
906    /// Hoists the document's event-handler content attributes that the HTML
907    /// spec maps onto the Window (`<body onload="...">`) into the host's
908    /// handler registry, so dispatching never searches the DOM again.
909    ///
910    /// Called once when a DOM is bound to the runtime (initial parse and every
911    /// `apply_dom`), mirroring how the parser activates a `load` handler on the
912    /// `<body>` element as it is parsed.
913    fn register_window_event_handlers(host: &mut JsHost) {
914        for node in host.dom.find_all(|node| node.tag_name() == Some("body")) {
915            if let Some(code) = node.borrow().value.get_attr("onload") {
916                host.window_inline_event_handlers
917                    .insert("load".to_string(), code.to_string());
918            }
919        }
920    }
921
922    /// Dispatches a click to the handlers registered on the element with the
923    /// given JS-facing dom id. Returns whether at least one handler ran.
924    pub fn click_dom_id(&mut self, dom_id: u64) -> bool {
925        let Some(node) = with_host(self.engine.vm(), |host| {
926            host.refs.get(&dom_id).and_then(|w| w.upgrade())
927        })
928        .flatten() else {
929            return false;
930        };
931        self.click(&node)
932    }
933
934    /// Dispatches a click to the handlers registered on `node`.
935    ///
936    /// Both the `onclick` property and `addEventListener("click", ...)` are
937    /// supported. The event bubbles through exposed ancestor elements so
938    /// delegated listeners such as React's root listener receive it.
939    /// Returns whether at least one handler ran.
940    pub fn click(&mut self, node: &NodeRef<HtmlNodeType>) -> bool {
941        let mut path = Vec::new();
942        let mut current = Some(Rc::clone(node));
943        while let Some(node) = current {
944            current = node.borrow().parent();
945            if let Some(object) = expose_node(self.engine.vm(), node).and_then(|v| v.as_object()) {
946                path.push(object);
947            }
948        }
949        let Some(target) = path.first().cloned() else {
950            return false;
951        };
952
953        let mut ran_handler = false;
954        for current_target in path {
955            let Some(dom_id) = node_dom_id(&JSValue::from_object(Rc::clone(&current_target)))
956            else {
957                continue;
958            };
959            let onclick = current_target.borrow().get("onclick");
960            let listeners = with_host(self.engine.vm(), |host| {
961                host.element_event_listeners
962                    .get(&dom_id)
963                    .and_then(|events| events.get("click"))
964                    .cloned()
965                    .unwrap_or_default()
966            })
967            .unwrap_or_default();
968            let has_onclick = is_callable(&onclick);
969            if !has_onclick && listeners.is_empty() {
970                continue;
971            }
972
973            ran_handler = true;
974            let event = make_event("click", Rc::clone(&target), Rc::clone(&current_target));
975            if has_onclick
976                && let Err(err) = self.engine.call(
977                    onclick,
978                    JSValue::from_object(Rc::clone(&current_target)),
979                    vec![JSValue::from_object(Rc::clone(&event))],
980                )
981            {
982                log::info!("JS error in onclick: {}", err);
983            }
984            if !event_flag(&event, "__orinium_immediate_propagation_stopped") {
985                for listener in listeners {
986                    if let Err(err) = self.engine.call(
987                        listener.0,
988                        JSValue::from_object(Rc::clone(&current_target)),
989                        vec![JSValue::from_object(Rc::clone(&event))],
990                    ) {
991                        log::info!("JS error in click listener: {}", err);
992                    }
993                    if event_flag(&event, "__orinium_immediate_propagation_stopped") {
994                        break;
995                    }
996                }
997            }
998            if event_flag(&event, "cancelBubble") {
999                break;
1000            }
1001        }
1002        if ran_handler {
1003            self.perform_microtask_checkpoint();
1004        }
1005        ran_handler
1006    }
1007
1008    /// Dispatches a `scroll` event to the handlers registered on the element
1009    /// with the given JS-facing dom id. Returns whether at least one handler
1010    /// ran.
1011    pub fn scroll_dom_id(&mut self, dom_id: u64) -> bool {
1012        let Some(node) = with_host(self.engine.vm(), |host| {
1013            host.refs.get(&dom_id).and_then(|w| w.upgrade())
1014        })
1015        .flatten() else {
1016            return false;
1017        };
1018        self.scroll(&node)
1019    }
1020
1021    /// Dispatches a `scroll` event that bubbles through `node`'s exposed
1022    /// ancestors.
1023    ///
1024    /// Both the `onscroll` property and `addEventListener("scroll", ...)` are
1025    /// supported. Returns whether at least one handler ran.
1026    pub fn scroll(&mut self, node: &NodeRef<HtmlNodeType>) -> bool {
1027        let mut path = Vec::new();
1028        let mut current = Some(Rc::clone(node));
1029        while let Some(node) = current {
1030            current = node.borrow().parent();
1031            if let Some(object) = expose_node(self.engine.vm(), node).and_then(|v| v.as_object()) {
1032                path.push(object);
1033            }
1034        }
1035        let Some(target) = path.first().cloned() else {
1036            return false;
1037        };
1038
1039        let mut ran_handler = false;
1040        for current_target in path {
1041            let Some(dom_id) = node_dom_id(&JSValue::from_object(Rc::clone(&current_target)))
1042            else {
1043                continue;
1044            };
1045            let onscroll = current_target.borrow().get("onscroll");
1046            let listeners = with_host(self.engine.vm(), |host| {
1047                host.element_event_listeners
1048                    .get(&dom_id)
1049                    .and_then(|events| events.get("scroll"))
1050                    .cloned()
1051                    .unwrap_or_default()
1052            })
1053            .unwrap_or_default();
1054            let has_onscroll = is_callable(&onscroll);
1055            if !has_onscroll && listeners.is_empty() {
1056                continue;
1057            }
1058
1059            ran_handler = true;
1060            let event = make_event("scroll", Rc::clone(&target), Rc::clone(&current_target));
1061            if has_onscroll
1062                && let Err(err) = self.engine.call(
1063                    onscroll,
1064                    JSValue::from_object(Rc::clone(&current_target)),
1065                    vec![JSValue::from_object(Rc::clone(&event))],
1066                )
1067            {
1068                log::info!("JS error in onscroll: {}", err);
1069            }
1070            if !event_flag(&event, "__orinium_immediate_propagation_stopped") {
1071                for listener in listeners {
1072                    if let Err(err) = self.engine.call(
1073                        listener.0,
1074                        JSValue::from_object(Rc::clone(&current_target)),
1075                        vec![JSValue::from_object(Rc::clone(&event))],
1076                    ) {
1077                        log::info!("JS error in scroll listener: {}", err);
1078                    }
1079                    if event_flag(&event, "__orinium_immediate_propagation_stopped") {
1080                        break;
1081                    }
1082                }
1083            }
1084            if event_flag(&event, "cancelBubble") {
1085                break;
1086            }
1087        }
1088        if ran_handler {
1089            self.perform_microtask_checkpoint();
1090        }
1091        ran_handler
1092    }
1093    /// Drains queued microtasks in FIFO order, including jobs queued by jobs.
1094    fn perform_microtask_checkpoint(&mut self) {
1095        while let Err(err) = self.engine.run_jobs() {
1096            if let JSError::Thrown(value) = &err
1097                && let Some(object) = value.as_object()
1098            {
1099                let object = object.borrow();
1100                let details = object
1101                    .keys()
1102                    .into_iter()
1103                    .map(|key| format!("{key}={}", object.get(&key).to_console_string()))
1104                    .collect::<Vec<_>>()
1105                    .join(", ");
1106                log::info!("JS error in microtask: {} ({details})", err);
1107            } else {
1108                log::info!("JS error in microtask: {}", err);
1109            }
1110        }
1111    }
1112}
1113
1114// ---------------------------------------------------------------------------
1115// Tests
1116// ---------------------------------------------------------------------------
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use crate::engine::html::Parser as HtmlParser;
1122    use web_apis::dom::element::{HTML_NAMESPACE, SVG_NAMESPACE, style_property_name};
1123
1124    fn runtime_from_html(html: &str) -> (JsRuntime, Rc<DomTree>) {
1125        let mut parser = HtmlParser::new(html);
1126        let dom = Rc::new(parser.parse());
1127        let runtime = JsRuntime::new(Rc::clone(&dom));
1128        (runtime, dom)
1129    }
1130
1131    #[test]
1132    fn set_text_content_mutates_dom_and_marks_dirty() {
1133        let (mut runtime, dom) = runtime_from_html(r#"<div id="hello">before</div>"#);
1134        runtime.run_script(
1135            r#"const el = document.getElementById("hello"); el.textContent = "hello from js";"#,
1136        );
1137        assert!(runtime.needs_redraw());
1138        assert!(runtime.take_needs_redraw());
1139
1140        let node = dom.get_element_by_id("hello").unwrap();
1141        assert_eq!(DomTree::inner_text(&node), "hello from js");
1142    }
1143
1144    #[test]
1145    fn viewport_dimensions_follow_browser_resizes() {
1146        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1147        runtime.set_viewport(1280.0, 720.0);
1148        runtime.run_script(
1149            r#"document.getElementById("result").setAttribute("data-size", innerWidth + ":" + innerHeight);"#,
1150        );
1151        assert_eq!(
1152            dom.get_element_by_id("result")
1153                .unwrap()
1154                .borrow()
1155                .value
1156                .get_attr("data-size"),
1157            Some("1280:720")
1158        );
1159        runtime.run_script(
1160            r#"document.getElementById("result").setAttribute("data-root", document.body.clientWidth + ":" + document.body.clientHeight + ":" + outerWidth + ":" + outerHeight);"#,
1161        );
1162        assert_eq!(
1163            dom.get_element_by_id("result")
1164                .unwrap()
1165                .borrow()
1166                .value
1167                .get_attr("data-root"),
1168            Some("1280:720:1280:720")
1169        );
1170    }
1171
1172    #[test]
1173    fn set_attribute_mutates_dom() {
1174        let (mut runtime, dom) = runtime_from_html(r#"<div id="hello"></div>"#);
1175        runtime.run_script(
1176            r#"const el = document.getElementById("hello"); el.setAttribute("data-run", "1");"#,
1177        );
1178
1179        let node = dom.get_element_by_id("hello").unwrap();
1180        assert_eq!(node.borrow().value.get_attr("data-run"), Some("1"));
1181    }
1182
1183    #[test]
1184    fn browser_environment_exposes_react_bootstrap_apis() {
1185        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1186        runtime.set_document_url("https://scratch.mit.edu/projects/editor/?tutorial=1#stage");
1187        runtime.run_script(
1188            r#"
1189            localStorage.setItem("answer", 42);
1190            sessionStorage.setItem("temporary", "yes");
1191            const query = matchMedia("(prefers-color-scheme: dark)");
1192            query.addEventListener("change", function () {});
1193            const event = new CustomEvent("ready", {detail: "loaded", cancelable: true});
1194            event.preventDefault();
1195            const frame = requestAnimationFrame(function () {});
1196            cancelAnimationFrame(frame);
1197            document.getElementById("result").setAttribute(
1198                "data-environment",
1199                navigator.language + ":" + localStorage.getItem("answer") + ":" +
1200                    localStorage.length + ":" + query.matches + ":" + (frame > 0) + ":" +
1201                    location.pathname + ":" + event.detail + ":" + event.defaultPrevented + ":" +
1202                    (typeof Intl === "object") + ":" + ("Locale" in Intl) + ":" +
1203                    Intl.getCanonicalLocales(["EN-us", "ja"])[0] + ":" +
1204                    new Intl.Locale("und-x-private").toString()
1205            );
1206            "#,
1207        );
1208
1209        let node = dom.get_element_by_id("result").unwrap();
1210        assert_eq!(
1211            node.borrow().value.get_attr("data-environment"),
1212            Some(
1213                "en-US:42:1:false:true:/projects/editor/:loaded:true:true:true:en-US:und-x-private"
1214            )
1215        );
1216    }
1217
1218    #[test]
1219    fn browser_origin_exposed_consistently_across_window_location_and_document() {
1220        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1221        runtime.set_page_origin("https://example.test");
1222        runtime.run_script(
1223            r#"document.getElementById("result").setAttribute(
1224                "data-origins",
1225                window.origin + ":" + location.origin + ":" + document.origin
1226            );"#,
1227        );
1228
1229        let node = dom.get_element_by_id("result").unwrap();
1230        assert_eq!(
1231            node.borrow().value.get_attr("data-origins"),
1232            Some("https://example.test:https://example.test:https://example.test")
1233        );
1234    }
1235
1236    #[test]
1237    fn opaque_page_reports_null_origin_everywhere() {
1238        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1239        runtime.set_page_origin("null");
1240        runtime.run_script(
1241            r#"document.getElementById("result").setAttribute(
1242                "data-origins",
1243                window.origin + ":" + location.origin + ":" + document.origin
1244            );"#,
1245        );
1246
1247        let node = dom.get_element_by_id("result").unwrap();
1248        assert_eq!(
1249            node.borrow().value.get_attr("data-origins"),
1250            Some("null:null:null")
1251        );
1252    }
1253
1254    #[test]
1255    fn browser_language_preferences_follow_the_host() {
1256        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1257        runtime.set_language("ja-JP");
1258        runtime.run_script(
1259            r#"
1260            document.getElementById("result").setAttribute(
1261                "data-languages",
1262                navigator.language + ":" + navigator.languages[0] + ":" +
1263                    navigator.languages[1] + ":" + navigator.languages[2]
1264            );
1265            "#,
1266        );
1267
1268        assert_eq!(
1269            dom.get_element_by_id("result")
1270                .unwrap()
1271                .borrow()
1272                .value
1273                .get_attr("data-languages"),
1274            Some("ja-JP:ja-JP:ja:en-US")
1275        );
1276    }
1277
1278    #[test]
1279    fn document_cookie_is_a_string_and_supports_assignment_and_expiry() {
1280        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1281        runtime.run_script(
1282            r#"
1283            const result = document.getElementById("result");
1284            result.setAttribute("data-empty-cookie", typeof document.cookie + ":" + document.cookie);
1285            document.cookie = "scratchlanguage=ja; Path=/";
1286            result.setAttribute("data-cookie", document.cookie);
1287            document.cookie = "scratchlanguage=; Max-Age=0; Path=/";
1288            result.setAttribute("data-expired-cookie", document.cookie);
1289            "#,
1290        );
1291
1292        let result = dom.get_element_by_id("result").unwrap();
1293        let result = result.borrow();
1294        assert_eq!(result.value.get_attr("data-empty-cookie"), Some("string:"));
1295        assert_eq!(
1296            result.value.get_attr("data-cookie"),
1297            Some("scratchlanguage=ja")
1298        );
1299        assert_eq!(result.value.get_attr("data-expired-cookie"), Some(""));
1300    }
1301
1302    #[test]
1303    fn url_apis_resolve_assets_and_manage_query_parameters() {
1304        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1305        runtime.run_script(
1306            r#"
1307            const asset = new URL("../assets/stage.svg?locale=ja#costume", "https://scratch.mit.edu/projects/editor/");
1308            const params = new URLSearchParams("project=123&mode=editor");
1309            params.set("mode", "fullscreen");
1310            params.append("cloud", "on");
1311            params.delete("project");
1312            document.getElementById("result").setAttribute(
1313                "data-url",
1314                asset.origin + ":" + asset.pathname + ":" + asset.searchParams.get("locale") +
1315                    ":" + params.has("cloud") + ":" + params.toString()
1316            );
1317            "#,
1318        );
1319
1320        let result = dom.get_element_by_id("result").unwrap();
1321        assert_eq!(
1322            result.borrow().value.get_attr("data-url"),
1323            Some(
1324                "https://scratch.mit.edu:/projects/assets/stage.svg:ja:true:mode=fullscreen&cloud=on"
1325            )
1326        );
1327    }
1328
1329    #[test]
1330    fn encoding_apis_round_trip_utf8_and_base64() {
1331        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1332        runtime.run_script(
1333            r#"
1334            const encoder = new TextEncoder();
1335            const decoder = new TextDecoder("utf-8");
1336            const bytes = encoder.encode("Scratch 日本");
1337            document.getElementById("result").setAttribute(
1338                "data-encoding",
1339                decoder.decode(bytes) + ":" + bytes.length + ":" + atob(btoa("Scratch")) +
1340                    ":" + decodeURIComponent(encodeURIComponent("日本 語"))
1341            );
1342            "#,
1343        );
1344
1345        let result = dom.get_element_by_id("result").unwrap();
1346        assert_eq!(
1347            result.borrow().value.get_attr("data-encoding"),
1348            Some("Scratch 日本:14:Scratch:日本 語")
1349        );
1350    }
1351
1352    #[test]
1353    fn layout_measurement_and_resize_observer_report_element_size() {
1354        let (mut runtime, dom) = runtime_from_html(
1355            r#"<canvas id="stage" width="480" height="360"></canvas><div id="result"></div>"#,
1356        );
1357        runtime.run_script(
1358            r#"
1359            const stage = document.getElementById("stage");
1360            const rect = stage.getBoundingClientRect();
1361            const observer = new ResizeObserver(function (entries) {
1362                const observed = entries[0].contentRect;
1363                document.getElementById("result").setAttribute(
1364                    "data-resize",
1365                    observed.width + ":" + observed.height
1366                );
1367            });
1368            observer.observe(stage);
1369            document.getElementById("result").setAttribute(
1370                "data-measure",
1371                rect.width + ":" + rect.height + ":" + stage.clientWidth + ":" + stage.offsetHeight
1372            );
1373            "#,
1374        );
1375
1376        let result = dom.get_element_by_id("result").unwrap();
1377        let result = result.borrow();
1378        assert_eq!(
1379            result.value.get_attr("data-measure"),
1380            Some("480:360:480:360")
1381        );
1382        assert_eq!(result.value.get_attr("data-resize"), Some("480:360"));
1383    }
1384
1385    #[test]
1386    fn layout_offsets_have_a_numeric_fallback() {
1387        let (mut runtime, dom) = runtime_from_html(
1388            r#"<div id="list" class="carousel slick-list"></div><div id="plain"></div><div id="result"></div>"#,
1389        );
1390        runtime.run_script(
1391            r#"
1392            document.getElementById("result").setAttribute(
1393                "data-widths",
1394                document.getElementById("list").offsetWidth + ":" +
1395                    document.getElementById("plain").offsetWidth + ":" +
1396                    document.getElementById("list").offsetLeft + ":" +
1397                    document.getElementById("list").offsetTop
1398            );
1399            "#,
1400        );
1401
1402        assert_eq!(
1403            dom.get_element_by_id("result")
1404                .unwrap()
1405                .borrow()
1406                .value
1407                .get_attr("data-widths"),
1408            Some("800:0:0:0")
1409        );
1410    }
1411
1412    #[test]
1413    fn dom_measurements_prefer_committed_layout_geometry() {
1414        let (mut runtime, dom) = runtime_from_html(
1415            r#"<div id="target" style="width: 1px; height: 2px"></div><div id="result"></div>"#,
1416        );
1417        let target = dom.get_element_by_id("target").unwrap();
1418        runtime.set_layout_metrics(HashMap::from([(
1419            Rc::as_ptr(&target) as usize,
1420            JsLayoutMetrics {
1421                offset_left: 12.0,
1422                offset_top: 34.0,
1423                offset_width: 222.0,
1424                offset_height: 111.0,
1425                client_width: 218.0,
1426                client_height: 107.0,
1427                rect_left: 42.5,
1428                rect_top: 64.25,
1429                rect_width: 222.0,
1430                rect_height: 111.0,
1431            },
1432        )]));
1433        runtime.run_script(
1434            r#"
1435            const target = document.getElementById("target");
1436            const rect = target.getBoundingClientRect();
1437            document.getElementById("result").setAttribute(
1438                "data-layout",
1439                target.offsetLeft + ":" + target.offsetTop + ":" +
1440                    target.offsetWidth + ":" + target.offsetHeight + ":" +
1441                    target.clientWidth + ":" + target.clientHeight + ":" +
1442                    rect.left + ":" + rect.top + ":" + rect.right + ":" + rect.bottom
1443            );
1444            "#,
1445        );
1446
1447        assert_eq!(
1448            dom.get_element_by_id("result")
1449                .unwrap()
1450                .borrow()
1451                .value
1452                .get_attr("data-layout"),
1453            Some("12:34:222:111:218:107:42.5:64.25:264.5:175.25")
1454        );
1455    }
1456
1457    #[test]
1458    fn inserting_script_element_queues_dynamic_resource_load() {
1459        let (mut runtime, _dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
1460        runtime.run_script(
1461            r#"
1462            const script = document.createElement("script");
1463            script.src = "/static/chunks/editor.js";
1464            script.async = true;
1465            document.head.appendChild(script);
1466            "#,
1467        );
1468
1469        let requests = runtime.take_dynamic_script_requests();
1470        assert_eq!(requests.len(), 1);
1471        assert!(requests[0].node_id > 0);
1472        match &requests[0].source {
1473            JsDynamicScriptSource::External(source) => {
1474                assert_eq!(source, "/static/chunks/editor.js")
1475            }
1476            JsDynamicScriptSource::Inline(_) => panic!("expected an external script request"),
1477        }
1478    }
1479
1480    #[test]
1481    fn inserting_stylesheet_link_queues_dynamic_resource_load() {
1482        let (mut runtime, _dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
1483        runtime.run_script(
1484            r#"
1485            const link = document.createElement("link");
1486            link.rel = "stylesheet";
1487            link.href = "/static/css/editor.css";
1488            document.head.appendChild(link);
1489            "#,
1490        );
1491
1492        let requests = runtime.take_dynamic_style_requests();
1493        assert_eq!(requests.len(), 1);
1494        assert!(requests[0].node_id > 0);
1495        assert_eq!(requests[0].url, "/static/css/editor.css");
1496    }
1497
1498    #[test]
1499    fn inserting_image_queues_dynamic_resource_load_once() {
1500        let (mut runtime, _dom) = runtime_from_html(r#"<html><body></body></html>"#);
1501        runtime.run_script(
1502            r#"
1503            const image = document.createElement("img");
1504            image.src = "/images/scratch-logo.svg";
1505            document.body.appendChild(image);
1506            "#,
1507        );
1508
1509        let requests = runtime.take_dynamic_image_requests();
1510        assert_eq!(requests.len(), 1);
1511        assert_eq!(requests[0].source, "/images/scratch-logo.svg");
1512    }
1513
1514    #[test]
1515    fn canvas_2d_context_records_visible_rectangle_commands() {
1516        let (mut runtime, dom) = runtime_from_html(r#"<canvas id="stage"></canvas>"#);
1517        runtime.run_script(
1518            r##"
1519            const canvas = document.getElementById("stage");
1520            canvas.width = 480;
1521            canvas.height = 360;
1522            const context = canvas.getContext("2d");
1523            context.fillStyle = "#ff8800";
1524            context.fillRect(10, 20, 30, 40);
1525            context.strokeStyle = "blue";
1526            context.strokeRect(0, 0, 480, 360);
1527            canvas.setAttribute("data-metrics", context.measureText("Scratch").width);
1528            "##,
1529        );
1530
1531        let canvas = dom.get_element_by_id("stage").unwrap();
1532        let canvas = canvas.borrow();
1533        assert_eq!(canvas.value.get_attr("width"), Some("480"));
1534        assert_eq!(canvas.value.get_attr("height"), Some("360"));
1535        assert_eq!(canvas.value.get_attr("data-metrics"), Some("42"));
1536        assert_eq!(
1537            canvas.value.get_attr("data-orinium-canvas-commands"),
1538            Some("fillRect|#ff8800|10|20|30|40\nstrokeRect|blue|0|0|480|360")
1539        );
1540    }
1541
1542    #[test]
1543    fn canvas_exposes_webgl_capability_surface() {
1544        let (mut runtime, dom) =
1545            runtime_from_html(r#"<canvas id="stage" width="480" height="360"></canvas>"#);
1546        runtime.run_script(
1547            r#"
1548            const canvas = document.getElementById("stage");
1549            const gl = canvas.getContext("webgl");
1550            const shader = gl.createShader(gl.VERTEX_SHADER);
1551            gl.shaderSource(shader, "void main() {}");
1552            gl.compileShader(shader);
1553            const program = gl.createProgram();
1554            gl.attachShader(program, shader);
1555            gl.linkProgram(program);
1556            canvas.setAttribute(
1557                "data-webgl",
1558                gl.getShaderParameter(shader, gl.COMPILE_STATUS) + ":" +
1559                    gl.getProgramParameter(program, gl.LINK_STATUS) + ":" +
1560                    gl.getParameter(gl.MAX_TEXTURE_SIZE) + ":" + gl.drawingBufferWidth
1561            );
1562            "#,
1563        );
1564
1565        let canvas = dom.get_element_by_id("stage").unwrap();
1566        assert_eq!(
1567            canvas.borrow().value.get_attr("data-webgl"),
1568            Some("true:true:4096:480")
1569        );
1570    }
1571
1572    #[test]
1573    fn mutation_observer_can_register_for_dom_changes() {
1574        let (mut runtime, dom) =
1575            runtime_from_html(r#"<html><body><div id="target"></div></body></html>"#);
1576        runtime.run_script(
1577            r#"
1578            const observer = new MutationObserver(function () {
1579                document.getElementById("target").setAttribute("data-observed", "yes");
1580            });
1581            observer.observe(document.documentElement, { childList: true, subtree: true });
1582            const records = observer.takeRecords();
1583            observer.disconnect();
1584            document.getElementById("target").setAttribute("data-records", records.length);
1585            "#,
1586        );
1587
1588        let node = dom.get_element_by_id("target").unwrap();
1589        assert_eq!(node.borrow().value.get_attr("data-records"), Some("0"));
1590        assert_eq!(node.borrow().value.get_attr("data-observed"), Some("yes"));
1591    }
1592
1593    #[test]
1594    fn element_id_is_a_live_reflected_property() {
1595        let (mut runtime, dom) = runtime_from_html(r#"<main id="root"></main>"#);
1596        runtime.run_script(
1597            r#"
1598            const child = document.createElement("button");
1599            child.id = "first";
1600            document.getElementById("root").appendChild(child);
1601            child.setAttribute("id", "second");
1602            child.setAttribute("data-current-id", child.id);
1603            "#,
1604        );
1605
1606        let child = dom.get_element_by_id("second").unwrap();
1607        assert_eq!(
1608            child.borrow().value.get_attr("data-current-id"),
1609            Some("second")
1610        );
1611        assert!(runtime.needs_redraw());
1612    }
1613
1614    #[test]
1615    fn form_properties_reflect_to_dom_attributes() {
1616        let (mut runtime, dom) = runtime_from_html(
1617            r#"<input id="field"><option id="option"></option><select id="select"></select>"#,
1618        );
1619        runtime.run_script(
1620            r#"
1621            const field = document.getElementById("field");
1622            field.value = "hello";
1623            field.checked = true;
1624            field.disabled = true;
1625            field.checked = false;
1626            const option = document.getElementById("option");
1627            option.selected = true;
1628            const select = document.getElementById("select");
1629            select.multiple = true;
1630            "#,
1631        );
1632
1633        let field = dom.get_element_by_id("field").unwrap();
1634        let field = field.borrow();
1635        assert_eq!(field.value.get_attr("value"), Some("hello"));
1636        assert_eq!(field.value.get_attr("checked"), None);
1637        assert_eq!(field.value.get_attr("disabled"), Some(""));
1638        drop(field);
1639        let option = dom.get_element_by_id("option").unwrap();
1640        assert_eq!(option.borrow().value.get_attr("selected"), Some(""));
1641        let select = dom.get_element_by_id("select").unwrap();
1642        assert_eq!(select.borrow().value.get_attr("multiple"), Some(""));
1643        assert!(runtime.needs_redraw());
1644    }
1645
1646    #[test]
1647    fn form_properties_are_accessors_on_the_element_prototype() {
1648        let (mut runtime, dom) = runtime_from_html(r#"<input id="field">"#);
1649        runtime.run_script(
1650            r#"
1651            const field = document.getElementById("field");
1652            const prototype = field.constructor.prototype;
1653            const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
1654            field.setAttribute("data-prototype", Object.getPrototypeOf(field) === prototype);
1655            field.setAttribute("data-interface", field.constructor === HTMLElement && field instanceof Element);
1656            field.setAttribute("data-own-value", field.hasOwnProperty("value"));
1657            field.setAttribute("data-accessor", typeof descriptor.get + ":" + typeof descriptor.set);
1658            descriptor.set.call(field, "tracked");
1659            field.setAttribute("data-read", descriptor.get.call(field));
1660            "#,
1661        );
1662
1663        let field = dom.get_element_by_id("field").unwrap();
1664        let field = field.borrow();
1665        assert_eq!(field.value.get_attr("data-prototype"), Some("true"));
1666        assert_eq!(field.value.get_attr("data-interface"), Some("true"));
1667        assert_eq!(field.value.get_attr("data-own-value"), Some("false"));
1668        assert_eq!(
1669            field.value.get_attr("data-accessor"),
1670            Some("function:function")
1671        );
1672        assert_eq!(field.value.get_attr("data-read"), Some("tracked"));
1673        assert_eq!(field.value.get_attr("value"), Some("tracked"));
1674    }
1675
1676    #[test]
1677    fn exposes_document_and_node_metadata_used_by_react_dom() {
1678        let (mut runtime, dom) = runtime_from_html(
1679            r#"<html><body><main id="root"><span id="child">text</span></main></body></html>"#,
1680        );
1681        runtime.run_script(
1682            r#"
1683            const root = document.getElementById("root");
1684            const child = document.getElementById("child");
1685            child.setAttribute("data-default-view", document.defaultView === window);
1686            child.setAttribute("data-ready-before", document.readyState);
1687            child.setAttribute("data-local-name", child.localName);
1688            child.setAttribute("data-parent-element", child.parentElement === root);
1689            child.setAttribute("data-connected", child.isConnected);
1690            child.setAttribute("data-text-connected", child.firstChild.isConnected);
1691            "#,
1692        );
1693
1694        let child = dom.get_element_by_id("child").unwrap();
1695        let child = child.borrow();
1696        assert_eq!(child.value.get_attr("data-default-view"), Some("true"));
1697        assert_eq!(child.value.get_attr("data-ready-before"), Some("loading"));
1698        assert_eq!(child.value.get_attr("data-local-name"), Some("span"));
1699        assert_eq!(child.value.get_attr("data-parent-element"), Some("true"));
1700        assert_eq!(child.value.get_attr("data-connected"), Some("true"));
1701        assert_eq!(child.value.get_attr("data-text-connected"), Some("true"));
1702        drop(child);
1703
1704        assert!(runtime.dispatch_dom_content_loaded());
1705        runtime.run_script(
1706            r#"
1707            document.getElementById("child").setAttribute(
1708                "data-ready-after",
1709                document.readyState
1710            );
1711            "#,
1712        );
1713        assert_eq!(
1714            dom.get_element_by_id("child")
1715                .unwrap()
1716                .borrow()
1717                .value
1718                .get_attr("data-ready-after"),
1719            Some("complete")
1720        );
1721    }
1722
1723    #[test]
1724    fn style_declaration_mutates_inline_style() {
1725        let (mut runtime, dom) = runtime_from_html(r#"<div id="target"></div>"#);
1726        runtime.run_script(
1727            r#"
1728            const target = document.getElementById("target");
1729            target.style.backgroundColor = "red";
1730            target.style.setProperty("--accent", "blue");
1731            target.style.marginTop = "4px";
1732            target.style.removeProperty("background-color");
1733            "#,
1734        );
1735
1736        let node = dom.get_element_by_id("target").unwrap();
1737        assert_eq!(
1738            node.borrow().value.get_attr("style"),
1739            Some("--accent: blue; margin-top: 4px;")
1740        );
1741        assert!(runtime.needs_redraw());
1742    }
1743
1744    #[test]
1745    fn inner_html_parses_replaces_and_serializes_children() {
1746        let (mut runtime, dom) = runtime_from_html(
1747            r#"<div id="target"><em id="old">old</em></div><div id="result"></div>"#,
1748        );
1749        runtime.run_script(
1750            r#"
1751            const target = document.getElementById("target");
1752            const old = document.getElementById("old");
1753            target.innerHTML = '<span id="child" data-label="a&b">hello</span><br>';
1754            old.setAttribute("data-detached", "yes");
1755            document.getElementById("result").setAttribute("data-html", target.innerHTML);
1756            "#,
1757        );
1758
1759        let target = dom.get_element_by_id("target").unwrap();
1760        assert_eq!(target.borrow().children().len(), 2);
1761        assert!(dom.get_element_by_id("old").is_none());
1762        assert_eq!(
1763            dom.get_element_by_id("result")
1764                .unwrap()
1765                .borrow()
1766                .value
1767                .get_attr("data-html"),
1768            Some("<span id=\"child\" data-label=\"a&amp;b\">hello</span><br>")
1769        );
1770        assert!(runtime.needs_redraw());
1771    }
1772
1773    #[test]
1774    fn style_property_names_follow_cssom_spelling() {
1775        assert_eq!(style_property_name("backgroundColor"), "background-color");
1776        assert_eq!(style_property_name("msTransition"), "-ms-transition");
1777        assert_eq!(style_property_name("WebkitTransform"), "-webkit-transform");
1778        assert_eq!(style_property_name("cssFloat"), "float");
1779        assert_eq!(style_property_name("--accent"), "--accent");
1780    }
1781
1782    #[test]
1783    fn get_attribute_reads_dom() {
1784        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello" data-x="v"></div>"#);
1785        runtime.run_script(
1786            r#"const el = document.getElementById("hello"); if (el.getAttribute("data-x") !== "v") { throw new Error("mismatch"); }"#,
1787        );
1788    }
1789
1790    #[test]
1791    fn missing_id_returns_null() {
1792        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello"></div>"#);
1793        runtime.run_script(
1794            r#"const el = document.getElementById("missing"); if (el !== null) { throw new Error("expected null"); }"#,
1795        );
1796    }
1797
1798    #[test]
1799    fn console_log_does_not_panic() {
1800        let (mut runtime, _dom) = runtime_from_html(r#"<html></html>"#);
1801        runtime.run_script(
1802            r#"console.log("a", 1, undefined); console.warn("w"); console.error("e");"#,
1803        );
1804    }
1805
1806    #[test]
1807    fn syntax_error_is_logged_not_panicked() {
1808        let (mut runtime, _dom) = runtime_from_html(r#"<html></html>"#);
1809        runtime.run_script("this is not valid js ((");
1810    }
1811
1812    #[test]
1813    fn accessor_reads_text_content() {
1814        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello">hi</div>"#);
1815        runtime.run_script(
1816            r#"const el = document.getElementById("hello"); if (el.textContent !== "hi") { throw new Error("mismatch"); }"#,
1817        );
1818    }
1819
1820    #[test]
1821    fn click_invokes_onclick_and_mutates_dom() {
1822        let (mut runtime, dom) =
1823            runtime_from_html(r#"<button id="b">click me</button><p id="result">not clicked</p>"#);
1824        runtime.run_script(
1825            r#"
1826            const button = document.getElementById("b");
1827            const result = document.getElementById("result");
1828            button.onclick = function () {
1829                result.textContent = "clicked!";
1830                result.setAttribute("data-clicked", "true");
1831            };
1832            "#,
1833        );
1834
1835        let button = dom.get_element_by_id("b").unwrap();
1836        assert!(runtime.click(&button));
1837        assert!(runtime.needs_redraw());
1838        assert!(runtime.take_needs_redraw());
1839
1840        let result = dom.get_element_by_id("result").unwrap();
1841        assert_eq!(DomTree::inner_text(&result), "clicked!");
1842        assert_eq!(result.borrow().value.get_attr("data-clicked"), Some("true"));
1843    }
1844
1845    #[test]
1846    fn click_without_handler_is_noop() {
1847        let (mut runtime, dom) = runtime_from_html(r#"<div id="x"></div>"#);
1848        runtime.run_script(r#"document.getElementById("x");"#);
1849        let node = dom.get_element_by_id("x").unwrap();
1850        assert!(!runtime.click(&node));
1851        assert!(!runtime.needs_redraw());
1852    }
1853
1854    #[test]
1855    fn click_invokes_element_event_listeners_in_registration_order() {
1856        let (mut runtime, dom) =
1857            runtime_from_html(r#"<button id="button">click</button><div id="result"></div>"#);
1858        runtime.run_script(
1859            r#"
1860            const button = document.getElementById("button");
1861            const result = document.getElementById("result");
1862            let order = "";
1863            button.addEventListener("click", function (event) {
1864                order = order + "a";
1865                result.setAttribute("data-event-type", event.type);
1866            });
1867            button.addEventListener("click", function () {
1868                order = order + "b";
1869                result.setAttribute("data-order", order);
1870            });
1871            "#,
1872        );
1873
1874        let button = dom.get_element_by_id("button").unwrap();
1875        assert!(runtime.click(&button));
1876
1877        let result = dom.get_element_by_id("result").unwrap();
1878        assert_eq!(result.borrow().value.get_attr("data-order"), Some("ab"));
1879        assert_eq!(
1880            result.borrow().value.get_attr("data-event-type"),
1881            Some("click")
1882        );
1883        assert!(runtime.needs_redraw());
1884    }
1885
1886    #[test]
1887    fn event_listeners_are_deduplicated_and_removable() {
1888        let (mut runtime, dom) =
1889            runtime_from_html(r#"<button id="button">click</button><div id="result"></div>"#);
1890        runtime.run_script(
1891            r#"
1892            const button = document.getElementById("button");
1893            const result = document.getElementById("result");
1894            function listener() {
1895                const count = result.getAttribute("data-count");
1896                result.setAttribute("data-count", count === null ? 1 : Number(count) + 1);
1897            }
1898            button.addEventListener("click", listener);
1899            button.addEventListener("click", listener);
1900            "#,
1901        );
1902
1903        let button = dom.get_element_by_id("button").unwrap();
1904        assert!(runtime.click(&button));
1905        let result = dom.get_element_by_id("result").unwrap();
1906        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
1907
1908        runtime.run_script(
1909            r#"
1910            button.removeEventListener("click", listener);
1911            window.addEventListener("test", listener);
1912            window.removeEventListener("test", listener);
1913            "#,
1914        );
1915        assert!(!runtime.click(&button));
1916        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
1917    }
1918
1919    #[test]
1920    fn document_event_listeners_can_be_removed() {
1921        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1922        runtime.run_script(
1923            r#"
1924            const result = document.getElementById("result");
1925            function listener() { result.setAttribute("data-ran", "yes"); }
1926            document.addEventListener("DOMContentLoaded", listener);
1927            document.removeEventListener("DOMContentLoaded", listener);
1928            "#,
1929        );
1930
1931        assert!(runtime.dispatch_dom_content_loaded());
1932        let result = dom.get_element_by_id("result").unwrap();
1933        assert_eq!(result.borrow().value.get_attr("data-ran"), None);
1934    }
1935
1936    #[test]
1937    fn click_bubbles_to_delegated_ancestor_listeners() {
1938        let (mut runtime, dom) = runtime_from_html(
1939            r#"<main id="root"><button id="button">click</button></main><div id="result"></div>"#,
1940        );
1941        runtime.run_script(
1942            r#"
1943            const root = document.getElementById("root");
1944            const result = document.getElementById("result");
1945            root.addEventListener("click", function (event) {
1946                result.setAttribute("data-target", event.target.id);
1947                result.setAttribute("data-current", event.currentTarget.id);
1948                result.setAttribute("data-this", this.id);
1949                event.preventDefault();
1950                result.setAttribute("data-prevented", event.defaultPrevented);
1951            });
1952            "#,
1953        );
1954
1955        let button = dom.get_element_by_id("button").unwrap();
1956        assert!(runtime.click(&button));
1957        let result = dom.get_element_by_id("result").unwrap();
1958        let result = result.borrow();
1959        assert_eq!(result.value.get_attr("data-target"), Some("button"));
1960        assert_eq!(result.value.get_attr("data-current"), Some("root"));
1961        assert_eq!(result.value.get_attr("data-this"), Some("root"));
1962        assert_eq!(result.value.get_attr("data-prevented"), Some("true"));
1963    }
1964
1965    #[test]
1966    fn click_propagation_can_be_stopped() {
1967        let (mut runtime, dom) = runtime_from_html(
1968            r#"<main id="root"><button id="button">click</button></main><div id="result"></div>"#,
1969        );
1970        runtime.run_script(
1971            r#"
1972            const root = document.getElementById("root");
1973            const button = document.getElementById("button");
1974            const result = document.getElementById("result");
1975            button.addEventListener("click", function (event) {
1976                result.setAttribute("data-child", "ran");
1977                event.stopPropagation();
1978            });
1979            root.addEventListener("click", function () {
1980                result.setAttribute("data-root", "ran");
1981            });
1982            "#,
1983        );
1984
1985        let button = dom.get_element_by_id("button").unwrap();
1986        assert!(runtime.click(&button));
1987        let result = dom.get_element_by_id("result").unwrap();
1988        let result = result.borrow();
1989        assert_eq!(result.value.get_attr("data-child"), Some("ran"));
1990        assert_eq!(result.value.get_attr("data-root"), None);
1991    }
1992
1993    #[test]
1994    fn click_does_not_invoke_other_event_types() {
1995        let (mut runtime, dom) = runtime_from_html(r#"<button id="button">click</button>"#);
1996        runtime.run_script(
1997            r#"
1998            const button = document.getElementById("button");
1999            button.addEventListener("mouseover", function () {
2000                button.setAttribute("data-ran", "yes");
2001            });
2002            "#,
2003        );
2004
2005        let button = dom.get_element_by_id("button").unwrap();
2006        assert!(!runtime.click(&button));
2007        assert_eq!(button.borrow().value.get_attr("data-ran"), None);
2008    }
2009
2010    #[test]
2011    fn scroll_invokes_onscroll_and_mutates_dom() {
2012        let (mut runtime, dom) =
2013            runtime_from_html(r#"<div id="s">scroll</div><p id="result">not scrolled</p>"#);
2014        runtime.run_script(
2015            r#"
2016            const s = document.getElementById("s");
2017            const result = document.getElementById("result");
2018            window.__scrolls = 0;
2019            s.onscroll = function () {
2020                window.__scrolls = (window.__scrolls || 0) + 1;
2021                result.textContent = "scrolled!";
2022            };
2023            "#,
2024        );
2025
2026        let s = dom.get_element_by_id("s").unwrap();
2027        assert!(runtime.scroll(&s));
2028        let result = dom.get_element_by_id("result").unwrap();
2029        assert_eq!(DomTree::inner_text(&result), "scrolled!");
2030    }
2031
2032    #[test]
2033    fn scroll_without_handler_is_noop() {
2034        let (mut runtime, dom) = runtime_from_html(r#"<div id="x"></div>"#);
2035        runtime.run_script(r#"document.getElementById("x");"#);
2036        let node = dom.get_element_by_id("x").unwrap();
2037        assert!(!runtime.scroll(&node));
2038    }
2039
2040    #[test]
2041    fn scroll_invokes_element_listeners_in_registration_order() {
2042        let (mut runtime, dom) =
2043            runtime_from_html(r#"<div id="s">scroll</div><div id="result"></div>"#);
2044        runtime.run_script(
2045            r#"
2046            const s = document.getElementById("s");
2047            const result = document.getElementById("result");
2048            let order = "";
2049            s.addEventListener("scroll", function (event) {
2050                order = order + "a";
2051                result.setAttribute("data-event-type", event.type);
2052            });
2053            s.addEventListener("scroll", function () {
2054                order = order + "b";
2055                result.setAttribute("data-order", order);
2056            });
2057            "#,
2058        );
2059
2060        let s = dom.get_element_by_id("s").unwrap();
2061        assert!(runtime.scroll(&s));
2062
2063        let result = dom.get_element_by_id("result").unwrap();
2064        assert_eq!(result.borrow().value.get_attr("data-order"), Some("ab"));
2065        assert_eq!(
2066            result.borrow().value.get_attr("data-event-type"),
2067            Some("scroll")
2068        );
2069    }
2070
2071    #[test]
2072    fn scroll_bubbles_to_delegated_ancestor_listeners() {
2073        let (mut runtime, dom) = runtime_from_html(
2074            r#"<main id="root"><div id="s">scroll</div></main><div id="result"></div>"#,
2075        );
2076        runtime.run_script(
2077            r#"
2078            const root = document.getElementById("root");
2079            const result = document.getElementById("result");
2080            root.addEventListener("scroll", function (event) {
2081                result.setAttribute("data-target", event.target.id);
2082            });
2083            "#,
2084        );
2085
2086        let s = dom.get_element_by_id("s").unwrap();
2087        assert!(runtime.scroll(&s));
2088
2089        let result = dom.get_element_by_id("result").unwrap();
2090        assert_eq!(result.borrow().value.get_attr("data-target"), Some("s"));
2091    }
2092
2093    #[test]
2094    fn scroll_dom_id_dispatches_to_the_named_element() {
2095        let (mut runtime, dom) = runtime_from_html(r#"<div id="s">scroll</div>"#);
2096        runtime.run_script(
2097            r#"
2098            const s = document.getElementById("s");
2099            s.addEventListener("scroll", function () {
2100                s.setAttribute("data-ran", "yes");
2101            });
2102            "#,
2103        );
2104        let s = dom.get_element_by_id("s").unwrap();
2105        // mirror the browser's mapping: resolve the live node's hidden dom id
2106        // and route the scroll through it.
2107        let dom_id = with_host(runtime.engine.vm(), |host| {
2108            host.refs
2109                .iter()
2110                .find(|(_, weak)| {
2111                    weak.upgrade()
2112                        .is_some_and(|n| Rc::as_ptr(&n) == Rc::as_ptr(&s))
2113                })
2114                .map(|(id, _)| *id)
2115        })
2116        .flatten()
2117        .expect("element must be registered in the host refs");
2118        assert!(runtime.scroll_dom_id(dom_id));
2119        assert_eq!(s.borrow().value.get_attr("data-ran"), Some("yes"));
2120    }
2121
2122    #[test]
2123    fn scroll_does_not_invoke_other_event_types() {
2124        let (mut runtime, dom) = runtime_from_html(r#"<div id="s">scroll</div>"#);
2125        runtime.run_script(
2126            r#"
2127            const s = document.getElementById("s");
2128            s.addEventListener("mouseover", function () {
2129                s.setAttribute("data-ran", "yes");
2130            });
2131            "#,
2132        );
2133
2134        let s = dom.get_element_by_id("s").unwrap();
2135        assert!(!runtime.scroll(&s));
2136        assert_eq!(s.borrow().value.get_attr("data-ran"), None);
2137    }
2138
2139    #[test]
2140    fn get_element_by_id_reuses_the_same_object() {
2141        let (mut runtime, _dom) = runtime_from_html(r#"<div id="x"></div>"#);
2142        runtime.run_script(
2143            r#"
2144            const a = document.getElementById("x");
2145            const b = document.getElementById("x");
2146            a.onclick = function () {};
2147            if (a !== b) { throw new Error("expected the same object"); }
2148            "#,
2149        );
2150    }
2151
2152    #[test]
2153    fn dom_content_loaded_listener_runs_when_dispatched() {
2154        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2155        runtime.run_script(
2156            r#"
2157            document.addEventListener("DOMContentLoaded", function (event) {
2158                const result = document.getElementById("result");
2159                result.setAttribute("data-ready", "yes");
2160                result.setAttribute("data-event-type", event.type);
2161            });
2162            "#,
2163        );
2164
2165        let result = dom.get_element_by_id("result").unwrap();
2166        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
2167        assert!(runtime.dispatch_dom_content_loaded());
2168        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
2169        assert_eq!(
2170            result.borrow().value.get_attr("data-event-type"),
2171            Some("DOMContentLoaded")
2172        );
2173        assert!(runtime.needs_redraw());
2174    }
2175
2176    #[test]
2177    fn dom_content_loaded_is_dispatched_only_once() {
2178        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2179        runtime.run_script(
2180            r#"
2181            let dispatchCount = 0;
2182            document.addEventListener("DOMContentLoaded", function () {
2183                dispatchCount = dispatchCount + 1;
2184                document.getElementById("result").setAttribute("data-count", dispatchCount);
2185            });
2186            "#,
2187        );
2188
2189        assert!(runtime.dispatch_dom_content_loaded());
2190        assert!(!runtime.dispatch_dom_content_loaded());
2191        let result = dom.get_element_by_id("result").unwrap();
2192        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
2193    }
2194
2195    #[test]
2196    fn window_onload_runs_when_dispatched() {
2197        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2198        runtime.run_script(
2199            r#"
2200            window.onload = function (event) {
2201                const result = document.getElementById("result");
2202                result.setAttribute("data-ready", "yes");
2203                result.setAttribute("data-event-type", event.type);
2204            };
2205            "#,
2206        );
2207
2208        let result = dom.get_element_by_id("result").unwrap();
2209        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
2210        assert!(runtime.dispatch_window_load());
2211        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
2212        assert_eq!(
2213            result.borrow().value.get_attr("data-event-type"),
2214            Some("load")
2215        );
2216    }
2217
2218    #[test]
2219    fn window_load_listener_runs_when_dispatched_and_only_once() {
2220        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2221        runtime.run_script(
2222            r#"
2223            let dispatchCount = 0;
2224            window.addEventListener("load", function () {
2225                dispatchCount = dispatchCount + 1;
2226                document.getElementById("result").setAttribute("data-count", dispatchCount);
2227            });
2228            "#,
2229        );
2230
2231        assert!(runtime.dispatch_window_load());
2232        assert!(!runtime.dispatch_window_load());
2233        let result = dom.get_element_by_id("result").unwrap();
2234        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
2235    }
2236
2237    #[test]
2238    fn body_onload_registered_at_setup_runs_on_dispatch() {
2239        let (mut runtime, dom) = runtime_from_html(
2240            r#"<body onload="document.getElementById('result').setAttribute('data-onload', 'yes')"><div id="result"></div></body>"#,
2241        );
2242
2243        let result = dom.get_element_by_id("result").unwrap();
2244        assert_eq!(result.borrow().value.get_attr("data-onload"), None);
2245        assert!(runtime.dispatch_window_load());
2246        assert_eq!(result.borrow().value.get_attr("data-onload"), Some("yes"));
2247        // The handler is registered at setup time, not re-scanned at dispatch.
2248        assert!(!runtime.dispatch_window_load());
2249    }
2250
2251    #[test]
2252    fn document_query_selector_and_query_selector_all_expose_elements() {
2253        let (mut runtime, dom) = runtime_from_html(
2254            r#"
2255            <div id="result"></div>
2256            <main><p class="item">first</p><p class="item featured">second</p></main>
2257            "#,
2258        );
2259        runtime.run_script(
2260            r#"
2261            const featured = document.querySelector("main > p.featured");
2262            featured.setAttribute("data-selected", "yes");
2263            const items = document.querySelectorAll("p.item");
2264            const classified = document.querySelectorAll("[class]");
2265            items[0].setAttribute("data-first", "yes");
2266            items.forEach(function (item, index) {
2267                item.setAttribute("data-index", index);
2268            });
2269            document.getElementById("result").setAttribute("data-count", items.length);
2270            document.getElementById("result").setAttribute("data-class-count", classified.length);
2271            "#,
2272        );
2273
2274        let items = dom.get_elements_by_class_name("item");
2275        assert_eq!(items[0].borrow().value.get_attr("data-first"), Some("yes"));
2276        assert_eq!(items[0].borrow().value.get_attr("data-index"), Some("0"));
2277        assert_eq!(items[1].borrow().value.get_attr("data-index"), Some("1"));
2278        assert_eq!(
2279            items[1].borrow().value.get_attr("data-selected"),
2280            Some("yes")
2281        );
2282        let result = dom.get_element_by_id("result").unwrap();
2283        assert_eq!(result.borrow().value.get_attr("data-count"), Some("2"));
2284        assert_eq!(
2285            result.borrow().value.get_attr("data-class-count"),
2286            Some("2")
2287        );
2288    }
2289
2290    #[test]
2291    fn element_query_selectors_are_scoped_to_descendants() {
2292        let (mut runtime, dom) = runtime_from_html(
2293            r#"
2294            <section id="scope"><span class="item">one</span><span class="item">two</span></section>
2295            <span class="item" id="outside">outside</span>
2296            "#,
2297        );
2298        runtime.run_script(
2299            r##"
2300            const scope = document.querySelector("#scope");
2301            scope.querySelector(".item").setAttribute("data-first", "yes");
2302            const items = scope.querySelectorAll(".item");
2303            items[1].setAttribute("data-second", "yes");
2304            scope.setAttribute("data-count", items.length);
2305            "##,
2306        );
2307
2308        let scope = dom.get_element_by_id("scope").unwrap();
2309        assert_eq!(scope.borrow().value.get_attr("data-count"), Some("2"));
2310        let items = DomTree::query_selector_all_within(&scope, ".item");
2311        assert_eq!(items[0].borrow().value.get_attr("data-first"), Some("yes"));
2312        assert_eq!(items[1].borrow().value.get_attr("data-second"), Some("yes"));
2313        let outside = dom.get_element_by_id("outside").unwrap();
2314        assert_eq!(outside.borrow().value.get_attr("data-first"), None);
2315        assert_eq!(outside.borrow().value.get_attr("data-second"), None);
2316    }
2317
2318    #[test]
2319    fn react_dom_collection_and_event_apis_are_available() {
2320        let (mut runtime, dom) = runtime_from_html(
2321            r#"
2322            <main id="root">
2323                <button class="control primary">one</button>
2324                <button class="control">two</button>
2325                <span class="primary">label</span>
2326            </main>
2327            <div id="result"></div>
2328            "#,
2329        );
2330        runtime.run_script(
2331            r#"
2332            const root = document.getElementById("root");
2333            const button = root.getElementsByTagName("button")[0];
2334            let received = "no";
2335            button.addEventListener("scratch-ready", function (event) {
2336                received = event.detail + ":" + (event.target === button);
2337                event.preventDefault();
2338            });
2339            const accepted = button.dispatchEvent(new CustomEvent(
2340                "scratch-ready", {detail: "yes", cancelable: true}
2341            ));
2342            document.getElementById("result").setAttribute(
2343                "data-dom-apis",
2344                document.getElementsByTagName("button").length + ":" +
2345                    document.getElementsByClassName("control primary").length + ":" +
2346                    root.getElementsByClassName("primary").length + ":" + received + ":" + accepted
2347            );
2348            "#,
2349        );
2350
2351        let result = dom.get_element_by_id("result").unwrap();
2352        assert_eq!(
2353            result.borrow().value.get_attr("data-dom-apis"),
2354            Some("2:1:2:yes:true:false")
2355        );
2356    }
2357
2358    #[test]
2359    fn create_and_append_element_and_text_nodes() {
2360        let (mut runtime, dom) = runtime_from_html(r#"<ul id="list"></ul>"#);
2361        runtime.run_script(
2362            r##"
2363            const item = document.createElement("li");
2364            item.setAttribute("class", "dynamic");
2365            const text = document.createTextNode("created by JavaScript");
2366            item.appendChild(text);
2367            document.querySelector("#list").appendChild(item);
2368            "##,
2369        );
2370
2371        let item = dom.query_selector("li.dynamic").unwrap();
2372        assert_eq!(DomTree::inner_text(&item), "created by JavaScript");
2373        assert!(runtime.needs_redraw());
2374    }
2375
2376    #[test]
2377    fn document_head_and_element_append_insert_dynamic_styles() {
2378        let (mut runtime, dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
2379        runtime.run_script(
2380            r#"
2381            const style = document.createElement("style");
2382            style.append("body { color: red; }");
2383            document.head.append(style);
2384            "#,
2385        );
2386
2387        let style = dom.query_selector("head style").unwrap();
2388        assert_eq!(DomTree::inner_text(&style), "body { color: red; }");
2389        assert!(runtime.needs_redraw());
2390    }
2391
2392    #[test]
2393    fn namespace_dom_apis_create_svg_elements_and_attributes() {
2394        let (mut runtime, dom) =
2395            runtime_from_html(r#"<main id="root"></main><div id="result"></div>"#);
2396        runtime.run_script(
2397            r##"
2398            const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2399            const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
2400            path.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", "#shape");
2401            svg.appendChild(path);
2402            document.querySelector("#root").appendChild(svg);
2403
2404            const result = document.querySelector("#result");
2405            result.setAttribute("data-svg-ns", svg.namespaceURI);
2406            result.setAttribute("data-path-ns", path.namespaceURI);
2407            result.setAttribute("data-html-ns", result.namespaceURI);
2408            "##,
2409        );
2410
2411        let path = dom.query_selector("path").unwrap();
2412        assert_eq!(path.borrow().value.get_attr("xlink:href"), Some("#shape"));
2413        let result = dom.get_element_by_id("result").unwrap();
2414        let result = result.borrow();
2415        assert_eq!(result.value.get_attr("data-svg-ns"), Some(SVG_NAMESPACE));
2416        assert_eq!(result.value.get_attr("data-path-ns"), Some(SVG_NAMESPACE));
2417        assert_eq!(result.value.get_attr("data-html-ns"), Some(HTML_NAMESPACE));
2418        assert!(runtime.needs_redraw());
2419    }
2420
2421    #[test]
2422    fn element_contains_checks_self_descendants_and_unrelated_nodes() {
2423        let (mut runtime, dom) = runtime_from_html(
2424            r#"<main id="root"><section id="child"><span id="nested"></span></section></main><aside id="other"></aside>"#,
2425        );
2426        runtime.run_script(
2427            r##"
2428            const root = document.querySelector("#root");
2429            const child = document.querySelector("#child");
2430            const nested = document.querySelector("#nested");
2431            const other = document.querySelector("#other");
2432            root.setAttribute("data-self", root.contains(root));
2433            root.setAttribute("data-child", root.contains(child));
2434            root.setAttribute("data-nested", root.contains(nested));
2435            root.setAttribute("data-other", root.contains(other));
2436            root.setAttribute("data-null", root.contains(null));
2437            "##,
2438        );
2439
2440        let root = dom.get_element_by_id("root").unwrap();
2441        let root = root.borrow();
2442        assert_eq!(root.value.get_attr("data-self"), Some("true"));
2443        assert_eq!(root.value.get_attr("data-child"), Some("true"));
2444        assert_eq!(root.value.get_attr("data-nested"), Some("true"));
2445        assert_eq!(root.value.get_attr("data-other"), Some("false"));
2446        assert_eq!(root.value.get_attr("data-null"), Some("false"));
2447    }
2448
2449    #[test]
2450    fn document_tracks_the_focused_element() {
2451        let (mut runtime, dom) = runtime_from_html(
2452            r#"<body><input id="field"><button id="other"></button><div id="result"></div></body>"#,
2453        );
2454        runtime.run_script(
2455            r##"
2456            const field = document.querySelector("#field");
2457            const other = document.querySelector("#other");
2458            const result = document.querySelector("#result");
2459            result.setAttribute("data-initial", document.activeElement === document.body);
2460            field.focus();
2461            result.setAttribute("data-field", document.activeElement === field);
2462            other.focus();
2463            result.setAttribute("data-other", document.activeElement === other);
2464            other.blur();
2465            result.setAttribute("data-blurred", document.activeElement === document.body);
2466            result.setAttribute("data-has-focus", document.hasFocus());
2467            "##,
2468        );
2469
2470        let result = dom.get_element_by_id("result").unwrap();
2471        let result = result.borrow();
2472        assert_eq!(result.value.get_attr("data-initial"), Some("true"));
2473        assert_eq!(result.value.get_attr("data-field"), Some("true"));
2474        assert_eq!(result.value.get_attr("data-other"), Some("true"));
2475        assert_eq!(result.value.get_attr("data-blurred"), Some("true"));
2476        assert_eq!(result.value.get_attr("data-has-focus"), Some("true"));
2477    }
2478
2479    #[test]
2480    fn react_dom_node_primitives_identify_and_reorder_nodes() {
2481        let (mut runtime, dom) =
2482            runtime_from_html(r#"<main id="root"></main><div id="result"></div>"#);
2483        runtime.run_script(
2484            r##"
2485            const root = document.querySelector("#root");
2486            const first = document.createElement("span");
2487            first.setAttribute("data-name", "first");
2488            const second = document.createElement("span");
2489            second.setAttribute("data-name", "second");
2490            const text = document.createTextNode("before");
2491            text.nodeValue = "after";
2492            second.appendChild(text);
2493            root.appendChild(first);
2494            root.insertBefore(second, first);
2495            root.removeChild(first);
2496            second.className = "react-node";
2497            second.setAttribute("data-remove", "yes");
2498            second.removeAttribute("data-remove");
2499
2500            const result = document.querySelector("#result");
2501            result.setAttribute("data-document-type", document.nodeType);
2502            result.setAttribute("data-root-type", root.nodeType);
2503            result.setAttribute("data-root-name", root.nodeName);
2504            result.setAttribute("data-owner", root.ownerDocument === document);
2505            result.setAttribute("data-first", root.firstChild.getAttribute("data-name"));
2506            result.setAttribute("data-last", root.lastChild.getAttribute("data-name"));
2507            result.setAttribute("data-count", root.childNodes.length);
2508            result.setAttribute("data-text", root.firstChild.firstChild.data);
2509            result.setAttribute("data-class", root.firstChild.className);
2510            result.setAttribute("data-removed", root.firstChild.hasAttribute("data-remove"));
2511            "##,
2512        );
2513
2514        let result = dom.get_element_by_id("result").unwrap();
2515        let result = result.borrow();
2516        assert_eq!(result.value.get_attr("data-document-type"), Some("9"));
2517        assert_eq!(result.value.get_attr("data-root-type"), Some("1"));
2518        assert_eq!(result.value.get_attr("data-root-name"), Some("MAIN"));
2519        assert_eq!(result.value.get_attr("data-owner"), Some("true"));
2520        assert_eq!(result.value.get_attr("data-first"), Some("second"));
2521        assert_eq!(result.value.get_attr("data-last"), Some("second"));
2522        assert_eq!(result.value.get_attr("data-count"), Some("1"));
2523        assert_eq!(result.value.get_attr("data-text"), Some("after"));
2524        assert_eq!(result.value.get_attr("data-class"), Some("react-node"));
2525        assert_eq!(result.value.get_attr("data-removed"), Some("false"));
2526
2527        let root = dom.get_element_by_id("root").unwrap();
2528        assert_eq!(root.borrow().children().len(), 1);
2529        assert_eq!(
2530            root.borrow().children()[0]
2531                .borrow()
2532                .value
2533                .get_attr("data-remove"),
2534            None
2535        );
2536    }
2537
2538    #[test]
2539    fn html_iframe_element_supports_host_instance_checks() {
2540        let (mut runtime, dom) = runtime_from_html(
2541            r#"<body><iframe id="frame"></iframe><div id="result"></div></body>"#,
2542        );
2543        runtime.run_script(
2544            r#"
2545            const frame = document.getElementById("frame");
2546            const result = document.getElementById("result");
2547            result.setAttribute("data-frame", frame instanceof HTMLIFrameElement);
2548            result.setAttribute("data-body", document.body instanceof HTMLIFrameElement);
2549            "#,
2550        );
2551
2552        let result = dom.get_element_by_id("result").unwrap();
2553        let result = result.borrow();
2554        assert_eq!(result.value.get_attr("data-frame"), Some("true"));
2555        assert_eq!(result.value.get_attr("data-body"), Some("false"));
2556    }
2557
2558    #[test]
2559    fn markup_declared_iframes_queue_loads_once_and_failures_are_not_retried() {
2560        let (mut runtime, dom) = runtime_from_html(
2561            r#"<html><body>
2562                <iframe src="https://example.test/frame-a.html"></iframe>
2563                <iframe src="frames/frame-b.html"></iframe>
2564                <iframe id="placeholder"></iframe>
2565            </body></html>"#,
2566        );
2567        runtime.set_document_url("https://example.test/dir/page.html");
2568
2569        // Register a stable dom id per node, as the processor's initial
2570        // `apply_dom` does, so the markup iframes are visible to the scan.
2571        let ids: HashMap<usize, u64> = {
2572            let mut ids = HashMap::new();
2573            let mut next_id = 1u64;
2574            dom.traverse(|node| {
2575                ids.insert(Rc::as_ptr(node) as usize, next_id);
2576                next_id += 1;
2577            });
2578            ids
2579        };
2580        let snapshot = DomSnapshot::from_mirror(&dom.root, &ids);
2581        runtime.apply_dom(&snapshot);
2582
2583        // The two iframes with a src are queued exactly once; the src-less
2584        // placeholder is skipped.
2585        assert_eq!(runtime.queue_markup_iframe_loads(), 2);
2586        let requests = runtime.take_iframe_fetch_requests();
2587        assert_eq!(requests.len(), 2);
2588        let by_url: HashMap<String, u64> = requests
2589            .into_iter()
2590            .map(|req| (req.url, req.dom_id))
2591            .collect();
2592        assert_eq!(
2593            by_url.len(),
2594            2,
2595            "one request per src-ified iframe, absolute or relative"
2596        );
2597        // Absolute src stays as-is; relative src resolves against the document.
2598        assert!(by_url.contains_key("https://example.test/frame-a.html"));
2599        assert!(by_url.contains_key("https://example.test/dir/frames/frame-b.html"));
2600        let frame_a = by_url["https://example.test/frame-a.html"];
2601        let frame_b = by_url["https://example.test/dir/frames/frame-b.html"];
2602
2603        // A second scan finds nothing new to queue.
2604        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
2605        assert!(runtime.take_iframe_fetch_requests().is_empty());
2606
2607        // A resolved load is not re-queued on later scans.
2608        runtime.resolve_iframe_fetch(
2609            frame_a,
2610            r#"<html><body><p>frame content</p></body></html>"#.to_string(),
2611        );
2612        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
2613        assert!(runtime.take_iframe_fetch_requests().is_empty());
2614
2615        // A failed load is remembered so it is not refetched every scan.
2616        runtime.reject_iframe_fetch(frame_b);
2617        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
2618        assert!(runtime.take_iframe_fetch_requests().is_empty());
2619    }
2620
2621    #[test]
2622    fn remove_detaches_node_but_keeps_it_available_for_reappend() {
2623        let (mut runtime, dom) = runtime_from_html(
2624            r#"<div id="first"><span id="moving">move</span></div><div id="second"></div>"#,
2625        );
2626        runtime.run_script(
2627            r##"
2628            const moving = document.querySelector("#moving");
2629            moving.remove();
2630            document.querySelector("#second").appendChild(moving);
2631            "##,
2632        );
2633
2634        let first = dom.get_element_by_id("first").unwrap();
2635        let second = dom.get_element_by_id("second").unwrap();
2636        assert!(DomTree::query_selector_within(&first, "#moving").is_none());
2637        assert!(DomTree::query_selector_within(&second, "#moving").is_some());
2638        assert!(runtime.needs_redraw());
2639    }
2640
2641    #[test]
2642    fn parent_node_and_children_expose_tree_relationships() {
2643        let (mut runtime, dom) = runtime_from_html(
2644            r#"<div id="parent">text<span id="first"></span><span id="second"></span></div>"#,
2645        );
2646        runtime.run_script(
2647            r##"
2648            const first = document.querySelector("#first");
2649            first.parentNode.setAttribute("data-parent", "yes");
2650            const children = first.parentNode.children;
2651            children[1].setAttribute("data-second", "yes");
2652            first.parentNode.setAttribute("data-child-count", children.length);
2653
2654            const text = document.createTextNode("dynamic");
2655            first.appendChild(text);
2656            text.parentNode.setAttribute("data-text-parent", "yes");
2657            "##,
2658        );
2659
2660        let parent = dom.get_element_by_id("parent").unwrap();
2661        assert_eq!(parent.borrow().value.get_attr("data-parent"), Some("yes"));
2662        assert_eq!(
2663            parent.borrow().value.get_attr("data-child-count"),
2664            Some("2")
2665        );
2666        let first = dom.get_element_by_id("first").unwrap();
2667        assert_eq!(
2668            first.borrow().value.get_attr("data-text-parent"),
2669            Some("yes")
2670        );
2671        let second = dom.get_element_by_id("second").unwrap();
2672        assert_eq!(second.borrow().value.get_attr("data-second"), Some("yes"));
2673    }
2674
2675    #[test]
2676    fn class_list_mutates_class_attribute_and_reports_membership() {
2677        let (mut runtime, dom) = runtime_from_html(r#"<div id="target" class="one two"></div>"#);
2678        runtime.run_script(
2679            r##"
2680            const target = document.querySelector("#target");
2681            let initial = "";
2682            for (const token of target.classList) initial += token + ",";
2683            target.setAttribute("data-initial-classes", initial);
2684            target.classList.add("two", "three");
2685            target.classList.remove("one", "missing");
2686            target.setAttribute("data-has-three", target.classList.contains("three"));
2687            target.setAttribute("data-removed-three", target.classList.toggle("three"));
2688            target.setAttribute("data-added-four", target.classList.toggle("four"));
2689            target.setAttribute("data-forced-off", target.classList.toggle("four", false));
2690            target.setAttribute("data-forced-on", target.classList.toggle("five", true));
2691            "##,
2692        );
2693
2694        let target = dom.get_element_by_id("target").unwrap();
2695        let target = target.borrow();
2696        assert_eq!(target.value.get_attr("class"), Some("two five"));
2697        assert_eq!(
2698            target.value.get_attr("data-initial-classes"),
2699            Some("one,two,")
2700        );
2701        assert_eq!(target.value.get_attr("data-has-three"), Some("true"));
2702        assert_eq!(target.value.get_attr("data-removed-three"), Some("false"));
2703        assert_eq!(target.value.get_attr("data-added-four"), Some("true"));
2704        assert_eq!(target.value.get_attr("data-forced-off"), Some("false"));
2705        assert_eq!(target.value.get_attr("data-forced-on"), Some("true"));
2706        assert!(runtime.needs_redraw());
2707    }
2708
2709    #[test]
2710    fn timeout_runs_once_with_additional_arguments_and_can_be_cancelled() {
2711        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2712        runtime.run_script(
2713            r##"
2714            setTimeout(function (value) {
2715                document.querySelector("#result").setAttribute("data-value", value);
2716            }, 0, "done");
2717            const cancelled = setTimeout(function () {
2718                document.querySelector("#result").setAttribute("data-cancelled", "no");
2719            }, 0);
2720            clearTimeout(cancelled);
2721            "##,
2722        );
2723
2724        assert!(runtime.run_due_timers());
2725        assert!(!runtime.run_due_timers());
2726        let result = dom.get_element_by_id("result").unwrap();
2727        assert_eq!(result.borrow().value.get_attr("data-value"), Some("done"));
2728        assert_eq!(result.borrow().value.get_attr("data-cancelled"), None);
2729    }
2730
2731    #[test]
2732    fn interval_can_clear_itself() {
2733        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2734        runtime.run_script(
2735            r##"
2736            const intervalId = setInterval(function () {
2737                document.querySelector("#result").setAttribute("data-ran", "once");
2738                clearInterval(intervalId);
2739            }, 0);
2740            "##,
2741        );
2742
2743        assert!(runtime.run_due_timers());
2744        assert!(!runtime.run_due_timers());
2745        let result = dom.get_element_by_id("result").unwrap();
2746        assert_eq!(result.borrow().value.get_attr("data-ran"), Some("once"));
2747    }
2748
2749    #[test]
2750    fn performance_now_exposes_monotonic_runtime_time() {
2751        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2752        runtime.run_script(
2753            r#"
2754            const first = performance.now();
2755            const second = performance.now();
2756            document.getElementById("result").setAttribute(
2757                "data-monotonic",
2758                typeof first === "number" && second >= first
2759            );
2760            "#,
2761        );
2762
2763        let result = dom.get_element_by_id("result").unwrap();
2764        assert_eq!(
2765            result.borrow().value.get_attr("data-monotonic"),
2766            Some("true")
2767        );
2768    }
2769
2770    #[test]
2771    fn microtasks_run_in_fifo_order_after_script_evaluation() {
2772        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2773        runtime.run_script(
2774            r##"
2775            const result = document.querySelector("#result");
2776            queueMicrotask(function () {
2777                result.setAttribute("data-order", result.getAttribute("data-order") + "-first");
2778                queueMicrotask(function () {
2779                    result.setAttribute("data-order", result.getAttribute("data-order") + "-second");
2780                });
2781            });
2782            result.setAttribute("data-order", "sync");
2783            "##,
2784        );
2785
2786        let result = dom.get_element_by_id("result").unwrap();
2787        assert_eq!(
2788            result.borrow().value.get_attr("data-order"),
2789            Some("sync-first-second")
2790        );
2791    }
2792
2793    #[test]
2794    fn timer_microtasks_run_before_the_next_timer_task() {
2795        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2796        runtime.run_script(
2797            r##"
2798            const result = document.querySelector("#result");
2799            setTimeout(function () {
2800                result.setAttribute("data-order", "timer");
2801                queueMicrotask(function () {
2802                    result.setAttribute("data-order", "timer-microtask");
2803                });
2804            }, 0);
2805            setTimeout(function () {
2806                result.setAttribute("data-observed", result.getAttribute("data-order"));
2807            }, 0);
2808            "##,
2809        );
2810
2811        assert!(runtime.run_due_timers());
2812        let result = dom.get_element_by_id("result").unwrap();
2813        assert_eq!(
2814            result.borrow().value.get_attr("data-observed"),
2815            Some("timer-microtask")
2816        );
2817    }
2818
2819    #[test]
2820    fn promise_reactions_share_fifo_order_with_queued_microtasks() {
2821        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2822        runtime.run_script(
2823            r##"
2824            const result = document.querySelector("#result");
2825            result.setAttribute("data-order", "sync");
2826            queueMicrotask(function () {
2827                result.setAttribute("data-order", result.getAttribute("data-order") + "-first");
2828            });
2829            new Promise(function (resolve) {
2830                resolve("promise");
2831            }).then(function (value) {
2832                result.setAttribute("data-order", result.getAttribute("data-order") + "-" + value);
2833            });
2834            queueMicrotask(function () {
2835                result.setAttribute("data-order", result.getAttribute("data-order") + "-last");
2836            });
2837            "##,
2838        );
2839
2840        let result = dom.get_element_by_id("result").unwrap();
2841        assert_eq!(
2842            result.borrow().value.get_attr("data-order"),
2843            Some("sync-first-promise-last")
2844        );
2845    }
2846
2847    #[test]
2848    fn a_failed_microtask_does_not_block_later_jobs() {
2849        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2850        runtime.run_script(
2851            r##"
2852            queueMicrotask(function () {
2853                missingFunction();
2854            });
2855            queueMicrotask(function () {
2856                document.querySelector("#result").setAttribute("data-ran", "yes");
2857            });
2858            "##,
2859        );
2860
2861        let result = dom.get_element_by_id("result").unwrap();
2862        assert_eq!(result.borrow().value.get_attr("data-ran"), Some("yes"));
2863    }
2864
2865    #[test]
2866    fn promise_static_methods_complete_during_script_checkpoint() {
2867        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2868        runtime.run_script(
2869            r##"
2870            const result = document.querySelector("#result");
2871            Promise.all([Promise.resolve("first"), "second"])
2872                .then(function (values) {
2873                    result.setAttribute("data-all", values[0] + "-" + values[1]);
2874                    return Promise.reject("expected");
2875                })
2876                .catch(function (reason) {
2877                    result.setAttribute("data-catch", reason);
2878                });
2879            "##,
2880        );
2881
2882        let result = dom.get_element_by_id("result").unwrap();
2883        assert_eq!(
2884            result.borrow().value.get_attr("data-all"),
2885            Some("first-second")
2886        );
2887        assert_eq!(
2888            result.borrow().value.get_attr("data-catch"),
2889            Some("expected")
2890        );
2891    }
2892
2893    #[test]
2894    fn arrow_callbacks_work_with_promises_and_lexical_this() {
2895        let (mut runtime, dom) =
2896            runtime_from_html(r#"<button id="target"></button><div id="other"></div>"#);
2897        runtime.run_script(
2898            r##"
2899            const target = document.querySelector("#target");
2900            Promise.resolve("promise").then(value => {
2901                target.setAttribute("data-promise", value);
2902            });
2903            target.addEventListener("click", function () {
2904                const update = () => this.setAttribute("data-this", "target");
2905                update.call(document.querySelector("#other"));
2906            });
2907            "##,
2908        );
2909
2910        let target = dom.get_element_by_id("target").unwrap();
2911        assert!(runtime.click(&target));
2912        assert_eq!(
2913            target.borrow().value.get_attr("data-promise"),
2914            Some("promise")
2915        );
2916        assert_eq!(target.borrow().value.get_attr("data-this"), Some("target"));
2917        let other = dom.get_element_by_id("other").unwrap();
2918        assert_eq!(other.borrow().value.get_attr("data-this"), None);
2919    }
2920
2921    #[test]
2922    fn queue_microtask_accepts_an_arrow_callback() {
2923        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2924        runtime.run_script(
2925            r##"
2926            const result = document.querySelector("#result");
2927            queueMicrotask(() => {
2928                result.setAttribute("data-microtask", "yes");
2929            });
2930            "##,
2931        );
2932
2933        let result = dom.get_element_by_id("result").unwrap();
2934        assert_eq!(
2935            result.borrow().value.get_attr("data-microtask"),
2936            Some("yes")
2937        );
2938    }
2939
2940    #[test]
2941    fn browser_global_aliases_share_window_properties() {
2942        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2943        runtime.run_script(
2944            r##"
2945            const result = window.document.querySelector("#result");
2946            result.setAttribute("data-same-self", window === self);
2947            result.setAttribute("data-same-global", window === globalThis);
2948            result.setAttribute("data-document", window.document === document);
2949            window.queueMicrotask(() => {
2950                result.setAttribute("data-microtask", "yes");
2951            });
2952            "##,
2953        );
2954
2955        let result = dom.get_element_by_id("result").unwrap();
2956        let result = result.borrow();
2957        assert_eq!(result.value.get_attr("data-same-self"), Some("true"));
2958        assert_eq!(result.value.get_attr("data-same-global"), Some("true"));
2959        assert_eq!(result.value.get_attr("data-document"), Some("true"));
2960        assert_eq!(result.value.get_attr("data-microtask"), Some("yes"));
2961    }
2962
2963    #[test]
2964    fn fetch_resolves_response_metadata_and_text_promise() {
2965        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2966        runtime.run_script(
2967            r##"
2968            fetch("data:text/plain,hello").then(response => {
2969                const result = document.querySelector("#result");
2970                result.setAttribute("data-ok", response.ok);
2971                result.setAttribute("data-status", response.status);
2972                result.setAttribute("data-status-text", response.statusText);
2973                result.setAttribute("data-url", response.url);
2974                result.setAttribute("data-redirected", response.redirected);
2975                result.setAttribute("data-body-used-before", response.bodyUsed);
2976                const body = response.text();
2977                result.setAttribute("data-body-used-after", response.bodyUsed);
2978                return body;
2979            }).then(text => {
2980                document.querySelector("#result").setAttribute("data-text", text);
2981            });
2982            "##,
2983        );
2984
2985        let requests = runtime.take_fetch_requests();
2986        assert_eq!(requests.len(), 1);
2987        assert_eq!(requests[0].url, "data:text/plain,hello");
2988        runtime.resolve_fetch(
2989            requests[0].id,
2990            JsFetchResponse {
2991                url: "data:text/plain,hello".to_string(),
2992                status: 200,
2993                status_text: "All Good".to_string(),
2994                redirected: true,
2995                body: b"hello".to_vec(),
2996                headers: Vec::new(),
2997            },
2998        );
2999
3000        let result = dom.get_element_by_id("result").unwrap();
3001        let result = result.borrow();
3002        assert_eq!(result.value.get_attr("data-ok"), Some("true"));
3003        assert_eq!(result.value.get_attr("data-status"), Some("200"));
3004        assert_eq!(result.value.get_attr("data-status-text"), Some("All Good"));
3005        assert_eq!(result.value.get_attr("data-redirected"), Some("true"));
3006        assert_eq!(
3007            result.value.get_attr("data-body-used-before"),
3008            Some("false")
3009        );
3010        assert_eq!(result.value.get_attr("data-body-used-after"), Some("true"));
3011        assert_eq!(
3012            result.value.get_attr("data-url"),
3013            Some("data:text/plain,hello")
3014        );
3015        assert_eq!(result.value.get_attr("data-text"), Some("hello"));
3016    }
3017
3018    #[test]
3019    fn fetch_array_buffer_preserves_binary_bytes() {
3020        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3021        runtime.run_script(
3022            r##"
3023            fetch("https://assets.scratch.mit.edu/project.sb3")
3024                .then(response => response.arrayBuffer())
3025                .then(buffer => {
3026                    const bytes = new Uint8Array(buffer);
3027                    document.querySelector("#result").setAttribute(
3028                        "data-bytes",
3029                        buffer.byteLength + ":" + bytes.length + ":" + bytes[0] + ":" + bytes[3]
3030                    );
3031                });
3032            "##,
3033        );
3034
3035        let requests = runtime.take_fetch_requests();
3036        runtime.resolve_fetch(
3037            requests[0].id,
3038            JsFetchResponse {
3039                url: requests[0].url.clone(),
3040                status: 200,
3041                status_text: "OK".to_string(),
3042                redirected: false,
3043                body: vec![0, 127, 128, 255],
3044                headers: Vec::new(),
3045            },
3046        );
3047
3048        let result = dom.get_element_by_id("result").unwrap();
3049        assert_eq!(
3050            result.borrow().value.get_attr("data-bytes"),
3051            Some("4:4:0:255")
3052        );
3053    }
3054
3055    #[test]
3056    fn response_body_cannot_be_consumed_twice() {
3057        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3058        runtime.run_script(
3059            r##"
3060            fetch("data:text/plain,hello").then(response => {
3061                return response.text().then(() => response.text());
3062            }).catch(reason => {
3063                document.querySelector("#result").setAttribute("data-error", reason);
3064            });
3065            "##,
3066        );
3067
3068        let requests = runtime.take_fetch_requests();
3069        runtime.resolve_fetch(
3070            requests[0].id,
3071            JsFetchResponse {
3072                url: "data:text/plain,hello".to_string(),
3073                status: 200,
3074                status_text: "OK".to_string(),
3075                redirected: false,
3076                body: b"hello".to_vec(),
3077                headers: Vec::new(),
3078            },
3079        );
3080
3081        let result = dom.get_element_by_id("result").unwrap();
3082        assert_eq!(
3083            result.borrow().value.get_attr("data-error"),
3084            Some("Response body has already been consumed")
3085        );
3086    }
3087
3088    #[test]
3089    fn fetch_captures_method_headers_and_body() {
3090        let (mut runtime, _dom) = runtime_from_html("<div></div>");
3091        runtime.run_script(
3092            r#"
3093            const headers = {};
3094            headers["Content-Type"] = "application/json";
3095            headers["X-Test"] = "yes";
3096            fetch("https://example.test/messages", {
3097                method: "post",
3098                headers: headers,
3099                body: "{\"message\":\"hello\"}"
3100            });
3101            "#,
3102        );
3103
3104        let requests = runtime.take_fetch_requests();
3105        assert_eq!(requests.len(), 1);
3106        assert_eq!(requests[0].method, "POST");
3107        assert!(
3108            requests[0]
3109                .headers
3110                .contains(&("Content-Type".to_string(), "application/json".to_string()))
3111        );
3112        assert!(
3113            requests[0]
3114                .headers
3115                .contains(&("X-Test".to_string(), "yes".to_string()))
3116        );
3117        assert_eq!(requests[0].body, br#"{"message":"hello"}"#);
3118    }
3119
3120    #[test]
3121    fn xml_http_request_captures_request_and_dispatches_load() {
3122        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3123        runtime.run_script(
3124            r##"
3125            const request = new XMLHttpRequest();
3126            request.open("post", "https://example.test/messages");
3127            request.setRequestHeader("Content-Type", "text/plain");
3128            request.onload = function () {
3129                const result = document.querySelector("#result");
3130                result.setAttribute("data-state", this.readyState);
3131                result.setAttribute("data-status", this.status);
3132                result.setAttribute("data-text", this.responseText);
3133                result.setAttribute("data-headers", this.getAllResponseHeaders());
3134            };
3135            request.send("hello");
3136            "##,
3137        );
3138
3139        let requests = runtime.take_fetch_requests();
3140        assert_eq!(requests.len(), 1);
3141        assert_eq!(requests[0].url, "https://example.test/messages");
3142        assert_eq!(requests[0].method, "POST");
3143        assert_eq!(requests[0].body, b"hello");
3144        assert!(
3145            requests[0]
3146                .headers
3147                .contains(&("Content-Type".to_string(), "text/plain".to_string()))
3148        );
3149
3150        runtime.resolve_fetch(
3151            requests[0].id,
3152            JsFetchResponse {
3153                url: requests[0].url.clone(),
3154                status: 201,
3155                status_text: "Created".to_string(),
3156                redirected: false,
3157                body: b"saved".to_vec(),
3158                headers: vec![("X-Test".to_string(), "yes".to_string())],
3159            },
3160        );
3161
3162        let result = dom.get_element_by_id("result").unwrap();
3163        let result = result.borrow();
3164        assert_eq!(result.value.get_attr("data-state"), Some("4"));
3165        assert_eq!(result.value.get_attr("data-status"), Some("201"));
3166        assert_eq!(result.value.get_attr("data-text"), Some("saved"));
3167        assert_eq!(
3168            result.value.get_attr("data-headers"),
3169            Some("X-Test: yes\r\n")
3170        );
3171    }
3172
3173    #[test]
3174    fn headers_are_case_insensitive_and_mutable() {
3175        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3176        runtime.run_script(
3177            r##"
3178            const headers = new Headers({ Accept: "application/json" });
3179            headers.append("X-Test", "one");
3180            headers.append("x-test", "two");
3181            headers.set("X-Replace", "before");
3182            headers.set("x-replace", "after");
3183            headers.delete("ACCEPT");
3184
3185            const result = document.querySelector("#result");
3186            result.setAttribute("data-test", headers.get("X-TEST"));
3187            result.setAttribute("data-replace", headers.get("X-Replace"));
3188            result.setAttribute("data-has-accept", headers.has("accept"));
3189            result.setAttribute("data-missing", headers.get("missing") === null);
3190
3191            fetch("https://example.test/", { headers: headers });
3192            "##,
3193        );
3194
3195        let result = dom.get_element_by_id("result").unwrap();
3196        let result = result.borrow();
3197        assert_eq!(result.value.get_attr("data-test"), Some("one, two"));
3198        assert_eq!(result.value.get_attr("data-replace"), Some("after"));
3199        assert_eq!(result.value.get_attr("data-has-accept"), Some("false"));
3200        assert_eq!(result.value.get_attr("data-missing"), Some("true"));
3201
3202        let requests = runtime.take_fetch_requests();
3203        assert!(
3204            requests[0]
3205                .headers
3206                .contains(&("x-test".to_string(), "one, two".to_string()))
3207        );
3208        assert!(
3209            requests[0]
3210                .headers
3211                .contains(&("x-replace".to_string(), "after".to_string()))
3212        );
3213    }
3214
3215    #[test]
3216    fn response_exposes_read_only_headers() {
3217        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3218        runtime.run_script(
3219            r##"
3220            fetch("data:text/plain,hello").then(response => {
3221                const result = document.querySelector("#result");
3222                result.setAttribute("data-type", response.headers.get("Content-Type"));
3223                result.setAttribute("data-has", response.headers.has("X-Test"));
3224            });
3225            "##,
3226        );
3227
3228        let requests = runtime.take_fetch_requests();
3229        runtime.resolve_fetch(
3230            requests[0].id,
3231            JsFetchResponse {
3232                url: "data:text/plain,hello".to_string(),
3233                status: 200,
3234                status_text: "OK".to_string(),
3235                redirected: false,
3236                body: b"hello".to_vec(),
3237                headers: vec![
3238                    ("content-type".to_string(), "text/plain".to_string()),
3239                    ("X-Test".to_string(), "yes".to_string()),
3240                ],
3241            },
3242        );
3243
3244        let result = dom.get_element_by_id("result").unwrap();
3245        let result = result.borrow();
3246        assert_eq!(result.value.get_attr("data-type"), Some("text/plain"));
3247        assert_eq!(result.value.get_attr("data-has"), Some("true"));
3248    }
3249
3250    #[test]
3251    fn request_objects_can_be_copied_and_passed_to_fetch() {
3252        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3253        runtime.run_script(
3254            r##"
3255            const headers = new Headers({ Accept: "application/json" });
3256            const original = new Request("https://example.test/messages", {
3257                method: "post",
3258                headers: headers,
3259                body: "hello"
3260            });
3261            const copied = new Request(original);
3262            const result = document.querySelector("#result");
3263            result.setAttribute("data-url", copied.url);
3264            result.setAttribute("data-method", copied.method);
3265            result.setAttribute("data-accept", copied.headers.get("accept"));
3266            fetch(copied, { method: "put" });
3267            "##,
3268        );
3269
3270        let result = dom.get_element_by_id("result").unwrap();
3271        let result = result.borrow();
3272        assert_eq!(
3273            result.value.get_attr("data-url"),
3274            Some("https://example.test/messages")
3275        );
3276        assert_eq!(result.value.get_attr("data-method"), Some("POST"));
3277        assert_eq!(
3278            result.value.get_attr("data-accept"),
3279            Some("application/json")
3280        );
3281
3282        let requests = runtime.take_fetch_requests();
3283        assert_eq!(requests[0].url, "https://example.test/messages");
3284        assert_eq!(requests[0].method, "PUT");
3285        assert_eq!(requests[0].body, b"hello");
3286        assert!(
3287            requests[0]
3288                .headers
3289                .contains(&("accept".to_string(), "application/json".to_string()))
3290        );
3291    }
3292
3293    #[test]
3294    fn response_json_resolves_objects_and_arrays() {
3295        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3296        runtime.run_script(
3297            r##"
3298            fetch("data:application/json,pending").then(response => response.json()).then(value => {
3299                const result = document.querySelector("#result");
3300                result.setAttribute("data-name", value.name);
3301                result.setAttribute("data-second", value.items[1]);
3302                result.setAttribute("data-enabled", value.enabled);
3303                result.setAttribute("data-empty", value.empty === null);
3304            });
3305            "##,
3306        );
3307
3308        let requests = runtime.take_fetch_requests();
3309        runtime.resolve_fetch(
3310            requests[0].id,
3311            JsFetchResponse {
3312                url: "data:application/json,pending".to_string(),
3313                status: 200,
3314                status_text: "OK".to_string(),
3315                redirected: false,
3316                body: br#"{"name":"Orinium","items":[1,2],"enabled":true,"empty":null}"#.to_vec(),
3317                headers: Vec::new(),
3318            },
3319        );
3320
3321        let result = dom.get_element_by_id("result").unwrap();
3322        let result = result.borrow();
3323        assert_eq!(result.value.get_attr("data-name"), Some("Orinium"));
3324        assert_eq!(result.value.get_attr("data-second"), Some("2"));
3325        assert_eq!(result.value.get_attr("data-enabled"), Some("true"));
3326        assert_eq!(result.value.get_attr("data-empty"), Some("true"));
3327    }
3328
3329    #[test]
3330    fn response_json_rejects_invalid_json() {
3331        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3332        runtime.run_script(
3333            r##"
3334            fetch("data:application/json,invalid")
3335                .then(response => response.json())
3336                .catch(reason => {
3337                    document.querySelector("#result").setAttribute("data-error", reason);
3338                });
3339            "##,
3340        );
3341
3342        let requests = runtime.take_fetch_requests();
3343        runtime.resolve_fetch(
3344            requests[0].id,
3345            JsFetchResponse {
3346                url: "data:application/json,invalid".to_string(),
3347                status: 200,
3348                status_text: "OK".to_string(),
3349                redirected: false,
3350                body: b"not json".to_vec(),
3351                headers: Vec::new(),
3352            },
3353        );
3354
3355        let result = dom.get_element_by_id("result").unwrap();
3356        assert!(
3357            result
3358                .borrow()
3359                .value
3360                .get_attr("data-error")
3361                .unwrap()
3362                .starts_with("Failed to parse JSON:")
3363        );
3364    }
3365
3366    #[test]
3367    fn fetch_rejection_runs_catch_reaction() {
3368        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3369        runtime.run_script(
3370            r##"
3371            fetch("https://invalid.test/").catch(reason => {
3372                document.querySelector("#result").setAttribute("data-error", reason);
3373            });
3374            "##,
3375        );
3376
3377        let requests = runtime.take_fetch_requests();
3378        runtime.reject_fetch(requests[0].id, "network failed".to_string());
3379        let result = dom.get_element_by_id("result").unwrap();
3380        assert_eq!(
3381            result.borrow().value.get_attr("data-error"),
3382            Some("network failed")
3383        );
3384    }
3385
3386    #[test]
3387    fn intersection_observer_fires_on_observe() {
3388        let (mut runtime, dom) = runtime_from_html(
3389            r#"<div id="target" style="width: 100px; height: 50px"></div><div id="result"></div>"#,
3390        );
3391        runtime.run_script(
3392            r#"
3393            const target = document.getElementById("target");
3394            const result = document.getElementById("result");
3395            const observer = new IntersectionObserver(function (entries) {
3396                const entry = entries[0];
3397                result.setAttribute("data-target", entry.target === target);
3398                result.setAttribute("data-is-intersecting", entry.isIntersecting);
3399                result.setAttribute("data-ratio", entry.intersectionRatio);
3400                result.setAttribute("data-has-root-bounds", entry.rootBounds !== null);
3401                result.setAttribute("data-bcr-width", entry.boundingClientRect.width);
3402            });
3403            observer.observe(target);
3404            "#,
3405        );
3406
3407        let result = dom.get_element_by_id("result").unwrap();
3408        let result = result.borrow();
3409        assert_eq!(result.value.get_attr("data-target"), Some("true"));
3410        // The element has style width/height so it is visible within the viewport.
3411        assert_eq!(result.value.get_attr("data-is-intersecting"), Some("true"));
3412        assert_eq!(result.value.get_attr("data-ratio"), Some("1"));
3413        assert_eq!(result.value.get_attr("data-has-root-bounds"), Some("true"));
3414        assert_eq!(result.value.get_attr("data-bcr-width"), Some("100"));
3415    }
3416
3417    #[test]
3418    fn custom_elements_define_and_connect() {
3419        let (mut runtime, dom) =
3420            runtime_from_html(r#"<html><body><div id="result"></div></body></html>"#);
3421        runtime.run_script(
3422            r#"
3423            class MyElement extends HTMLElement {
3424                connectedCallback() {
3425                    document.getElementById("result").setAttribute("data-connected", "yes");
3426                }
3427                disconnectedCallback() {
3428                    document.getElementById("result").setAttribute("data-disconnected", "yes");
3429                }
3430            }
3431            customElements.define("my-element", MyElement);
3432            document.getElementById("result").setAttribute(
3433                "data-proto",
3434                typeof MyElement.prototype.connectedCallback
3435            );
3436            const el = document.createElement("my-element");
3437            document.body.appendChild(el);
3438            document.body.removeChild(el);
3439            "#,
3440        );
3441
3442        let result = dom.get_element_by_id("result").unwrap();
3443        let result = result.borrow();
3444        // The prototype lookup finds the function.
3445        assert_eq!(result.value.get_attr("data-proto"), Some("function"));
3446        // Lifecycle callbacks fire via enqueue_job + microtask checkpoint.
3447        assert_eq!(result.value.get_attr("data-connected"), Some("yes"));
3448        assert_eq!(result.value.get_attr("data-disconnected"), Some("yes"));
3449    }
3450
3451    #[test]
3452    fn custom_elements_define_getters_work() {
3453        let (mut runtime, _dom) = runtime_from_html(r#"<html><body></body></html>"#);
3454        runtime.run_script(
3455            r#"
3456            class MyEl extends HTMLElement {}
3457            customElements.define("my-el", MyEl);
3458            if (customElements.get("my-el") === undefined) throw new Error("get failed");
3459            if (customElements.get("no-such") !== undefined) throw new Error("get should be undefined");
3460            "#,
3461        );
3462    }
3463
3464    #[test]
3465    fn custom_elements_attribute_changed_and_when_defined() {
3466        let (mut runtime, dom) =
3467            runtime_from_html(r#"<html><body><div id="result"></div></body></html>"#);
3468        runtime.run_script(
3469            r#"
3470            globalThis.__attrLog = [];
3471            class AttrEl extends HTMLElement {
3472                attributeChangedCallback(name, oldVal, newVal) {
3473                    globalThis.__attrLog.push(name + ":" + (oldVal === null ? "null" : oldVal) + ":" + (newVal === null ? "null" : newVal));
3474                }
3475            }
3476            AttrEl.observedAttributes = ["data-val"];
3477            customElements.define("attr-el", AttrEl);
3478            const el = document.createElement("attr-el");
3479            document.body.appendChild(el);
3480            el.setAttribute("data-val", "first");
3481            el.setAttribute("data-val", "second");
3482            el.removeAttribute("data-val");
3483            // whenDefined resolves immediately for an already-defined name.
3484            let wdResolved = false;
3485            customElements.whenDefined("attr-el").then(function () {
3486                wdResolved = true;
3487            });
3488            document.getElementById("result").setAttribute(
3489                "data-wd", wdResolved
3490            );
3491            "#,
3492        );
3493
3494        // Read the log after microtasks have fired the callbacks.
3495        runtime.run_script(
3496            r#"document.getElementById("result").setAttribute(
3497                "data-log", globalThis.__attrLog.join("|")
3498            );"#,
3499        );
3500
3501        let result = dom.get_element_by_id("result").unwrap();
3502        let result = result.borrow();
3503        // Three callbacks fire via microtask: first set, second set, remove.
3504        // oldValue is null on first set (attr didn't exist before).        assert_eq!(result.value.get_attr("data-log"), Some("data-val:null:first|data-val:first:second|data-val:second:null"));
3505        assert_eq!(result.value.get_attr("data-wd"), Some("true"));
3506    }
3507
3508    #[test]
3509    fn shadow_dom_attach_and_query() {
3510        let (mut runtime, dom) =
3511            runtime_from_html(r#"<html><body><div id="host"></div></body></html>"#);
3512        // First: verify attachShadow works at all
3513        runtime.run_script(
3514            r##"
3515            var host = document.getElementById("host");
3516            host.setAttribute("data-step1", "ready");
3517            "##,
3518        );
3519        let result = dom.get_element_by_id("host").unwrap();
3520        assert_eq!(result.borrow().value.get_attr("data-step1"), Some("ready"));
3521
3522        // Now try attachShadow in its own script
3523        runtime.run_script(
3524            r##"
3525            var host = document.getElementById("host");
3526            host.attachShadow({ mode: "open" });
3527            host.setAttribute("data-step2", "shadow-attached");
3528            "##,
3529        );
3530        assert_eq!(
3531            result.borrow().value.get_attr("data-step2"),
3532            Some("shadow-attached")
3533        );
3534
3535        // Now test the rest
3536        runtime.run_script(
3537            r##"
3538            var host = document.getElementById("host");
3539            try {
3540                var sr = host.shadowRoot;
3541                host.setAttribute("data-sr", sr !== null ? "true" : "false");
3542                var span = document.createElement("span");
3543                span.id = "inner";
3544                span.textContent = "shadow text";
3545                sr.appendChild(span);
3546                var found = sr.querySelector("#inner");
3547                host.setAttribute("data-found", found !== null ? found.textContent : "NOT_FOUND");
3548                var notFound = host.querySelector("#inner");
3549                host.setAttribute("data-boundary", notFound === null ? "true" : "false");
3550                host.setAttribute("data-text", host.textContent.trim() === "" ? "true" : "false");
3551            } catch(e) {
3552                host.setAttribute("data-error", e.toString());
3553            }
3554            "##,
3555        );
3556        let result = dom.get_element_by_id("host").unwrap();
3557        let result = result.borrow();
3558        assert_eq!(result.value.get_attr("data-sr"), Some("true"));
3559        assert_eq!(result.value.get_attr("data-found"), Some("shadow text"));
3560        assert_eq!(result.value.get_attr("data-boundary"), Some("true"));
3561        assert_eq!(result.value.get_attr("data-text"), Some("true"));
3562    }
3563
3564    #[test]
3565    fn shadow_dom_closed_root() {
3566        let (mut runtime, dom) =
3567            runtime_from_html(r#"<html><body><div id="host"></div></body></html>"#);
3568        runtime.run_script(
3569            r##"
3570            var host = document.getElementById("host");
3571            host.attachShadow({ mode: "closed" });
3572            host.setAttribute("data-closed", host.shadowRoot === null ? "true" : "false");
3573            "##,
3574        );
3575        let result = dom.get_element_by_id("host").unwrap();
3576        let result = result.borrow();
3577        assert_eq!(result.value.get_attr("data-closed"), Some("true"));
3578    }
3579
3580    #[test]
3581    fn document_write_inserts_parsed_html_into_body() {
3582        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
3583        runtime.run_script(r#"document.write("<p>Hello</p>");"#);
3584        let p = dom.query_selector("body p").unwrap();
3585        assert_eq!(DomTree::inner_text(&p), "Hello");
3586        assert!(runtime.needs_redraw());
3587    }
3588
3589    #[test]
3590    fn document_writeln_appends_content_with_newline() {
3591        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
3592        runtime.run_script(r#"document.writeln("<span>A</span>");"#);
3593        let span = dom.query_selector("body span").unwrap();
3594        assert_eq!(DomTree::inner_text(&span), "A");
3595    }
3596
3597    #[test]
3598    fn dom_exception_has_name_message_and_code() {
3599        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3600        runtime.run_script(
3601            r#"
3602        try {
3603            throw new DOMException("test error", "SyntaxError");
3604        } catch (e) {
3605            document.getElementById("r").setAttribute("data-name", e.name);
3606            document.getElementById("r").setAttribute("data-msg", e.message);
3607            document.getElementById("r").setAttribute("data-code", e.code);
3608        }
3609        "#,
3610        );
3611        let r = dom.get_element_by_id("r").unwrap();
3612        let r = r.borrow();
3613        assert_eq!(r.value.get_attr("data-name"), Some("SyntaxError"));
3614        assert_eq!(r.value.get_attr("data-msg"), Some("test error"));
3615        assert_eq!(r.value.get_attr("data-code"), Some("12"));
3616    }
3617
3618    #[test]
3619    fn dom_exception_static_constants_are_exposed() {
3620        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3621        runtime.run_script(
3622            r#"
3623        document.getElementById("r").setAttribute(
3624            "data-codes",
3625            DOMException.SYNTAX_ERR + ":" +
3626            DOMException.HIERARCHY_REQUEST_ERR + ":" +
3627            DOMException.NOT_FOUND_ERR + ":" +
3628            DOMException.INVALID_STATE_ERR
3629        );
3630        "#,
3631        );
3632        let r = dom.get_element_by_id("r").unwrap();
3633        assert_eq!(r.borrow().value.get_attr("data-codes"), Some("12:3:8:11"));
3634    }
3635
3636    #[test]
3637    fn dom_exception_to_string_formats_name_and_message() {
3638        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3639        runtime.run_script(
3640            r#"
3641        var e = new DOMException("oops", "NotFoundError");
3642        document.getElementById("r").setAttribute("data-str", e.toString());
3643        "#,
3644        );
3645        let r = dom.get_element_by_id("r").unwrap();
3646        assert_eq!(
3647            r.borrow().value.get_attr("data-str"),
3648            Some("NotFoundError: oops")
3649        );
3650    }
3651
3652    #[test]
3653    fn create_element_throws_invalid_character_error_for_empty_name() {
3654        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3655        runtime.run_script(
3656            r#"
3657        try {
3658            document.createElement("");
3659            document.getElementById("r").setAttribute("data-error", "no-throw");
3660        } catch (e) {
3661            document.getElementById("r").setAttribute("data-error", e.name);
3662        }
3663        "#,
3664        );
3665        let r = dom.get_element_by_id("r").unwrap();
3666        assert_eq!(
3667            r.borrow().value.get_attr("data-error"),
3668            Some("InvalidCharacterError")
3669        );
3670    }
3671
3672    #[test]
3673    fn create_element_throws_invalid_character_error_for_invalid_name() {
3674        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3675        runtime.run_script(
3676            r#"
3677        try {
3678            document.createElement("123bad");
3679            document.getElementById("r").setAttribute("data-error", "no-throw");
3680        } catch (e) {
3681            document.getElementById("r").setAttribute("data-error", e.name);
3682        }
3683        "#,
3684        );
3685        let r = dom.get_element_by_id("r").unwrap();
3686        assert_eq!(
3687            r.borrow().value.get_attr("data-error"),
3688            Some("InvalidCharacterError")
3689        );
3690    }
3691
3692    #[test]
3693    fn create_element_valid_name_works() {
3694        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
3695        runtime.run_script(
3696            r#"
3697        var el = document.createElement("div");
3698        el.id = "created";
3699        document.body.appendChild(el);
3700        "#,
3701        );
3702        assert!(dom.get_element_by_id("created").is_some());
3703    }
3704
3705    #[test]
3706    fn create_element_ns_throws_invalid_character_error_for_invalid_name() {
3707        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3708        runtime.run_script(
3709            r#"
3710        try {
3711            document.createElementNS("http://www.w3.org/2000/svg", "123bad");
3712            document.getElementById("r").setAttribute("data-error", "no-throw");
3713        } catch (e) {
3714            document.getElementById("r").setAttribute("data-error", e.name);
3715        }
3716        "#,
3717        );
3718        let r = dom.get_element_by_id("r").unwrap();
3719        assert_eq!(
3720            r.borrow().value.get_attr("data-error"),
3721            Some("InvalidCharacterError")
3722        );
3723    }
3724
3725    #[test]
3726    fn node_constants_are_exposed_on_global() {
3727        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3728        runtime.run_script(
3729            r#"
3730        document.getElementById("r").setAttribute("data-elem", Node.ELEMENT_NODE);
3731        document.getElementById("r").setAttribute("data-text", Node.TEXT_NODE);
3732        document.getElementById("r").setAttribute("data-doc", Node.DOCUMENT_NODE);
3733        document.getElementById("r").setAttribute("data-frag", Node.DOCUMENT_FRAGMENT_NODE);
3734        "#,
3735        );
3736        let r = dom.get_element_by_id("r").unwrap();
3737        let r = r.borrow();
3738        assert_eq!(r.value.get_attr("data-elem"), Some("1"));
3739        assert_eq!(r.value.get_attr("data-text"), Some("3"));
3740        assert_eq!(r.value.get_attr("data-doc"), Some("9"));
3741        assert_eq!(r.value.get_attr("data-frag"), Some("11"));
3742    }
3743
3744    #[test]
3745    fn node_constants_are_exposed_on_instance_nodes() {
3746        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3747        runtime.run_script(
3748            r#"
3749        document.getElementById("r").setAttribute("data-doc-frag", document.DOCUMENT_FRAGMENT_NODE);
3750        document.getElementById("r").setAttribute("data-cmt", document.body.COMMENT_NODE);
3751        document.getElementById("r").setAttribute("data-txt", document.createTextNode("").ELEMENT_NODE);
3752        document.getElementById("r").setAttribute("data-frag", document.createElement("div").DOCUMENT_FRAGMENT_NODE);
3753        "#,
3754        );
3755        let r = dom.get_element_by_id("r").unwrap();
3756        let r = r.borrow();
3757        assert_eq!(r.value.get_attr("data-doc-frag"), Some("11"));
3758        assert_eq!(r.value.get_attr("data-cmt"), Some("8"));
3759        assert_eq!(r.value.get_attr("data-txt"), Some("1"));
3760        assert_eq!(r.value.get_attr("data-frag"), Some("11"));
3761    }
3762
3763    #[test]
3764    fn document_first_child_is_doctype_node() {
3765        let (mut runtime, dom) = runtime_from_html(
3766            r#"<!DOCTYPE html><html><body><div id="r"><span>x</span></div></body></html>"#,
3767        );
3768        runtime.run_script(
3769            r#"
3770        document.getElementById("r").setAttribute("data-doc-type", document.nodeType);
3771        document.getElementById("r").setAttribute("data-doctype-node", document.firstChild.nodeType);
3772        document.getElementById("r").setAttribute("data-doctype-name", document.firstChild.nodeName);
3773        var span = document.getElementById("r").firstChild;
3774        document.getElementById("r").setAttribute("data-text-type", span.firstChild.nodeType);
3775        "#,
3776        );
3777        let r = dom.get_element_by_id("r").unwrap();
3778        let r = r.borrow();
3779        assert_eq!(r.value.get_attr("data-doc-type"), Some("9"));
3780        assert_eq!(r.value.get_attr("data-doctype-node"), Some("10"));
3781        assert_eq!(r.value.get_attr("data-doctype-name"), Some("html"));
3782        assert_eq!(r.value.get_attr("data-text-type"), Some("3"));
3783    }
3784
3785    #[test]
3786    fn create_element_ns_preserves_qualified_name() {
3787        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3788        runtime.run_script(
3789            r#"
3790        var el = document.createElementNS("http://ns.example.com/", "prefix:localname");
3791        document.getElementById("r").setAttribute("data-tag", el.tagName);
3792        document.getElementById("r").setAttribute("data-local", el.localName);
3793        document.getElementById("r").setAttribute("data-prefix", el.prefix);
3794        document.getElementById("r").setAttribute("data-ns", el.namespaceURI);
3795        "#,
3796        );
3797        let r = dom.get_element_by_id("r").unwrap();
3798        let r = r.borrow();
3799        assert_eq!(r.value.get_attr("data-tag"), Some("prefix:localname"));
3800        assert_eq!(r.value.get_attr("data-local"), Some("localname"));
3801        assert_eq!(r.value.get_attr("data-prefix"), Some("prefix"));
3802        assert_eq!(r.value.get_attr("data-ns"), Some("http://ns.example.com/"));
3803    }
3804
3805    #[test]
3806    fn document_close_returns_undefined() {
3807        let (mut runtime, _) = runtime_from_html(r#"<html><body></body></html>"#);
3808        runtime.run_script(
3809            r#"
3810        var result = document.close();
3811        document.getElementById("r").setAttribute("data-close", typeof result);
3812        "#,
3813        );
3814        // document.close() returns undefined, and there is no element "r" yet
3815        // so we test it differently
3816        let (mut runtime2, dom2) = runtime_from_html(r#"<div id="r"></div>"#);
3817        runtime2.run_script(
3818            r#"
3819        document.close();
3820        document.getElementById("r").setAttribute("data-close", "ok");
3821        "#,
3822        );
3823        let r = dom2.get_element_by_id("r").unwrap();
3824        assert_eq!(r.borrow().value.get_attr("data-close"), Some("ok"));
3825    }
3826
3827    #[test]
3828    fn dom_exception_instanceof_error() {
3829        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3830        runtime.run_script(
3831            r#"
3832        var e = new DOMException("test", "SyntaxError");
3833        document.getElementById("r").setAttribute("data-is-error", e instanceof Error);
3834        document.getElementById("r").setAttribute("data-is-domexc", e instanceof DOMException);
3835        "#,
3836        );
3837        let r = dom.get_element_by_id("r").unwrap();
3838        let r = r.borrow();
3839        assert_eq!(r.value.get_attr("data-is-error"), Some("true"));
3840        assert_eq!(r.value.get_attr("data-is-domexc"), Some("true"));
3841    }
3842
3843    #[test]
3844    fn create_comment_returns_comment_node() {
3845        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3846        runtime.run_script(
3847            r#"
3848        var c = document.createComment("hello");
3849        document.getElementById("r").setAttribute("data-type", c.nodeType);
3850        document.getElementById("r").setAttribute("data-name", c.nodeName);
3851        document.getElementById("r").setAttribute("data-data", c.data);
3852        "#,
3853        );
3854        let r = dom.get_element_by_id("r").unwrap();
3855        let r = r.borrow();
3856        assert_eq!(r.value.get_attr("data-type"), Some("8"));
3857        assert_eq!(r.value.get_attr("data-name"), Some("#comment"));
3858        assert_eq!(r.value.get_attr("data-data"), Some("hello"));
3859    }
3860
3861    #[test]
3862    fn create_processing_instruction_returns_pi_node() {
3863        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3864        runtime.run_script(
3865            r#"
3866        var pi = document.createProcessingInstruction("xml-stylesheet", "href=\"style.css\"");
3867        document.getElementById("r").setAttribute("data-type", pi.nodeType);
3868        document.getElementById("r").setAttribute("data-data", pi.data);
3869        "#,
3870        );
3871        let r = dom.get_element_by_id("r").unwrap();
3872        let r = r.borrow();
3873        assert_eq!(r.value.get_attr("data-type"), Some("7"));
3874        assert_eq!(r.value.get_attr("data-data"), Some("href=\"style.css\""));
3875    }
3876
3877    #[test]
3878    fn create_processing_instruction_empty_target_throws() {
3879        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
3880        runtime.run_script(
3881            r#"
3882        try {
3883            document.createProcessingInstruction("", "data");
3884            document.getElementById("r").setAttribute("data-error", "no-throw");
3885        } catch (e) {
3886            document.getElementById("r").setAttribute("data-error", e.name);
3887        }
3888        "#,
3889        );
3890        let r = dom.get_element_by_id("r").unwrap();
3891        assert_eq!(r.borrow().value.get_attr("data-error"), Some("SyntaxError"));
3892    }
3893
3894    #[test]
3895    fn innerhtml_setter_uses_parser_and_replaces_children() {
3896        let (mut runtime, dom) = runtime_from_html(r#"<div id="target"></div><div id="r"></div>"#);
3897        runtime.run_script(r#"
3898        var t = document.getElementById("target");
3899        t.innerHTML = "<p>A</p><span>B</span>";
3900        document.getElementById("r").setAttribute("data-count", t.childNodes.length);
3901        document.getElementById("r").setattr || document.getElementById("r").setAttribute("data-tags",
3902            t.children[0].tagName + ":" + t.children[1].tagName
3903        );
3904        "#);
3905        let r = dom.get_element_by_id("r").unwrap();
3906        let r = r.borrow();
3907        assert_eq!(r.value.get_attr("data-count"), Some("2"));
3908    }
3909}