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::computed_style;
12use crate::engine::js::web_apis::dom::document::IframeDocument;
13use crate::engine::layouter::dom_snapshot::DomSnapshot;
14use crate::engine::permissions::Permissions;
15use crate::engine::tree::NodeRef;
16use pixi_byte::value::jsobject::JSObject;
17use pixi_byte::{JSError, JSValue};
18use std::cell::{Cell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::rc::Rc;
21use std::time::{Duration, Instant};
22
23pub mod devtools;
24pub mod processor;
25pub use devtools::JsDevToolsRequest;
26pub use processor::{JsProcessor, JsTask, JsTaskResult};
27
28mod common;
29pub(crate) mod runtime;
30mod trace;
31pub use trace::{JsActiveTrace, JsTraceBatch, JsTraceEvent, JsTraceKind};
32pub(crate) mod web_apis;
33pub use web_apis::missing_api_detector;
34
35/// Whether a freshly created runtime records JavaScript execution by default.
36pub fn default_trace_enabled() -> bool {
37    trace::default_enabled()
38}
39
40// Re-export items needed by sibling modules.
41pub(crate) use common::{
42    host_read_only_property, is_callable, node_dom_id, with_host, with_host_mut,
43};
44pub(crate) use web_apis::dom::document::expose_node;
45pub(crate) use web_apis::dom::events::{event_flag, make_event};
46pub(crate) use web_apis::network::{make_fetch_response, resolve_xml_http_request};
47
48// ---------------------------------------------------------------------------
49// Core types
50// ---------------------------------------------------------------------------
51
52pub(crate) struct JsTimer {
53    id: u64,
54    callback: JSValue,
55    arguments: Vec<JSValue>,
56    deadline: Instant,
57    interval: Option<Duration>,
58}
59
60pub(crate) struct JsFetchCapability {
61    resolve: JSValue,
62    reject: JSValue,
63}
64
65/// A fetch request waiting to be dispatched by the browser network layer.
66#[derive(Debug)]
67pub struct JsFetchRequest {
68    pub(crate) id: u64,
69    pub(crate) url: String,
70    pub(crate) method: String,
71    pub(crate) headers: Vec<(String, String)>,
72    pub(crate) body: Vec<u8>,
73}
74
75/// An `<iframe src="...">` whose content has to be fetched and parsed.
76#[derive(Debug)]
77pub struct JsIframeFetchRequest {
78    /// The DOM id of the `<iframe>` element requesting the load.
79    pub dom_id: u64,
80    /// The absolute (resolved) URL of the iframe source.
81    pub url: String,
82}
83
84/// The serialized content document of a single `<iframe>`, carried from the JS
85/// thread so the layout can render it nested inside the host page.
86#[derive(Debug)]
87pub struct IframeContentSnapshot {
88    /// The JS-facing DOM id of the `<iframe>` element this content belongs to.
89    pub iframe_dom_id: u64,
90    /// The iframe's content document tree, serialized as a movable snapshot.
91    pub content: DomSnapshot,
92}
93
94/// A script element inserted by JavaScript after the initial HTML parse.
95#[derive(Debug)]
96pub(crate) struct JsDynamicScriptRequest {
97    pub(crate) node_id: u64,
98    pub(crate) source: JsDynamicScriptSource,
99}
100
101#[derive(Debug)]
102pub(crate) enum JsDynamicScriptSource {
103    Inline(String),
104    External(String),
105}
106
107#[derive(Debug)]
108pub(crate) struct JsDynamicStyleRequest {
109    pub(crate) node_id: u64,
110    pub(crate) url: String,
111}
112
113/// An image element created or populated after the initial HTML parse.
114#[derive(Debug)]
115// TODO: Track the owning image node so src changes can cancel/reload and dispatch load/error.
116pub(crate) struct JsDynamicImageRequest {
117    pub(crate) source: String,
118}
119
120/// Geometry produced by the committed layout tree for a live DOM element.
121#[derive(Debug, Clone, Copy, Default, PartialEq)]
122pub struct JsLayoutMetrics {
123    pub offset_left: f64,
124    pub offset_top: f64,
125    pub offset_width: f64,
126    pub offset_height: f64,
127    pub client_width: f64,
128    pub client_height: f64,
129    pub rect_left: f64,
130    pub rect_top: f64,
131    pub rect_width: f64,
132    pub rect_height: f64,
133}
134
135/// The response data exposed to a JavaScript `Response` object.
136#[derive(Debug)]
137pub struct JsFetchResponse {
138    pub(crate) url: String,
139    pub(crate) status: u16,
140    pub(crate) status_text: String,
141    pub(crate) redirected: bool,
142    pub(crate) body: Vec<u8>,
143    pub(crate) headers: Vec<(String, String)>,
144}
145
146/// A registered custom element definition.
147#[derive(Clone)]
148pub(crate) struct CustomElementDefinition {
149    pub(crate) constructor: JSValue,
150    pub(crate) connected_callback: Option<JSValue>,
151    pub(crate) disconnected_callback: Option<JSValue>,
152    pub(crate) attribute_changed_callback: Option<JSValue>,
153    pub(crate) observed_attributes: Vec<String>,
154    /// Resolve functions for pending `whenDefined()` promises.
155    pub(crate) when_defined_resolvers: Vec<JSValue>,
156}
157
158/// State shared between the JS natives and the browser side.
159///
160/// The JS-facing `u64` counter (`__orinium_dom_id`) maps to a live DOM node so
161/// element handles survive relayouts: `Rc` handles on the DOM nodes are stable,
162/// while snapshot ids are not.
163pub struct JsHost {
164    pub(crate) dom: Rc<DomTree>,
165    pub(crate) refs: HashMap<
166        u64,
167        std::rc::Weak<std::cell::RefCell<crate::engine::tree::TreeNode<HtmlNodeType>>>,
168    >,
169    /// Element JS objects per DOM id, kept alive so `onclick` handlers
170    /// registered on them survive and can be invoked on user clicks.
171    pub(crate) objects: HashMap<u64, Rc<RefCell<JSObject>>>,
172    /// Stable `CSSStyleDeclaration` wrappers for exposed elements.
173    pub(crate) styles: HashMap<u64, Rc<RefCell<JSObject>>>,
174    /// Stable 2D rendering contexts for canvas elements.
175    pub(crate) canvas_contexts: HashMap<u64, Rc<RefCell<JSObject>>>,
176    /// Explicit namespaces assigned through `document.createElementNS`.
177    pub(crate) namespaces: HashMap<u64, String>,
178    pub(crate) element_prototype: Rc<RefCell<JSObject>>,
179    pub(crate) element_constructor: Rc<RefCell<JSObject>>,
180    pub(crate) node_prototype: Option<Rc<RefCell<JSObject>>>,
181    pub(crate) fragment_prototype: Option<Rc<RefCell<JSObject>>>,
182    /// `ShadowRoot` interface prototype assigned to exposed shadow-root
183    /// wrappers so `instanceof ShadowRoot` holds.
184    pub(crate) shadow_root_prototype: Option<Rc<RefCell<JSObject>>>,
185    pub(crate) document: Option<Rc<RefCell<JSObject>>>,
186    pub(crate) document_implementation: Option<Rc<RefCell<JSObject>>>,
187    /// Independent document instances for `<iframe>` elements, keyed by the
188    /// iframe element's DOM id. Each iframe gets its own DOM tree.
189    pub(crate) iframe_documents: HashMap<u64, Rc<RefCell<IframeDocument>>>,
190    /// DOM ids of iframes whose content failed to load; a failed load is not
191    /// retried automatically (only a new `src` re-queues it).
192    pub(crate) failed_iframe_fetches: HashSet<u64>,
193    pub(crate) document_event_listeners: HashMap<String, Vec<JSValue>>,
194    /// Stable `Selection` singleton returned by `document.getSelection()`.
195    pub(crate) document_selection: Option<Rc<RefCell<JSObject>>>,
196    /// Element event listeners keyed by dom id and event type. Each entry is a
197    /// `(callback, capture)` pair; the capture phase flag distinguishes
198    /// capturing listeners from bubbling ones.
199    pub(crate) element_event_listeners: HashMap<u64, HashMap<String, Vec<(JSValue, bool)>>>,
200    /// Inline event-handler content attributes mapped onto the Window per the
201    /// HTML spec (e.g. `<body onload="...">` registers a `load` event handler
202    /// on the Window). Keyed by event type; populated once when the DOM is
203    /// bound to the runtime so dispatching never re-scans the tree.
204    pub(crate) window_inline_event_handlers: HashMap<String, String>,
205    pub(crate) active_element: Option<u64>,
206    /// DOM id of the element that last entered fullscreen via
207    /// `requestFullscreen()`, or `None` when the page is not fullscreen.
208    pub(crate) fullscreen_element: Option<u64>,
209    /// Keeps JS-created or removed nodes alive while their wrappers exist.
210    pub(crate) detached_nodes: HashMap<u64, NodeRef<HtmlNodeType>>,
211    /// Detached document objects created via
212    /// `document.implementation.createHTMLDocument`, kept alive for scripts.
213    pub(crate) detached_documents: Vec<Rc<RefCell<JSObject>>>,
214    /// DOM id of the main document node, so that `ownerDocument` and the
215    /// `this`-relative document accessors can recognise the top-level document.
216    pub(crate) main_document_dom_id: Option<u64>,
217    pub(crate) timers: Vec<JsTimer>,
218    pub(crate) fetch_requests: Vec<JsFetchRequest>,
219    pub(crate) iframe_fetch_requests: Vec<JsIframeFetchRequest>,
220    /// Top-level navigation URLs requested by scripts (e.g. `form.submit()`),
221    /// drained after each task and dispatched by the browser chrome.
222    pub(crate) navigation_requests: Vec<String>,
223    /// DOM ids of iframes whose content is already queued for loading, to avoid
224    /// re-queuing a fetch on every `contentDocument` access.
225    pub(crate) pending_iframe_fetches: std::collections::HashSet<u64>,
226    pub(crate) dynamic_script_requests: Vec<JsDynamicScriptRequest>,
227    pub(crate) queued_dynamic_scripts: HashSet<u64>,
228    pub(crate) dynamic_style_requests: Vec<JsDynamicStyleRequest>,
229    pub(crate) queued_dynamic_styles: HashSet<u64>,
230    pub(crate) dynamic_image_requests: Vec<JsDynamicImageRequest>,
231    pub(crate) queued_dynamic_images: HashSet<u64>,
232    pub(crate) fetch_capabilities: HashMap<u64, JsFetchCapability>,
233    pub(crate) fetch_urls: HashMap<u64, String>,
234    /// `AbortSignal` objects registered by in-flight fetches, keyed by request
235    /// id. Held here rather than on [`JsFetchRequest`] so the signal — an
236    /// `Rc<RefCell<JSObject>>` that can never leave the JS thread — never
237    /// crosses to the browser side. Entries are removed when the request
238    /// settles or aborts.
239    pub(crate) fetch_signals: HashMap<u64, Rc<RefCell<JSObject>>>,
240    pub(crate) xhr_requests: HashMap<u64, Rc<RefCell<JSObject>>>,
241    pub(crate) constructing_fetch_capability: Option<JsFetchCapability>,
242    pub(crate) devtools_requests: Vec<JsDevToolsRequest>,
243    pub(crate) devtools_capabilities: HashMap<u64, devtools::JsDevToolsCapability>,
244    pub(crate) constructing_devtools_capability: Option<devtools::JsDevToolsCapability>,
245    pub(crate) next_devtools_id: u64,
246    pub(crate) console_results: Vec<(u64, String)>,
247    /// Registered custom element definitions keyed by lowercase tag name.
248    pub(crate) custom_elements: HashMap<String, CustomElementDefinition>,
249    /// Shadow root associations: host_dom_id -> shadow_root_dom_id.
250    pub(crate) shadow_roots: HashMap<u64, u64>,
251    // TODO: Persist localStorage per origin and sessionStorage per top-level browsing context.
252    pub(crate) local_storage: HashMap<String, String>,
253    pub(crate) session_storage: HashMap<String, String>,
254    // TODO: Move cookies into a shared origin/path-aware jar with expiry and security attributes.
255    pub(crate) document_cookies: HashMap<String, String>,
256    /// DOM id of the script currently being evaluated, exposed as
257    /// `document.currentScript`.
258    pub(crate) current_script_dom_id: Option<u64>,
259    /// User-supplied `document.adoptedStyleSheets` array.
260    pub(crate) adopted_style_sheets: Option<JSValue>,
261    /// Stable `document.fonts` (`FontFaceSet`) singleton.
262    pub(crate) document_fonts: Option<Rc<RefCell<JSObject>>>,
263    pub(crate) document_url: String,
264    /// ASCII serialization of the document's origin (`"null"` when opaque).
265    pub(crate) origin: String,
266    /// Effective capabilities of the top-level document, inherited by any child
267    /// frame. Defaults to no capabilities until the browser sets them.
268    pub(crate) permissions: Permissions,
269    pub(crate) viewport: (f64, f64),
270    /// Committed layout measurements keyed by the address of a live DOM node.
271    pub(crate) layout_metrics: HashMap<usize, JsLayoutMetrics>,
272    /// Committed layout measurements keyed by stable JS-facing DOM id.
273    pub(crate) layout_metrics_by_dom_id: HashMap<u64, JsLayoutMetrics>,
274    /// Serialized computed CSS values (`name: value`) keyed by stable
275    /// JS-facing DOM id, rebuilt after each committed layout so
276    /// `getComputedStyle` reflects the resolved cascade instead of only the
277    /// inline `style` attribute.
278    pub(crate) computed_styles: HashMap<u64, Vec<(String, String)>>,
279    /// Stable read-only `CSSStyleDeclaration` wrappers backing
280    /// `getComputedStyle` results, keyed by DOM id.
281    pub(crate) computed_style_declarations: HashMap<u64, Rc<RefCell<JSObject>>>,
282    /// On-demand cascade resolution used when no committed layout snapshot is
283    /// available (headless callers, and reads before the first layout).
284    pub(crate) computed_style_resolver: computed_style::Resolver,
285    pub(crate) next_fetch_id: u64,
286    pub(crate) next_timer_id: u64,
287    pub(crate) time_origin: Instant,
288    pub(crate) dom_content_loaded_fired: bool,
289    pub(crate) window_load_fired: bool,
290    pub(crate) next_id: u64,
291    pub(crate) needs_redraw: Rc<Cell<bool>>,
292    /// Bounded JavaScript execution trace shown by the DevTools pane.
293    pub(crate) trace: trace::JsTraceSession,
294}
295
296impl JsHost {
297    /// Finds the JS-facing DOM id registered for a live DOM node, if any.
298    pub(crate) fn dom_id_for_node(&self, node: &NodeRef<HtmlNodeType>) -> Option<u64> {
299        self.refs.iter().find_map(|(&dom_id, weak)| {
300            if std::ptr::eq(weak.as_ptr(), Rc::as_ptr(node)) {
301                Some(dom_id)
302            } else {
303                None
304            }
305        })
306    }
307}
308
309/// A JS engine instance with DOM bindings installed.
310pub struct JsRuntime {
311    engine: pixi_byte::JSEngine,
312    needs_redraw: Rc<Cell<bool>>,
313}
314
315impl JsRuntime {
316    /// Creates a runtime sharing the given DOM tree with the browser side.
317    pub fn new(dom: Rc<DomTree>) -> Self {
318        let needs_redraw = Rc::new(Cell::new(false));
319        let (element_prototype, element_constructor) =
320            web_apis::dom::element::make_element_interface();
321        let mut host = JsHost {
322            dom,
323            refs: HashMap::new(),
324            objects: HashMap::new(),
325            styles: HashMap::new(),
326            canvas_contexts: HashMap::new(),
327            namespaces: HashMap::new(),
328            element_prototype,
329            element_constructor,
330            node_prototype: None,
331            fragment_prototype: None,
332            shadow_root_prototype: None,
333            document: None,
334            document_implementation: None,
335            iframe_documents: HashMap::new(),
336            failed_iframe_fetches: HashSet::new(),
337            document_event_listeners: HashMap::new(),
338            document_selection: None,
339            element_event_listeners: HashMap::new(),
340            window_inline_event_handlers: HashMap::new(),
341            active_element: None,
342            fullscreen_element: None,
343            detached_nodes: HashMap::new(),
344            timers: Vec::new(),
345            fetch_requests: Vec::new(),
346            iframe_fetch_requests: Vec::new(),
347            navigation_requests: Vec::new(),
348            pending_iframe_fetches: std::collections::HashSet::new(),
349            dynamic_script_requests: Vec::new(),
350            queued_dynamic_scripts: HashSet::new(),
351            dynamic_style_requests: Vec::new(),
352            queued_dynamic_styles: HashSet::new(),
353            dynamic_image_requests: Vec::new(),
354            queued_dynamic_images: HashSet::new(),
355            fetch_capabilities: HashMap::new(),
356            fetch_urls: HashMap::new(),
357            fetch_signals: HashMap::new(),
358            xhr_requests: HashMap::new(),
359            constructing_fetch_capability: None,
360            devtools_requests: Vec::new(),
361            devtools_capabilities: HashMap::new(),
362            constructing_devtools_capability: None,
363            next_devtools_id: 0,
364            console_results: Vec::new(),
365            custom_elements: HashMap::new(),
366            shadow_roots: HashMap::new(),
367            local_storage: HashMap::new(),
368            session_storage: HashMap::new(),
369            document_cookies: HashMap::new(),
370            current_script_dom_id: None,
371            adopted_style_sheets: None,
372            document_fonts: None,
373            document_url: "about:blank".to_string(),
374            origin: "null".to_string(),
375            permissions: Permissions::none(),
376            viewport: (800.0, 600.0),
377            layout_metrics: HashMap::new(),
378            layout_metrics_by_dom_id: HashMap::new(),
379            computed_styles: HashMap::new(),
380            computed_style_declarations: HashMap::new(),
381            computed_style_resolver: computed_style::Resolver::new(),
382            next_fetch_id: 0,
383            next_timer_id: 0,
384            time_origin: Instant::now(),
385            dom_content_loaded_fired: false,
386            window_load_fired: false,
387            next_id: 0,
388            needs_redraw: Rc::clone(&needs_redraw),
389            detached_documents: Vec::new(),
390            main_document_dom_id: None,
391            trace: trace::JsTraceSession::new(),
392        };
393        Self::register_window_event_handlers(&mut host);
394
395        let host = Rc::new(RefCell::new(host));
396
397        let mut engine = pixi_byte::JSEngine::new();
398        engine.set_host(host);
399
400        web_apis::console::install_console(&mut engine);
401        trace::install_caught_exception_hook(&mut engine);
402        web_apis::dom::document::install_document(&mut engine);
403
404        web_apis::observers::install_mutation_observer(&mut engine);
405        web_apis::observers::install_resize_observer(&mut engine);
406        web_apis::observers::install_intersection_observer(&mut engine);
407        web_apis::timers::install_timers(&mut engine);
408        web_apis::performance::install_performance(&mut engine);
409        web_apis::message_channel::install_message_channel(&mut engine);
410        runtime::microtasks::install_microtasks(&mut engine);
411        web_apis::network::install_headers(&mut engine);
412        web_apis::network::install_request(&mut engine);
413        web_apis::network::install_fetch(&mut engine);
414        web_apis::network::install_xml_http_request(&mut engine);
415        web_apis::network::install_response(&mut engine);
416        web_apis::abort::install_abort_apis(&mut engine);
417        web_apis::file::install_blob_apis(&mut engine);
418        devtools::install(&mut engine);
419        web_apis::url::install_url_apis(&mut engine);
420        web_apis::encoding::install_encoding_apis(&mut engine);
421        web_apis::browser_env::install_browser_environment(&mut engine);
422        web_apis::browser_env::install_global_aliases(&mut engine);
423        web_apis::misc::install_misc_apis(&mut engine);
424        web_apis::dom::custom_elements::install_custom_elements(&mut engine);
425
426        Self {
427            engine,
428            needs_redraw,
429        }
430    }
431
432    /// Updates the CSS-pixel viewport exposed through the Window API.
433    pub fn set_viewport(&mut self, width: f32, height: f32) {
434        let width = width.max(0.0) as f64;
435        let height = height.max(0.0) as f64;
436        let mut global = self.engine.global_mut().borrow_mut();
437        global.set("innerWidth".to_string(), JSValue::from_number(width));
438        global.set("innerHeight".to_string(), JSValue::from_number(height));
439        global.set("outerWidth".to_string(), JSValue::from_number(width));
440        global.set("outerHeight".to_string(), JSValue::from_number(height));
441        drop(global);
442        with_host_mut(self.engine.vm(), |host| host.viewport = (width, height));
443    }
444
445    /// Replaces geometry exposed by DOM measurement APIs with the latest
446    /// committed layout result.
447    #[cfg(test)]
448    pub(crate) fn set_layout_metrics(&mut self, metrics: HashMap<usize, JsLayoutMetrics>) {
449        with_host_mut(self.engine.vm(), |host| host.layout_metrics = metrics);
450    }
451
452    /// Replaces geometry using stable DOM ids supplied by the browser UI
453    /// thread, whose live node addresses differ from this runtime's mirror.
454    pub(crate) fn set_layout_metrics_by_dom_id(&mut self, metrics: HashMap<u64, JsLayoutMetrics>) {
455        with_host_mut(self.engine.vm(), |host| {
456            host.layout_metrics_by_dom_id = metrics
457        });
458    }
459
460    /// Replaces the serialized computed CSS values using stable DOM ids.
461    pub(crate) fn set_computed_styles_by_dom_id(
462        &mut self,
463        computed_styles: HashMap<u64, Vec<(String, String)>>,
464    ) {
465        with_host_mut(self.engine.vm(), |host| {
466            // A committed snapshot is authoritative from here on, so any
467            // on-demand values resolved before it are superseded.
468            host.computed_style_resolver.invalidate();
469            host.computed_styles = computed_styles
470        });
471    }
472
473    /// Updates the language preferences exposed through `navigator`.
474    pub fn set_language(&mut self, language: &str) {
475        let language = language.trim();
476        if language.is_empty() {
477            return;
478        }
479        let mut languages = vec![JSValue::from_string(language.to_string())];
480        if let Some(base) = language.split('-').next()
481            && !base.eq_ignore_ascii_case(language)
482        {
483            languages.push(JSValue::from_string(base.to_string()));
484        }
485        if !language.eq_ignore_ascii_case("en-US") {
486            languages.push(JSValue::from_string("en-US".to_string()));
487        }
488
489        let global = self.engine.global_mut().borrow_mut();
490        let Some(navigator) = global.get("navigator").as_object() else {
491            return;
492        };
493        drop(global);
494        let mut navigator = navigator.borrow_mut();
495        navigator.define_property(
496            "language".to_string(),
497            host_read_only_property(JSValue::from_string(language.to_string())),
498        );
499        navigator.define_property(
500            "languages".to_string(),
501            host_read_only_property(self.engine.vm().array_from_values(languages)),
502        );
503    }
504
505    /// Starts a traced operation, or returns `None` when tracing is paused.
506    fn begin_trace(
507        &self,
508        kind: JsTraceKind,
509        label: String,
510        detail: String,
511        source: Option<String>,
512    ) -> Option<trace::JsTraceSpan> {
513        if !trace::is_enabled(self.engine.vm()) {
514            return None;
515        }
516        let active_id = trace::begin_active(
517            self.engine.vm(),
518            kind,
519            label.clone(),
520            detail.clone(),
521            source.clone(),
522        );
523        Some(trace::JsTraceSpan {
524            active_id,
525            started: Instant::now(),
526            kind,
527            label,
528            detail,
529            source,
530        })
531    }
532
533    /// Finishes a traced operation and records it in the DevTools stream.
534    fn end_trace(
535        &mut self,
536        span: Option<trace::JsTraceSpan>,
537        error: Option<String>,
538        stack: Vec<String>,
539    ) {
540        let Some(span) = span else {
541            return;
542        };
543        trace::end_active(self.engine.vm(), span.active_id);
544        let duration_us = span.started.elapsed().as_micros() as u64;
545        let variables = if error.is_some() {
546            self.engine.last_error_variables().to_vec()
547        } else {
548            Vec::new()
549        };
550        let program_counter = error.as_ref().and_then(|_| self.engine.last_error_pc());
551        trace::record(
552            self.engine.vm(),
553            span.kind,
554            span.label,
555            span.detail,
556            duration_us,
557            error,
558            stack,
559            span.source,
560            variables,
561            program_counter,
562        );
563    }
564
565    /// Returns the error's JavaScript stack, or an empty stack on success.
566    fn trace_error_stack(&self, error: &Option<String>) -> Vec<String> {
567        if error.is_some() {
568            self.engine.last_error_stack().to_vec()
569        } else {
570            Vec::new()
571        }
572    }
573
574    /// Drains traced events for transport to the browser thread.
575    pub(crate) fn take_trace_batch(&mut self) -> JsTraceBatch {
576        trace::take_batch(self.engine.vm())
577    }
578
579    /// Enables or disables JavaScript execution tracing.
580    pub(crate) fn set_js_tracing(&mut self, enabled: bool) {
581        trace::set_enabled(self.engine.vm(), enabled);
582    }
583
584    /// Clears the buffered JavaScript execution trace.
585    pub(crate) fn clear_js_trace(&mut self) {
586        trace::clear(self.engine.vm());
587    }
588
589    /// Evaluates a DevTools console expression and queues its printable result.
590    pub(crate) fn evaluate_console(&mut self, id: u64, source: &str) {
591        let result = match self.engine.eval(source) {
592            Ok(value) => trace::format_console_value(&value),
593            Err(error) => format!("Uncaught {error}"),
594        };
595        with_host_mut(self.engine.vm(), |host| {
596            host.console_results.push((id, result))
597        });
598    }
599
600    /// Drains completed DevTools console evaluations.
601    pub(crate) fn take_console_results(&mut self) -> Vec<(u64, String)> {
602        with_host_mut(self.engine.vm(), |host| {
603            std::mem::take(&mut host.console_results)
604        })
605        .unwrap_or_default()
606    }
607
608    /// Evaluates a script, logging JS errors instead of crashing the page.
609    pub fn run_script(&mut self, source: &str, label: Option<&str>) {
610        let display_label = label.unwrap_or("script").to_string();
611        let span = self.begin_trace(
612            JsTraceKind::Script,
613            display_label,
614            trace::source_summary(source),
615            Some(trace::source_preview(source)),
616        );
617        let result = self.engine.eval(source);
618        let error = result.as_ref().err().map(ToString::to_string);
619        match result {
620            Ok(_) => {}
621            Err(err) => {
622                if let JSError::Thrown(value) = &err
623                    && let Some(object) = value.as_object()
624                {
625                    let object = object.borrow();
626                    let details = object
627                        .keys()
628                        .into_iter()
629                        .map(|key| format!("{key}={}", object.get(&key).to_console_string()))
630                        .collect::<Vec<_>>()
631                        .join(", ");
632                    log::info!("JS error: uncaught object ({details})");
633                }
634                log::info!(
635                    "JS error: {} (label {:?}, source {:?}, pc={:?}, fn={:?}, stack={:?})",
636                    err,
637                    label,
638                    source.chars().take(60).collect::<String>(),
639                    self.engine.last_error_pc(),
640                    self.engine.last_error_fn(),
641                    self.engine
642                        .last_error_stack()
643                        .iter()
644                        .take(10)
645                        .collect::<Vec<_>>(),
646                );
647            }
648        }
649        let stack = self.trace_error_stack(&error);
650        self.end_trace(span, error, stack);
651        self.perform_microtask_checkpoint();
652    }
653
654    /// Evaluates a script, returning the first JS error (parse or compile
655    /// failure, or an uncaught thrown value). Diagnostic harnesses use this to
656    /// distinguish "script ran clean" from "never parsed"; runtime exceptions
657    /// that the page itself catches still surface as `Ok`.
658    pub fn try_run_script(&mut self, source: &str, label: Option<&str>) -> Result<(), String> {
659        let display_label = label.unwrap_or("script").to_string();
660        let span = self.begin_trace(
661            JsTraceKind::Script,
662            display_label,
663            trace::source_summary(source),
664            Some(trace::source_preview(source)),
665        );
666        let result = self.engine.eval(source);
667        let error = result.as_ref().err().map(ToString::to_string);
668        let stack = self.trace_error_stack(&error);
669        self.end_trace(span, error, stack);
670        self.perform_microtask_checkpoint();
671        match result {
672            Ok(_) => Ok(()),
673            Err(err) => Err(err.to_string()),
674        }
675    }
676
677    /// Evaluates an expression and returns its value, or `undefined` on error.
678    pub fn eval_value(&mut self, source: &str) -> JSValue {
679        match self.engine.eval(source) {
680            Ok(value) => value,
681            Err(err) => {
682                log::info!("JS error evaluating {source:?}: {err}");
683                JSValue::undefined()
684            }
685        }
686    }
687
688    /// Diagnostic: sizes of the host-side maps that hold JS values.
689    #[doc(hidden)]
690    pub fn host_stats(&self) -> String {
691        with_host(self.engine.vm(), |host| {
692            let doc_listeners: usize = host
693                .document_event_listeners
694                .values()
695                .map(std::vec::Vec::len)
696                .sum();
697            let elem_listeners: usize = host
698                .element_event_listeners
699                .values()
700                .flat_map(|by_type| by_type.values().map(std::vec::Vec::len))
701                .sum();
702            format!(
703                "timers={} doc_listeners={} elem_listener_types={} elem_listeners={} \
704                 objects={} styles={} canvas={} namespaces={} detached_nodes={} \
705                 detached_docs={} fetch_signals={} xhr={} fetch_requests={} iframes={} \
706                 layout_metrics={} computed_styles={} computed_decls={} custom_elements={}",
707                host.timers.len(),
708                doc_listeners,
709                host.element_event_listeners.len(),
710                elem_listeners,
711                host.objects.len(),
712                host.styles.len(),
713                host.canvas_contexts.len(),
714                host.namespaces.len(),
715                host.detached_nodes.len(),
716                host.detached_documents.len(),
717                host.fetch_signals.len(),
718                host.xhr_requests.len(),
719                host.fetch_requests.len(),
720                host.iframe_documents.len(),
721                host.layout_metrics.len() + host.layout_metrics_by_dom_id.len(),
722                host.computed_styles.len(),
723                host.computed_style_declarations.len(),
724                host.custom_elements.len(),
725            )
726        })
727        .unwrap_or_default()
728    }
729
730    /// Updates the URL exposed through the window's `location` object.
731    pub fn set_document_url(&mut self, url: &str) {
732        let _ = with_host_mut(self.engine.vm(), |host| {
733            host.document_url = url.to_string();
734        });
735    }
736
737    /// Updates the serialized origin exposed through the window's
738    /// `location`/`window`/`document` objects.
739    pub fn set_page_origin(&mut self, origin: &str) {
740        let _ = with_host_mut(self.engine.vm(), |host| {
741            host.origin = origin.to_string();
742        });
743    }
744
745    /// Installs the effective capabilities of the top-level document.
746    ///
747    /// Child frames derive their own permissions by inheriting from this set, so
748    /// an embedded document can never hold a capability the page lacks.
749    pub fn set_page_permissions(&mut self, permissions: Permissions) {
750        let _ = with_host_mut(self.engine.vm(), |host| {
751            host.permissions = permissions;
752        });
753    }
754
755    /// Dispatches `DOMContentLoaded` to document listeners once.
756    ///
757    /// Returns `true` only for the first dispatch attempt. Listener errors are
758    /// logged and do not prevent the remaining listeners from running.
759    pub fn dispatch_dom_content_loaded(&mut self) -> bool {
760        let Some((document, listeners)) = with_host_mut(self.engine.vm(), |host| {
761            if host.dom_content_loaded_fired {
762                return None;
763            }
764
765            host.dom_content_loaded_fired = true;
766            Some((
767                host.document.as_ref().cloned(),
768                host.document_event_listeners
769                    .get("DOMContentLoaded")
770                    .cloned()
771                    .unwrap_or_default(),
772            ))
773        })
774        .flatten() else {
775            return false;
776        };
777
778        let Some(document) = document else {
779            return true;
780        };
781        let span = self.begin_trace(
782            JsTraceKind::Event,
783            "DOMContentLoaded".to_string(),
784            format!("{} listener(s)", listeners.len()),
785            None,
786        );
787        let mut first_error = None;
788        for listener in listeners {
789            let event = make_event(
790                "DOMContentLoaded",
791                Rc::clone(&document),
792                Rc::clone(&document),
793            );
794            if let Err(err) = self.engine.call(
795                listener,
796                JSValue::from_object(Rc::clone(&document)),
797                vec![JSValue::from_object(event)],
798            ) {
799                log::info!("JS error on DOMContentLoaded: {}", err);
800                first_error.get_or_insert_with(|| err.to_string());
801            }
802        }
803        let stack = self.trace_error_stack(&first_error);
804        self.end_trace(span, first_error, stack);
805        self.perform_microtask_checkpoint();
806        true
807    }
808
809    /// Dispatches the window `load` event once the page has finished loading.
810    ///
811    /// Supports the body `onload` inline handler, the `window.onload` property,
812    /// and `addEventListener("load", ...)` registrations in that order. Returns
813    /// `true` only for the first dispatch attempt.
814    pub fn dispatch_window_load(&mut self) -> bool {
815        let state = with_host_mut(self.engine.vm(), |host| {
816            if host.window_load_fired {
817                return None;
818            }
819            host.window_load_fired = true;
820            Some((
821                host.window_inline_event_handlers
822                    .get("load")
823                    .cloned()
824                    .unwrap_or_default(),
825                host.document_event_listeners
826                    .get("load")
827                    .cloned()
828                    .unwrap_or_default(),
829            ))
830        });
831        let Some((body_onload, listeners)) = state.flatten() else {
832            return false;
833        };
834
835        let window_object = Rc::clone(self.engine.global_mut());
836        let span = self.begin_trace(
837            JsTraceKind::Event,
838            "load".to_string(),
839            format!("{} listener(s)", listeners.len()),
840            None,
841        );
842        let mut first_error = None;
843
844        // The body `onload` is a window-level inline event handler that fires
845        // first, as if it had been registered first by the parser.
846        if !body_onload.trim().is_empty()
847            && let Some(error) =
848                self.call_inline_load_handler(&window_object, &body_onload, "body onload")
849        {
850            first_error.get_or_insert(error);
851        }
852
853        let onload = window_object.borrow().get("onload");
854        if is_callable(&onload)
855            && let Some(error) = self.call_load_handler(&window_object, onload, "window.onload")
856        {
857            first_error.get_or_insert(error);
858        }
859
860        for listener in listeners {
861            if let Some(error) =
862                self.call_load_handler(&window_object, listener, "window load listener")
863            {
864                first_error.get_or_insert(error);
865            }
866        }
867
868        let stack = self.trace_error_stack(&first_error);
869        self.end_trace(span, first_error, stack);
870        self.perform_microtask_checkpoint();
871        true
872    }
873
874    /// Compiles and runs an inline event-handler content attribute (e.g.
875    /// `<body onload="...">`) with `this` bound to the Window and an `event`
876    /// parameter, per the HTML spec's inline event handler activation.
877    fn call_inline_load_handler(
878        &mut self,
879        window: &Rc<RefCell<JSObject>>,
880        code: &str,
881        label: &str,
882    ) -> Option<String> {
883        let span = self.begin_trace(
884            JsTraceKind::Script,
885            label.to_string(),
886            "inline handler".to_string(),
887            Some(trace::source_preview(code)),
888        );
889        let event = make_event("load", Rc::clone(window), Rc::clone(window));
890        // Per HTML spec, the inline handler is compiled as a function whose
891        // `event` parameter and `this` (the Window) are set; invoking it runs
892        // the assigned code (e.g. Acid3's body onload="update()").
893        let wrapped = format!("(function(event) {{ {code} \n}})");
894        let handler = match self.engine.eval(&wrapped) {
895            Ok(handler) => handler,
896            Err(err) => {
897                let error = err.to_string();
898                log::info!("JS error in {label}: {err}");
899                let stack = self.trace_error_stack(&Some(error.clone()));
900                self.end_trace(span, Some(error.clone()), stack);
901                return Some(error);
902            }
903        };
904        let error = if is_callable(&handler) {
905            match self.engine.call(
906                handler,
907                JSValue::from_object(Rc::clone(window)),
908                vec![JSValue::from_object(event)],
909            ) {
910                Ok(_) => None,
911                Err(err) => {
912                    log::info!("JS error in {label}: {err}");
913                    Some(err.to_string())
914                }
915            }
916        } else {
917            None
918        };
919        let stack = self.trace_error_stack(&error);
920        self.end_trace(span, error.clone(), stack);
921        error
922    }
923
924    /// Invokes a registered window `load` listener with the Window as `this`.
925    fn call_load_handler(
926        &mut self,
927        window: &Rc<RefCell<JSObject>>,
928        handler: JSValue,
929        label: &str,
930    ) -> Option<String> {
931        let span = self.begin_trace(
932            JsTraceKind::Event,
933            label.to_string(),
934            "load listener".to_string(),
935            None,
936        );
937        let event = make_event("load", Rc::clone(window), Rc::clone(window));
938        let error = match self.engine.call(
939            handler,
940            JSValue::from_object(Rc::clone(window)),
941            vec![JSValue::from_object(event)],
942        ) {
943            Ok(_) => None,
944            Err(err) => {
945                log::info!("JS error in {label}: {err}");
946                Some(err.to_string())
947            }
948        };
949        let stack = self.trace_error_stack(&error);
950        self.end_trace(span, error.clone(), stack);
951        error
952    }
953
954    /// Runs timer callbacks whose deadlines have elapsed.
955    ///
956    /// Returns whether at least one callback was invoked. Repeating timers are
957    /// rescheduled before invocation so they can cancel themselves.
958    pub fn run_due_timers(&mut self) -> bool {
959        let invocations = with_host_mut(self.engine.vm(), |host| {
960            let now = Instant::now();
961            let mut invocations = Vec::new();
962            let mut index = 0;
963            while index < host.timers.len() {
964                if host.timers[index].deadline > now {
965                    index += 1;
966                    continue;
967                }
968
969                let callback = host.timers[index].callback.clone();
970                let arguments = host.timers[index].arguments.clone();
971                let is_interval = host.timers[index].interval.is_some();
972                if let Some(interval) = host.timers[index].interval {
973                    host.timers[index].deadline = now + interval;
974                    index += 1;
975                } else {
976                    host.timers.remove(index);
977                }
978                invocations.push((callback, arguments, is_interval));
979            }
980            invocations
981        })
982        .unwrap_or_default();
983
984        let ran_callback = !invocations.is_empty();
985        for (callback, arguments, is_interval) in invocations {
986            let label = if is_interval {
987                "setInterval"
988            } else {
989                "setTimeout"
990            };
991            let span = self.begin_trace(
992                JsTraceKind::Timer,
993                label.to_string(),
994                format!("{} argument(s)", arguments.len()),
995                None,
996            );
997            let error = match self.engine.call(callback, JSValue::undefined(), arguments) {
998                Ok(_) => None,
999                Err(err) => {
1000                    log::info!("JS error in timer callback: {}", err);
1001                    Some(err.to_string())
1002                }
1003            };
1004            let stack = self.trace_error_stack(&error);
1005            self.end_trace(span, error, stack);
1006            self.perform_microtask_checkpoint();
1007        }
1008        ran_callback
1009    }
1010
1011    /// Returns whether a script mutated the DOM and a relayout is needed.
1012    pub fn needs_redraw(&self) -> bool {
1013        self.needs_redraw.get()
1014    }
1015
1016    /// Clears and returns the redraw flag.
1017    pub fn take_needs_redraw(&self) -> bool {
1018        self.needs_redraw.replace(false)
1019    }
1020
1021    /// Flags that the runtime produced visible state (e.g. an iframe content
1022    /// document) that must be carried back to the browser side even though the
1023    /// host DOM tree itself did not mutate.
1024    pub(crate) fn mark_needs_redraw(&self) {
1025        self.needs_redraw.set(true);
1026    }
1027
1028    /// Takes fetch requests queued by JavaScript since the previous call.
1029    pub(crate) fn take_fetch_requests(&mut self) -> Vec<JsFetchRequest> {
1030        with_host_mut(self.engine.vm(), |host| {
1031            std::mem::take(&mut host.fetch_requests)
1032        })
1033        .unwrap_or_default()
1034    }
1035
1036    /// Takes top-level navigation URLs queued by scripts since the previous
1037    /// call (`form.requestSubmit()` / `form.submit()` on GET forms).
1038    pub fn take_navigation_requests(&mut self) -> Vec<String> {
1039        with_host_mut(self.engine.vm(), |host| {
1040            std::mem::take(&mut host.navigation_requests)
1041        })
1042        .unwrap_or_default()
1043    }
1044
1045    /// Takes iframe-loading requests queued by JavaScript since the previous
1046    /// call. Each must be resolved via `resolve_iframe_fetch` once fetched.
1047    pub fn take_iframe_fetch_requests(&mut self) -> Vec<JsIframeFetchRequest> {
1048        with_host_mut(self.engine.vm(), |host| {
1049            std::mem::take(&mut host.iframe_fetch_requests)
1050        })
1051        .unwrap_or_default()
1052    }
1053
1054    /// Parses fetched iframe HTML, installs it as the iframe's `contentDocument`
1055    /// and fires the iframe's `load` event.
1056    pub fn resolve_iframe_fetch(&mut self, dom_id: u64, html: String, url: String) {
1057        let installed = with_host_mut(self.engine.vm(), |host| {
1058            host.pending_iframe_fetches.remove(&dom_id);
1059            host.failed_iframe_fetches.remove(&dom_id);
1060            web_apis::dom::document::install_parsed_iframe_document(host, dom_id, &html, &url)
1061        });
1062        if installed.unwrap_or(false) {
1063            self.dispatch_element_event(dom_id, "load");
1064            // Installing a content document does not mutate the host tree, so
1065            // nothing would otherwise flag this task's result: mark the runtime
1066            // so the processor ships the snapshot with the iframe documents and
1067            // the browser thread grafts them into layout.
1068            self.mark_needs_redraw();
1069        }
1070    }
1071
1072    /// Marks an iframe load as failed so later `contentDocument` accesses do not
1073    /// keep re-queuing a fetch.
1074    pub fn reject_iframe_fetch(&mut self, dom_id: u64) {
1075        with_host_mut(self.engine.vm(), |host| {
1076            host.pending_iframe_fetches.remove(&dom_id);
1077            host.failed_iframe_fetches.insert(dom_id);
1078        });
1079    }
1080
1081    /// Queues network loads for every `<iframe src>` in the bound DOM that has
1082    /// not yet been queued, loaded, or failed.
1083    ///
1084    /// Markup-declared iframes (and frames inserted through fragment parsing
1085    /// such as `innerHTML`) never pass through the `src` setter, so nothing
1086    /// would otherwise request their content. The processor runs this after
1087    /// each task so those frames load like any other subresource. Returns the
1088    /// number of loads newly queued.
1089    pub(crate) fn queue_markup_iframe_loads(&mut self) -> usize {
1090        with_host_mut(self.engine.vm(), |host| {
1091            let iframes = host.dom.find_all(|node| node.tag_name() == Some("iframe"));
1092            let mut queued = 0;
1093            for node in iframes {
1094                let src = {
1095                    let node_ref = node.borrow();
1096                    node_ref
1097                        .value
1098                        .get_attr("src")
1099                        .map(str::trim)
1100                        .unwrap_or("")
1101                        .to_string()
1102                };
1103                if src.is_empty() {
1104                    continue;
1105                }
1106                let Some(dom_id) = host.dom_id_for_node(&node) else {
1107                    continue;
1108                };
1109                let before = host.pending_iframe_fetches.len();
1110                web_apis::dom::element::queue_iframe_fetch_if_needed(host, dom_id, &src);
1111                if host.pending_iframe_fetches.len() > before {
1112                    queued += 1;
1113                }
1114            }
1115            queued
1116        })
1117        .unwrap_or(0)
1118    }
1119
1120    pub(crate) fn take_dynamic_script_requests(&mut self) -> Vec<JsDynamicScriptRequest> {
1121        with_host_mut(self.engine.vm(), |host| {
1122            std::mem::take(&mut host.dynamic_script_requests)
1123        })
1124        .unwrap_or_default()
1125    }
1126
1127    pub(crate) fn take_dynamic_style_requests(&mut self) -> Vec<JsDynamicStyleRequest> {
1128        with_host_mut(self.engine.vm(), |host| {
1129            std::mem::take(&mut host.dynamic_style_requests)
1130        })
1131        .unwrap_or_default()
1132    }
1133
1134    pub(crate) fn take_dynamic_image_requests(&mut self) -> Vec<JsDynamicImageRequest> {
1135        with_host_mut(self.engine.vm(), |host| {
1136            std::mem::take(&mut host.dynamic_image_requests)
1137        })
1138        .unwrap_or_default()
1139    }
1140
1141    /// Dispatches a non-bubbling event to a dynamically inserted element.
1142    pub(crate) fn dispatch_element_event(&mut self, node_id: u64, event_type: &str) {
1143        let Some((target, listeners)) = with_host(self.engine.vm(), |host| {
1144            let target = host.objects.get(&node_id).cloned()?;
1145            let listeners = host
1146                .element_event_listeners
1147                .get(&node_id)
1148                .and_then(|events| events.get(event_type))
1149                .cloned()
1150                .unwrap_or_default();
1151            Some((target, listeners))
1152        })
1153        .flatten() else {
1154            return;
1155        };
1156        let handler = target.borrow().get(&format!("on{event_type}"));
1157        let event = make_event(event_type, Rc::clone(&target), Rc::clone(&target));
1158        let span = self.begin_trace(
1159            JsTraceKind::Event,
1160            event_type.to_string(),
1161            format!("{} listener(s)", listeners.len()),
1162            None,
1163        );
1164        let mut first_error = None;
1165        if is_callable(&handler)
1166            && let Err(error) = self.engine.call(
1167                handler,
1168                JSValue::from_object(Rc::clone(&target)),
1169                vec![JSValue::from_object(Rc::clone(&event))],
1170            )
1171        {
1172            log::info!("JS error in on{event_type}: {error}");
1173            first_error.get_or_insert_with(|| error.to_string());
1174        }
1175        for listener in listeners {
1176            if event_flag(&event, "__orinium_immediate_propagation_stopped") {
1177                break;
1178            }
1179            if let Err(error) = self.engine.call(
1180                listener.0,
1181                JSValue::from_object(Rc::clone(&target)),
1182                vec![JSValue::from_object(Rc::clone(&event))],
1183            ) {
1184                log::info!("JS error in {event_type} listener: {error}");
1185                first_error.get_or_insert_with(|| error.to_string());
1186            }
1187        }
1188        let stack = self.trace_error_stack(&first_error);
1189        self.end_trace(span, first_error, stack);
1190    }
1191
1192    /// Resolves a pending JavaScript fetch and runs its microtask checkpoint.
1193    pub(crate) fn resolve_fetch(&mut self, id: u64, response: JsFetchResponse) {
1194        let url = with_host_mut(self.engine.vm(), |host| host.fetch_urls.remove(&id))
1195            .flatten()
1196            .unwrap_or_default();
1197        with_host_mut(self.engine.vm(), |host| {
1198            host.fetch_signals.remove(&id);
1199        });
1200        let capability =
1201            with_host_mut(self.engine.vm(), |host| host.fetch_capabilities.remove(&id)).flatten();
1202        if let Some(capability) = capability {
1203            let span = self.begin_trace(
1204                JsTraceKind::Fetch,
1205                "fetch resolve".to_string(),
1206                format!("{url} — HTTP {} {}", response.status, response.status_text),
1207                None,
1208            );
1209            let response = make_fetch_response(response);
1210            let error = match self.engine.call(
1211                capability.resolve,
1212                JSValue::undefined(),
1213                vec![JSValue::from_object(response)],
1214            ) {
1215                Ok(_) => None,
1216                Err(err) => {
1217                    log::info!("JS error while resolving fetch: {}", err);
1218                    Some(err.to_string())
1219                }
1220            };
1221            let stack = self.trace_error_stack(&error);
1222            self.end_trace(span, error, stack);
1223            self.perform_microtask_checkpoint();
1224            return;
1225        }
1226        let xhr = with_host_mut(self.engine.vm(), |host| host.xhr_requests.remove(&id)).flatten();
1227        let Some(xhr) = xhr else { return };
1228        let span = self.begin_trace(
1229            JsTraceKind::Fetch,
1230            "XHR load".to_string(),
1231            format!("request {id}"),
1232            None,
1233        );
1234        resolve_xml_http_request(&mut self.engine, xhr, response);
1235        self.end_trace(span, None, Vec::new());
1236        self.perform_microtask_checkpoint();
1237    }
1238
1239    /// Rejects a pending JavaScript fetch and runs its microtask checkpoint.
1240    pub(crate) fn reject_fetch(&mut self, id: u64, reason: String) {
1241        let url = with_host_mut(self.engine.vm(), |host| host.fetch_urls.remove(&id))
1242            .flatten()
1243            .unwrap_or_default();
1244        with_host_mut(self.engine.vm(), |host| {
1245            host.fetch_signals.remove(&id);
1246        });
1247        let capability =
1248            with_host_mut(self.engine.vm(), |host| host.fetch_capabilities.remove(&id)).flatten();
1249        if let Some(capability) = capability {
1250            let span = self.begin_trace(
1251                JsTraceKind::Fetch,
1252                "fetch reject".to_string(),
1253                format!("{url} — {reason}"),
1254                None,
1255            );
1256            let reason_value = JSValue::from_string(reason.clone());
1257            let error =
1258                match self
1259                    .engine
1260                    .call(capability.reject, JSValue::undefined(), vec![reason_value])
1261                {
1262                    Ok(_) => None,
1263                    Err(err) => {
1264                        log::info!("JS error while rejecting fetch: {}", err);
1265                        Some(err.to_string())
1266                    }
1267                };
1268            let stack = self.trace_error_stack(&error);
1269            self.end_trace(span, Some(error.unwrap_or(reason)), stack);
1270            self.perform_microtask_checkpoint();
1271            return;
1272        }
1273        let xhr = with_host_mut(self.engine.vm(), |host| host.xhr_requests.remove(&id)).flatten();
1274        let Some(xhr) = xhr else { return };
1275        let span = self.begin_trace(
1276            JsTraceKind::Fetch,
1277            "XHR error".to_string(),
1278            format!("request {id}"),
1279            None,
1280        );
1281        let handler = xhr.borrow().get("onerror");
1282        let error = if is_callable(&handler) {
1283            match self.engine.call(
1284                handler,
1285                JSValue::from_object(Rc::clone(&xhr)),
1286                vec![JSValue::from_string(reason)],
1287            ) {
1288                Ok(_) => None,
1289                Err(err) => Some(err.to_string()),
1290            }
1291        } else {
1292            None
1293        };
1294        self.end_trace(span, error, Vec::new());
1295        self.perform_microtask_checkpoint();
1296    }
1297
1298    /// Serializes the current mirror DOM for the browser side.
1299    ///
1300    /// Nodes exposed to scripts keep their stable `dom_id` so the UI thread can
1301    /// rebuild the tree and re-register references on commit.
1302    pub fn snapshot(&self) -> DomSnapshot {
1303        let Some((root, dom_ids)) = with_host(self.engine.vm(), |host| {
1304            let mut reverse = HashMap::new();
1305            for (dom_id, weak) in &host.refs {
1306                if let Some(node) = weak.upgrade() {
1307                    reverse.insert(Rc::as_ptr(&node) as usize, *dom_id);
1308                }
1309            }
1310            (Rc::clone(&host.dom.root), reverse)
1311        }) else {
1312            return DomSnapshot::default();
1313        };
1314        DomSnapshot::from_mirror(&root, &dom_ids)
1315    }
1316
1317    /// Serializes every iframe's content document into movable snapshots so the
1318    /// layout can render each `<iframe>`'s content nested inside the host page.
1319    pub fn snapshot_iframe_documents(&self) -> Vec<IframeContentSnapshot> {
1320        let Some(docs) = with_host(self.engine.vm(), |host| {
1321            let mut out: Vec<(u64, Rc<DomTree>)> = Vec::new();
1322            for (dom_id, doc) in &host.iframe_documents {
1323                out.push((*dom_id, Rc::clone(&doc.borrow().tree)));
1324            }
1325            Some(out)
1326        })
1327        .flatten() else {
1328            return Vec::new();
1329        };
1330        let mut snapshots = Vec::with_capacity(docs.len());
1331        for (dom_id, tree) in docs {
1332            let (content, _refs) = DomSnapshot::from_tree(&tree.root);
1333            snapshots.push(IframeContentSnapshot {
1334                iframe_dom_id: dom_id,
1335                content,
1336            });
1337        }
1338        snapshots
1339    }
1340
1341    /// Replaces the mirror DOM with a snapshot produced by the browser side.
1342    ///
1343    /// The mirror is rebuilt from `snapshot` and node references are re-registered
1344    /// so existing JS element handles keep resolving. JS-created (detached) nodes
1345    /// are preserved; they are not part of the committed DOM but may still be
1346    /// referenced from scripts.
1347    pub fn apply_dom(&mut self, snapshot: &DomSnapshot) {
1348        let (tree, dom_ids) = snapshot.into_tree();
1349        with_host_mut(self.engine.vm(), |host| {
1350            host.dom = Rc::new(tree);
1351            let mut refs = std::mem::take(&mut host.refs);
1352            host.dom.traverse(|node| {
1353                if let Some(&dom_id) = dom_ids.get(&(Rc::as_ptr(node) as usize)) {
1354                    refs.insert(dom_id, Rc::downgrade(node));
1355                }
1356            });
1357            for (&dom_id, node) in &host.detached_nodes {
1358                refs.entry(dom_id).or_insert_with(|| Rc::downgrade(node));
1359            }
1360            host.refs = refs;
1361            if let Some(max_id) = dom_ids.values().max() {
1362                host.next_id = host.next_id.max(*max_id);
1363            }
1364            Self::register_window_event_handlers(host);
1365        });
1366    }
1367
1368    /// Hoists the document's event-handler content attributes that the HTML
1369    /// spec maps onto the Window (`<body onload="...">`) into the host's
1370    /// handler registry, so dispatching never searches the DOM again.
1371    ///
1372    /// Called once when a DOM is bound to the runtime (initial parse and every
1373    /// `apply_dom`), mirroring how the parser activates a `load` handler on the
1374    /// `<body>` element as it is parsed.
1375    fn register_window_event_handlers(host: &mut JsHost) {
1376        for node in host.dom.find_all(|node| node.tag_name() == Some("body")) {
1377            if let Some(code) = node.borrow().value.get_attr("onload") {
1378                host.window_inline_event_handlers
1379                    .insert("load".to_string(), code.to_string());
1380            }
1381        }
1382    }
1383
1384    /// Dispatches a click to the handlers registered on the element with the
1385    /// given JS-facing dom id. Returns whether at least one handler ran.
1386    pub fn click_dom_id(&mut self, dom_id: u64) -> bool {
1387        let Some(node) = with_host(self.engine.vm(), |host| {
1388            host.refs.get(&dom_id).and_then(|w| w.upgrade())
1389        })
1390        .flatten() else {
1391            return false;
1392        };
1393        self.click(&node)
1394    }
1395
1396    /// Dispatches a click to the handlers registered on `node`.
1397    ///
1398    /// Both the `onclick` property and `addEventListener("click", ...)` are
1399    /// supported. The event bubbles through exposed ancestor elements so
1400    /// delegated listeners such as React's root listener receive it.
1401    /// Returns whether at least one handler ran.
1402    pub fn click(&mut self, node: &NodeRef<HtmlNodeType>) -> bool {
1403        let mut path = Vec::new();
1404        let mut current = Some(Rc::clone(node));
1405        while let Some(node) = current {
1406            current = node.borrow().parent();
1407            if let Some(object) = expose_node(self.engine.vm(), node).and_then(|v| v.as_object()) {
1408                path.push(object);
1409            }
1410        }
1411        let Some(target) = path.first().cloned() else {
1412            return false;
1413        };
1414
1415        let span = self.begin_trace(
1416            JsTraceKind::Event,
1417            "click".to_string(),
1418            format!("{} target(s)", path.len()),
1419            None,
1420        );
1421        let mut first_error = None;
1422        let mut ran_handler = false;
1423        for current_target in path {
1424            let Some(dom_id) = node_dom_id(&JSValue::from_object(Rc::clone(&current_target)))
1425            else {
1426                continue;
1427            };
1428            let onclick = current_target.borrow().get("onclick");
1429            let listeners = with_host(self.engine.vm(), |host| {
1430                host.element_event_listeners
1431                    .get(&dom_id)
1432                    .and_then(|events| events.get("click"))
1433                    .cloned()
1434                    .unwrap_or_default()
1435            })
1436            .unwrap_or_default();
1437            let has_onclick = is_callable(&onclick);
1438            if !has_onclick && listeners.is_empty() {
1439                continue;
1440            }
1441
1442            ran_handler = true;
1443            let event = make_event("click", Rc::clone(&target), Rc::clone(&current_target));
1444            if has_onclick
1445                && let Err(err) = self.engine.call(
1446                    onclick,
1447                    JSValue::from_object(Rc::clone(&current_target)),
1448                    vec![JSValue::from_object(Rc::clone(&event))],
1449                )
1450            {
1451                log::info!("JS error in onclick: {}", err);
1452                first_error.get_or_insert_with(|| err.to_string());
1453            }
1454            if !event_flag(&event, "__orinium_immediate_propagation_stopped") {
1455                for listener in listeners {
1456                    if let Err(err) = self.engine.call(
1457                        listener.0,
1458                        JSValue::from_object(Rc::clone(&current_target)),
1459                        vec![JSValue::from_object(Rc::clone(&event))],
1460                    ) {
1461                        log::info!("JS error in click listener: {}", err);
1462                        first_error.get_or_insert_with(|| err.to_string());
1463                    }
1464                    if event_flag(&event, "__orinium_immediate_propagation_stopped") {
1465                        break;
1466                    }
1467                }
1468            }
1469            if event_flag(&event, "cancelBubble") {
1470                break;
1471            }
1472        }
1473        let stack = self.trace_error_stack(&first_error);
1474        self.end_trace(span, first_error, stack);
1475        if ran_handler {
1476            self.perform_microtask_checkpoint();
1477        }
1478        ran_handler
1479    }
1480
1481    /// Dispatches a `scroll` event to the handlers registered on the element
1482    /// with the given JS-facing dom id. Returns whether at least one handler
1483    /// ran.
1484    pub fn scroll_dom_id(&mut self, dom_id: u64) -> bool {
1485        let Some(node) = with_host(self.engine.vm(), |host| {
1486            host.refs.get(&dom_id).and_then(|w| w.upgrade())
1487        })
1488        .flatten() else {
1489            return false;
1490        };
1491        self.scroll(&node)
1492    }
1493
1494    /// Dispatches a `scroll` event that bubbles through `node`'s exposed
1495    /// ancestors.
1496    ///
1497    /// Both the `onscroll` property and `addEventListener("scroll", ...)` are
1498    /// supported. Returns whether at least one handler ran.
1499    pub fn scroll(&mut self, node: &NodeRef<HtmlNodeType>) -> bool {
1500        let mut path = Vec::new();
1501        let mut current = Some(Rc::clone(node));
1502        while let Some(node) = current {
1503            current = node.borrow().parent();
1504            if let Some(object) = expose_node(self.engine.vm(), node).and_then(|v| v.as_object()) {
1505                path.push(object);
1506            }
1507        }
1508        let Some(target) = path.first().cloned() else {
1509            return false;
1510        };
1511
1512        let span = self.begin_trace(
1513            JsTraceKind::Event,
1514            "scroll".to_string(),
1515            format!("{} target(s)", path.len()),
1516            None,
1517        );
1518        let mut first_error = None;
1519        let mut ran_handler = false;
1520        for current_target in path {
1521            let Some(dom_id) = node_dom_id(&JSValue::from_object(Rc::clone(&current_target)))
1522            else {
1523                continue;
1524            };
1525            let onscroll = current_target.borrow().get("onscroll");
1526            let listeners = with_host(self.engine.vm(), |host| {
1527                host.element_event_listeners
1528                    .get(&dom_id)
1529                    .and_then(|events| events.get("scroll"))
1530                    .cloned()
1531                    .unwrap_or_default()
1532            })
1533            .unwrap_or_default();
1534            let has_onscroll = is_callable(&onscroll);
1535            if !has_onscroll && listeners.is_empty() {
1536                continue;
1537            }
1538
1539            ran_handler = true;
1540            let event = make_event("scroll", Rc::clone(&target), Rc::clone(&current_target));
1541            if has_onscroll
1542                && let Err(err) = self.engine.call(
1543                    onscroll,
1544                    JSValue::from_object(Rc::clone(&current_target)),
1545                    vec![JSValue::from_object(Rc::clone(&event))],
1546                )
1547            {
1548                log::info!("JS error in onscroll: {}", err);
1549                first_error.get_or_insert_with(|| err.to_string());
1550            }
1551            if !event_flag(&event, "__orinium_immediate_propagation_stopped") {
1552                for listener in listeners {
1553                    if let Err(err) = self.engine.call(
1554                        listener.0,
1555                        JSValue::from_object(Rc::clone(&current_target)),
1556                        vec![JSValue::from_object(Rc::clone(&event))],
1557                    ) {
1558                        log::info!("JS error in scroll listener: {}", err);
1559                        first_error.get_or_insert_with(|| err.to_string());
1560                    }
1561                    if event_flag(&event, "__orinium_immediate_propagation_stopped") {
1562                        break;
1563                    }
1564                }
1565            }
1566            if event_flag(&event, "cancelBubble") {
1567                break;
1568            }
1569        }
1570        let stack = self.trace_error_stack(&first_error);
1571        self.end_trace(span, first_error, stack);
1572        if ran_handler {
1573            self.perform_microtask_checkpoint();
1574        }
1575        ran_handler
1576    }
1577    /// Drains queued microtasks; exposed for tests and harnesses that call
1578    /// native APIs directly between script evaluations.
1579    #[cfg(test)]
1580    pub(crate) fn perform_microtask_checkpoint_public(&mut self) {
1581        self.perform_microtask_checkpoint();
1582    }
1583
1584    /// Drains queued microtasks/jobs without requiring a timer to be due.
1585    ///
1586    /// Framework schedulers (React's `MessageChannel`-based pump) queue their
1587    /// work as jobs rather than timers, so harnesses need this to settle them.
1588    pub fn drain_microtasks(&mut self) {
1589        self.perform_microtask_checkpoint();
1590    }
1591
1592    /// Drains queued microtasks in FIFO order, including jobs queued by jobs.
1593    fn perform_microtask_checkpoint(&mut self) {
1594        let span = if self.engine.has_pending_jobs() {
1595            self.begin_trace(
1596                JsTraceKind::Microtask,
1597                "microtasks".to_string(),
1598                "checkpoint".to_string(),
1599                None,
1600            )
1601        } else {
1602            None
1603        };
1604        let mut first_error = None;
1605        const MAX_ROUNDS: usize = 50;
1606        let mut rounds = 0;
1607        loop {
1608            rounds += 1;
1609            if rounds > MAX_ROUNDS {
1610                break;
1611            }
1612            match self.engine.run_jobs() {
1613                Ok(()) => break,
1614                Err(err) => {
1615                    if let JSError::Thrown(value) = &err
1616                        && let Some(object) = value.as_object()
1617                    {
1618                        let object = object.borrow();
1619                        let details = object
1620                            .keys()
1621                            .into_iter()
1622                            .map(|key| format!("{key}={}", object.get(&key).to_console_string()))
1623                            .collect::<Vec<_>>()
1624                            .join(", ");
1625                        log::info!("JS error in microtask: {} ({details})", err);
1626                    } else {
1627                        log::info!("JS error in microtask: {}", err);
1628                    }
1629                    first_error.get_or_insert_with(|| err.to_string());
1630                }
1631            }
1632        }
1633        let stack = self.trace_error_stack(&first_error);
1634        self.end_trace(span, first_error, stack);
1635    }
1636}
1637
1638// ---------------------------------------------------------------------------
1639// Tests
1640// ---------------------------------------------------------------------------
1641
1642#[cfg(test)]
1643mod tests {
1644    use super::*;
1645    use crate::engine::html::Parser as HtmlParser;
1646    use crate::test_support::assert_send;
1647    use web_apis::dom::element::{HTML_NAMESPACE, SVG_NAMESPACE, style_property_name};
1648
1649    fn runtime_from_html(html: &str) -> (JsRuntime, Rc<DomTree>) {
1650        let mut parser = HtmlParser::new(html);
1651        let dom = Rc::new(parser.parse());
1652        let runtime = JsRuntime::new(Rc::clone(&dom));
1653        (runtime, dom)
1654    }
1655
1656    /// A [`JsTaskResult`] travels JS thread → UI thread through a channel, so
1657    /// it must be `Send`. `JsFetchRequest` used to carry an `AbortSignal`
1658    /// (`Rc<RefCell<JSObject>>`) purely to keep it alive, which silently made
1659    /// every result `!Send`; the signal now stays in `JsHost::fetch_signals`.
1660    /// Asserted so that field cannot come back unnoticed.
1661    #[test]
1662    fn task_result_is_send() {
1663        assert_send::<crate::engine::js::processor::JsTaskResult>();
1664        assert_send::<JsFetchRequest>();
1665        assert_send::<JsFetchResponse>();
1666    }
1667
1668    #[test]
1669    fn misc_globals_behave_like_the_platform() {
1670        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1671        runtime.run_script(
1672            r##"
1673            const results = [];
1674
1675            // Object.defineProperties
1676            const target = {a: 1};
1677            Object.defineProperties(target, {
1678                b: {value: 2, enumerable: true},
1679                c: {get: function () { return 3; }, enumerable: true},
1680            });
1681            results.push("defineProperties:" + (target.a + target.b + target.c));
1682
1683            // Reflect
1684            results.push("reflect.has:" + Reflect.has(target, "b"));
1685            results.push("reflect.get:" + Reflect.get(target, "a"));
1686            results.push("reflect.ownKeys:" + Reflect.ownKeys(target).length);
1687            results.push(
1688                "reflect.apply:" + Reflect.apply(function (x) { return x * 2; }, null, [21])
1689            );
1690
1691            // crypto.getRandomValues
1692            const bytes = new Uint8Array(8);
1693            crypto.getRandomValues(bytes);
1694            const filled = Array.prototype.every.call(bytes, function (b) { return b >= 0; });
1695            results.push("crypto:" + filled + ":" + bytes.length + ":" + bytes.byteLength);
1696            results.push("uuid:" + /^[0-9a-f-]{36}$/.test(crypto.randomUUID()));
1697
1698            // FormData
1699            const form = new FormData();
1700            form.append("q", "hello world");
1701            form.append("lang", "ja");
1702            form.append("q", "second");
1703            results.push("form.get:" + form.get("q"));
1704            results.push("form.getAll:" + form.getAll("q").join("|"));
1705            results.push("form.has:" + form.has("lang"));
1706            form.set("lang", "en");
1707            results.push("form.afterSet:" + form.get("lang"));
1708            form.delete("lang");
1709            results.push("form.afterDelete:" + form.get("lang"));
1710            results.push("form.toString:" + form.toString());
1711
1712            // DOMParser
1713            const doc = new DOMParser().parseFromString(
1714                '<html><body><p id="x">hi</p><span class="k">there</span></body></html>',
1715                "text/html"
1716            );
1717            results.push("parser.body:" + (doc.body !== null));
1718            results.push("parser.qs:" + doc.querySelector("#x").textContent);
1719            results.push("parser.tag:" + doc.getElementsByTagName("span").length);
1720
1721            document.getElementById("result").setAttribute("data-misc", results.join(";"));
1722            "##,
1723            None,
1724        );
1725
1726        let node = dom.get_element_by_id("result").unwrap();
1727        let data = node
1728            .borrow()
1729            .value
1730            .get_attr("data-misc")
1731            .unwrap_or_default()
1732            .to_string();
1733        let parts: Vec<&str> = data.split(';').collect();
1734
1735        assert_eq!(parts[0], "defineProperties:6");
1736        assert_eq!(parts[1], "reflect.has:true");
1737        assert_eq!(parts[2], "reflect.get:1");
1738        assert_eq!(parts[3], "reflect.ownKeys:3");
1739        assert_eq!(parts[4], "reflect.apply:42");
1740        assert!(parts[5].starts_with("crypto:true:8:8"), "got {}", parts[5]);
1741        assert_eq!(parts[6], "uuid:true");
1742        assert_eq!(parts[7], "form.get:hello world");
1743        assert_eq!(parts[8], "form.getAll:hello world|second");
1744        assert_eq!(parts[9], "form.has:true");
1745        assert_eq!(parts[10], "form.afterSet:en");
1746        assert_eq!(parts[11], "form.afterDelete:null");
1747        assert_eq!(parts[12], "form.toString:q=hello+world&q=second");
1748        assert_eq!(parts[13], "parser.body:true");
1749        assert_eq!(parts[14], "parser.qs:hi");
1750        assert_eq!(parts[15], "parser.tag:1");
1751    }
1752
1753    #[test]
1754    fn event_target_and_window_constructors_supported() {
1755        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1756        runtime.run_script(
1757            r##"
1758            const results = [];
1759
1760            // window.EventTarget / window.Window / window.XMLHttpRequest
1761            // must be constructors with an object `.prototype` for the
1762            // webcomponents-sd `ab()` feature detection to pass.
1763            results.push("et:" + typeof window.EventTarget);
1764            results.push("et.proto:" + (typeof EventTarget.prototype === "object"));
1765            results.push("win:" + (typeof Window === "function" || typeof Window === "object"));
1766            results.push("win.proto:" + (typeof Window.prototype === "object"));
1767            results.push("xhr:" + (typeof XMLHttpRequest));
1768            results.push("xhr.proto:" + (typeof XMLHttpRequest.prototype === "object"));
1769
1770            // Mimic wc-sd's E(): getOwnPropertyDescriptor on each prototype.
1771            function e(a, b) {
1772                b = b === undefined ? [] : b;
1773                const out = [];
1774                for (let c = 0; c < b.length; c++) {
1775                    const d = b[c];
1776                    out.push(!!Object.getOwnPropertyDescriptor(a, d));
1777                }
1778                return out.join(",");
1779            }
1780            const keys = ["dispatchEvent", "addEventListener", "removeEventListener"];
1781            results.push("et." + e(window.EventTarget.prototype, keys));
1782            results.push("win." + e(window.Window.prototype, keys));
1783            results.push("node." + e(Node.prototype, keys));
1784            results.push("xr." + e(XMLHttpRequest.prototype, keys));
1785
1786            // Node instances are EventTargets too.
1787            results.push("node.methods:" + typeof Node.prototype.addEventListener + ":" + typeof Node.prototype.dispatchEvent);
1788
1789            // wc-sd ab() also runs E() over Element/DocumentFragment/Document.
1790            results.push("el:" + (typeof Element.prototype));
1791            results.push("df:" + (typeof DocumentFragment.prototype));
1792            results.push("doc:" + (typeof Document.prototype));
1793
1794            // new EventTarget() instances inherit the methods from the prototype
1795            const t = new EventTarget();
1796            results.push("inst:" + typeof t.addEventListener + ":" + typeof t.dispatchEvent);
1797
1798            document.getElementById("result").setAttribute("data-eventtarget", results.join(";"));
1799            "##, None);
1800
1801        let node = dom.get_element_by_id("result").unwrap();
1802        let data = node
1803            .borrow()
1804            .value
1805            .get_attr("data-eventtarget")
1806            .unwrap_or_default()
1807            .to_string();
1808        assert_eq!(
1809            data,
1810            "et:object;et.proto:true;win:true;win.proto:true;xhr:object;xhr.proto:true;et.true,true,true;win.true,true,true;node.true,true,true;xr.true,true,true;node.methods:function:function;el:object;df:object;doc:object;inst:function:function",
1811            "got: {data}"
1812        );
1813    }
1814
1815    #[test]
1816    fn template_content_exposes_parsed_fragment() {
1817        let (mut runtime, dom) = runtime_from_html(
1818            r#"<template id="t"><div id="x">hi</div></template><div id="result"></div>"#,
1819        );
1820        runtime.run_script(
1821            r##"
1822            const results = [];
1823            results.push("tmpl:" + typeof window.HTMLTemplateElement);
1824            results.push("tmpl.proto:" + (typeof HTMLTemplateElement.prototype === "object"));
1825            const t = document.getElementById("t");
1826            const content = t.content;
1827            results.push("content:" + (content instanceof DocumentFragment));
1828            results.push("content.firstChild:" + (content.firstChild && content.firstChild.tagName === "DIV"));
1829            const x = content.querySelector("#x");
1830            results.push("x:" + (x && x.textContent === "hi"));
1831            if (x) {
1832                x.setAttribute("data-stamped", "yes");
1833            }
1834            document.getElementById("result").setAttribute("data-template", results.join(";"));
1835            "##, None);
1836        let node = dom.get_element_by_id("result").unwrap();
1837        let data = node
1838            .borrow()
1839            .value
1840            .get_attr("data-template")
1841            .unwrap_or_default()
1842            .to_string();
1843        assert_eq!(
1844            data, "tmpl:function;tmpl.proto:true;content:true;content.firstChild:true;x:true",
1845            "got: {data}"
1846        );
1847    }
1848
1849    #[test]
1850    fn template_inertness_and_lazy_content() {
1851        let (mut runtime, dom) = runtime_from_html(
1852            r#"<template id="t"><div id="inner" class="c">hi</div><span>two</span></template><div id="result"></div>"#,
1853        );
1854        runtime.run_script(
1855            r##"
1856            const results = [];
1857            const t = document.getElementById("t");
1858
1859            // Instance-of checks against the real constructors.
1860            results.push("inst:" + (t instanceof HTMLTemplateElement));
1861            results.push("elem:" + (t instanceof Element) + ":" + (t instanceof HTMLElement));
1862
1863            // `content` exposes the parsed children as a DocumentFragment and
1864            // is live: repeated accesses return the same fragment.
1865            const content = t.content;
1866            results.push("content:" + (content instanceof DocumentFragment));
1867            results.push("live:" + (t.content === content));
1868            results.push("count:" + content.childNodes.length);
1869            const inner = content.querySelector("#inner");
1870            results.push("inner:" + (inner && inner.textContent === "hi"));
1871            results.push("span:" + content.querySelectorAll("span").length);
1872
1873            // Inertness: template contents are unreachable from document-wide
1874            // queries (matching browsers) but mutations inside `content` work.
1875            results.push("qs:" + (document.querySelector("#inner") === null));
1876            results.push("id:" + (document.getElementById("inner") === null));
1877            results.push("btn:" + document.getElementsByTagName("span").length);
1878            results.push("cls:" + document.getElementsByClassName("c").length);
1879            inner.setAttribute("data-stamped", "yes");
1880            results.push("stamp:" + (content.querySelector("#inner").getAttribute("data-stamped") === "yes"));
1881
1882            // `document.createElement("template")` gets a lazily created,
1883            // usable, stable `content` fragment.
1884            const t2 = document.createElement("template");
1885            results.push("lazy:" + (t2.content instanceof DocumentFragment));
1886            t2.content.appendChild(document.createElement("b"));
1887            results.push("append:" + t2.content.childNodes.length);
1888            results.push("stable:" + (t2.content === t2.content));
1889
1890            document.getElementById("result").setAttribute("data-template", results.join(";"));
1891            "##, None);
1892        let node = dom.get_element_by_id("result").unwrap();
1893        let data = node
1894            .borrow()
1895            .value
1896            .get_attr("data-template")
1897            .unwrap_or_default()
1898            .to_string();
1899        assert_eq!(
1900            data,
1901            "inst:true;elem:true:true;content:true;live:true;count:2;inner:true;span:1;qs:true;id:true;btn:0;cls:0;stamp:true;lazy:true;append:1;stable:true",
1902            "got: {data}"
1903        );
1904    }
1905
1906    #[test]
1907    fn template_content_instantiation_via_import_node_and_clone_node() {
1908        let (mut runtime, dom) = runtime_from_html(
1909            r#"<div id="host"></div><ul id="list"></ul>\
1910               <template id="row"><li class="row"><b class="name"></b></li></template>\
1911               <template id="nested"><div><span>deep</span><template><i>inner</i></template></div></template>\
1912               <div id="result"></div>"#,
1913        );
1914        runtime.run_script(
1915            r##"
1916            const results = [];
1917            const host = document.getElementById("host");
1918
1919            // importNode(template.content, true) deep-clones into a detached
1920            // fragment without touching the original template.
1921            const t = document.getElementById("row");
1922            const frag = document.importNode(t.content, true);
1923            results.push("frag:" + (frag instanceof DocumentFragment) + ":" + frag.childNodes.length);
1924            results.push("orig-intact:" + (t.content.querySelectorAll("li").length === 1));
1925
1926            // Appending the fragment instantiates the cloned rows into the
1927            // document, where document-wide queries can now reach them.
1928            host.appendChild(frag);
1929            results.push("appended:" + host.querySelectorAll("li.row").length);
1930            results.push("doc-visible:" + document.querySelectorAll("li.row").length);
1931            results.push("deep:" + (host.querySelector("li.row b.name") !== null));
1932
1933            // Instantiated copies are independent of the template: mutations
1934            // on the clone never leak back into `template.content`.
1935            host.querySelector("b.name").textContent = "cloned!";
1936            results.push("independent:" + (t.content.querySelector("b.name").textContent === ""));
1937
1938            // cloneNode(true) on `content` works as an alternative.
1939            const frag2 = t.content.cloneNode(true);
1940            results.push("clone-frag:" + (frag2 instanceof DocumentFragment) + ":" + frag2.querySelectorAll("li").length);
1941
1942            // Repeated instantiation yields fresh copies each time.
1943            const frag3 = document.importNode(t.content, true);
1944            document.getElementById("list").appendChild(frag3);
1945            results.push("repeat:" + document.querySelectorAll("li.row").length);
1946
1947            // Nested templates: importing the outer content keeps the inner
1948            // `<template>` inert — its content stays inside its own fragment.
1949            const nested = document.getElementById("nested");
1950            const nfrag = document.importNode(nested.content, true);
1951            host.appendChild(nfrag);
1952            results.push("nested-span:" + (host.querySelector("span") !== null));
1953            const innerTemplate = host.querySelector("template");
1954            results.push("nested-tmpl:" + (innerTemplate !== null) + ":" + (innerTemplate && innerTemplate.content.querySelector("i") !== null));
1955
1956            // Shallow import clones the fragment but none of its children.
1957            const shallow = document.importNode(t.content, false);
1958            results.push("shallow:" + (shallow instanceof DocumentFragment) + ":" + shallow.childNodes.length);
1959
1960            // `importNode(node)` defaults deep to true (per spec).
1961            const def = document.importNode(t.content);
1962            results.push("default-deep:" + def.querySelectorAll("li").length);
1963
1964            document.getElementById("result").setAttribute("data-r", results.join(";"));
1965            "##, None);
1966        let node = dom.get_element_by_id("result").unwrap();
1967        let data = node
1968            .borrow()
1969            .value
1970            .get_attr("data-r")
1971            .unwrap_or_default()
1972            .to_string();
1973        assert_eq!(
1974            data,
1975            "frag:true:1;orig-intact:true;appended:1;doc-visible:1;deep:true;independent:true;clone-frag:true:1;repeat:2;nested-span:true;nested-tmpl:true:true;shallow:true:0;default-deep:1",
1976            "got: {data}"
1977        );
1978    }
1979
1980    #[test]
1981    fn implementation_create_document_is_independent() {
1982        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
1983        runtime.run_script(
1984            r##"
1985            const results = [];
1986
1987            // A created document is a distinct object with its own tree.
1988            const d = document.implementation.createDocument(null, null, null);
1989            results.push("distinct:" + (d !== document));
1990            results.push("nodeType:" + d.nodeType);
1991            results.push("children:" + d.childNodes.length);
1992
1993            // Appending to the document makes the element its documentElement.
1994            const root = d.createElement("root");
1995            d.appendChild(root);
1996            results.push("documentElement:" + d.documentElement.tagName);
1997            results.push("sameNode:" + (d.documentElement === root));
1998
1999            // ownerDocument is correct for a node that is in no tree yet, and
2000            // for one that has been inserted.
2001            const e1 = d.createElement("test");
2002            results.push("detachedOwner:" + (e1.ownerDocument === d));
2003            d.documentElement.appendChild(e1);
2004            results.push("insertedOwner:" + (e1.parentNode.ownerDocument === d));
2005            results.push("ownerType:" + e1.parentNode.ownerDocument.nodeType);
2006
2007            // The top-level document is untouched by any of the above.
2008            results.push("mainDocElement:" + document.documentElement.tagName);
2009            results.push("mainUntouched:" + (document.getElementById("result") !== null));
2010
2011            // createDocument with a qualified name builds an XHTML-ish document.
2012            const x = document.implementation.createDocument(
2013                "http://www.w3.org/1999/xhtml", "html", null);
2014            results.push("xhtmlRoot:" + x.documentElement.tagName);
2015            results.push("xhtmlTitle:" + JSON.stringify(x.title));
2016            x.documentElement.appendChild(
2017                x.createElementNS("http://www.w3.org/1999/xhtml", "head"));
2018            x.documentElement.appendChild(
2019                x.createElementNS("http://www.w3.org/1999/xhtml", "body"));
2020            const title = x.createElementNS("http://www.w3.org/1999/xhtml", "title");
2021            x.documentElement.firstChild.appendChild(title);
2022            results.push("xhtmlBody:" + x.body.tagName);
2023            results.push("xhtmlForms:" + x.forms.length);
2024            title.textContent = "Sparrow";
2025            results.push("xhtmlTitleSet:" + x.title);
2026
2027            document.getElementById("result").setAttribute("data-r", results.join(";"));
2028            "##,
2029            None,
2030        );
2031        let node = dom.get_element_by_id("result").unwrap();
2032        let data = node
2033            .borrow()
2034            .value
2035            .get_attr("data-r")
2036            .unwrap_or_default()
2037            .to_string();
2038        assert_eq!(
2039            data,
2040            "distinct:true;nodeType:9;children:0;documentElement:ROOT;sameNode:true;\
2041             detachedOwner:true;insertedOwner:true;ownerType:9;mainDocElement:HTML;\
2042             mainUntouched:true;xhtmlRoot:HTML;xhtmlTitle:\"\";xhtmlBody:BODY;xhtmlForms:0;\
2043             xhtmlTitleSet:Sparrow",
2044            "got: {data}"
2045        );
2046    }
2047
2048    #[test]
2049    fn node_interface_and_html_document_helpers() {
2050        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2051        runtime.run_script(
2052            r##"
2053            const results = [];
2054
2055            // Node is now a constructor with a prototype
2056            results.push("node.typeof:" + typeof Node);
2057            results.push("node.prototype:" + (typeof Node.prototype === "object"));
2058
2059            // wc-sd gate: firstChild must be a configurable accessor with a getter
2060            const d = Object.getOwnPropertyDescriptor(Node.prototype, "firstChild");
2061            results.push("fc.desc:" + (!!d && d.configurable === true && typeof d.get === "function"));
2062            results.push("fc.enum:" + d.enumerable + ":" + d.writable);
2063
2064            // getRootNode gate for ShadyDOM detection
2065            results.push("rootNode:" + typeof Node.prototype.getRootNode);
2066            results.push("rootIsDoc:" + (document.documentElement.getRootNode() === document));
2067
2068            // textNode rides Node.prototype accessors? (engine: instance props win)
2069
2070            // document.implementation.createHTMLDocument
2071            const doc = document.implementation.createHTMLDocument("inert");
2072            results.push("htmlDoc.doctype:" + (doc !== null));
2073            results.push("htmlDoc.ns:" + doc.namespaceURI);
2074            results.push("htmlDoc.html:" + doc.documentElement.tagName);
2075            results.push("htmlDoc.body:" + doc.body.tagName);
2076            results.push("htmlDoc.title:" + doc.title);
2077            const p = doc.createElement("p");
2078            p.innerHTML = "<b>hi</b>";
2079            results.push("htmlDoc.el:" + p.tagName + ":" + p.firstChild.tagName);
2080
2081            // createEvent('CustomEvent') + initCustomEvent
2082            const ev = document.createEvent("CustomEvent");
2083            results.push("ce.has:" + typeof ev.initCustomEvent);
2084            ev.initCustomEvent("yt:run", true, false, {x: 1});
2085            results.push("ce.prop:" + ev.type + ":" + ev.bubbles + ":" + ev.cancelable + ":" + ev.detail.x);
2086
2087            document.getElementById("result").setAttribute("data-node", results.join(";"));
2088            "##, None);
2089
2090        let node = dom.get_element_by_id("result").unwrap();
2091        let data = node
2092            .borrow()
2093            .value
2094            .get_attr("data-node")
2095            .unwrap_or_default()
2096            .to_string();
2097        let parts: Vec<&str> = data.split(';').collect();
2098
2099        assert_eq!(parts[0], "node.typeof:object");
2100        assert_eq!(parts[1], "node.prototype:true");
2101        assert_eq!(parts[2], "fc.desc:true");
2102        assert_eq!(parts[3], "fc.enum:false:undefined");
2103        assert_eq!(parts[4], "rootNode:function");
2104        assert_eq!(parts[5], "rootIsDoc:true");
2105        assert_eq!(parts[6], "htmlDoc.doctype:true");
2106        assert_eq!(parts[7], "htmlDoc.ns:http://www.w3.org/1999/xhtml");
2107        assert_eq!(parts[8], "htmlDoc.html:HTML");
2108        assert_eq!(parts[9], "htmlDoc.body:BODY");
2109        assert_eq!(parts[10], "htmlDoc.title:inert");
2110        assert_eq!(parts[11], "htmlDoc.el:P:B");
2111        assert_eq!(parts[12], "ce.has:function");
2112        assert_eq!(parts[13], "ce.prop:yt:run:true:false:1");
2113    }
2114
2115    #[test]
2116    fn set_text_content_mutates_dom_and_marks_dirty() {
2117        let (mut runtime, dom) = runtime_from_html(r#"<div id="hello">before</div>"#);
2118        runtime.run_script(
2119            r#"const el = document.getElementById("hello"); el.textContent = "hello from js";"#,
2120            None,
2121        );
2122        assert!(runtime.needs_redraw());
2123        assert!(runtime.take_needs_redraw());
2124
2125        let node = dom.get_element_by_id("hello").unwrap();
2126        assert_eq!(DomTree::inner_text(&node), "hello from js");
2127    }
2128
2129    #[test]
2130    fn viewport_dimensions_follow_browser_resizes() {
2131        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2132        runtime.set_viewport(1280.0, 720.0);
2133        runtime.run_script(
2134            r#"document.getElementById("result").setAttribute("data-size", innerWidth + ":" + innerHeight);"#, None);
2135        assert_eq!(
2136            dom.get_element_by_id("result")
2137                .unwrap()
2138                .borrow()
2139                .value
2140                .get_attr("data-size"),
2141            Some("1280:720")
2142        );
2143        runtime.run_script(
2144            r#"document.getElementById("result").setAttribute("data-root", document.body.clientWidth + ":" + document.body.clientHeight + ":" + outerWidth + ":" + outerHeight);"#, None);
2145        assert_eq!(
2146            dom.get_element_by_id("result")
2147                .unwrap()
2148                .borrow()
2149                .value
2150                .get_attr("data-root"),
2151            Some("1280:720:1280:720")
2152        );
2153    }
2154
2155    #[test]
2156    fn set_attribute_mutates_dom() {
2157        let (mut runtime, dom) = runtime_from_html(r#"<div id="hello"></div>"#);
2158        runtime.run_script(
2159            r#"const el = document.getElementById("hello"); el.setAttribute("data-run", "1");"#,
2160            None,
2161        );
2162
2163        let node = dom.get_element_by_id("hello").unwrap();
2164        assert_eq!(node.borrow().value.get_attr("data-run"), Some("1"));
2165    }
2166
2167    #[test]
2168    fn match_media_evaluates_width_and_type_queries_against_viewport() {
2169        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2170        runtime.set_viewport(1000.0, 600.0);
2171        runtime.run_script(
2172            r#"
2173            const results = [];
2174            results.push(matchMedia("(max-width: 1000px)").matches);
2175            results.push(matchMedia("(max-width: 999px)").matches);
2176            results.push(matchMedia("(min-width: 1000px)").matches);
2177            results.push(matchMedia("screen").matches);
2178            results.push(matchMedia("print").matches);
2179            results.push(matchMedia("all").matches);
2180            results.push(matchMedia("").matches);
2181            results.push(matchMedia("screen and (min-width: 500px) and (max-width: 1200px)").matches);
2182            results.push(matchMedia("only screen and (max-width: 600px)").matches);
2183            document.getElementById("result").setAttribute("data-media", results.join(","));
2184            "#, None);
2185        let data = dom
2186            .get_element_by_id("result")
2187            .unwrap()
2188            .borrow()
2189            .value
2190            .get_attr("data-media")
2191            .unwrap_or_default()
2192            .to_string();
2193        assert_eq!(
2194            data, "true,false,true,true,false,true,false,true,false",
2195            "got: {data}"
2196        );
2197    }
2198
2199    #[test]
2200    fn computed_style_reflects_committed_layout_values() {
2201        let (mut runtime, dom) =
2202            runtime_from_html(r#"<div id="xm" style="color: red"></div><div id="result"></div>"#);
2203        runtime.run_script(
2204            r#"document.getElementById("result").setAttribute("data-id", String(document.getElementById("xm").__orinium_dom_id));"#, None);
2205        let dom_id: u64 = dom
2206            .get_element_by_id("result")
2207            .unwrap()
2208            .borrow()
2209            .value
2210            .get_attr("data-id")
2211            .unwrap_or_default()
2212            .parse()
2213            .expect("JS-facing dom id");
2214        runtime.set_computed_styles_by_dom_id(std::collections::HashMap::from([(
2215            dom_id,
2216            vec![
2217                ("display".to_string(), "flex".to_string()),
2218                ("opacity".to_string(), "0.5".to_string()),
2219                ("background-color".to_string(), "#3366ff".to_string()),
2220                ("width".to_string(), "240px".to_string()),
2221            ],
2222        )]));
2223        runtime.run_script(
2224            r#"
2225            const xm = document.getElementById("xm");
2226            const cs = getComputedStyle(xm);
2227            const results = [];
2228            results.push(cs.opacity);
2229            results.push(cs.getPropertyValue("display"));
2230            results.push(cs.width);
2231            results.push(cs.backgroundColor);
2232            results.push(cs.getPropertyValue("color"));
2233            results.push(String(cs));
2234            results.push(cs.cssText);
2235            results.push(String(xm.style));
2236            results.push(String(cs) === cs.cssText);
2237            document.getElementById("result").setAttribute("data-computed", results.join("|"));
2238            "#,
2239            None,
2240        );
2241        let data = dom
2242            .get_element_by_id("result")
2243            .unwrap()
2244            .borrow()
2245            .value
2246            .get_attr("data-computed")
2247            .unwrap_or_default()
2248            .to_string();
2249        assert_eq!(
2250            data,
2251            "0.5|flex|240px|#3366ff|red|display: flex; opacity: 0.5; background-color: #3366ff; width: 240px;|display: flex; opacity: 0.5; background-color: #3366ff; width: 240px;|color: red|true",
2252            "got: {data}"
2253        );
2254    }
2255
2256    #[test]
2257    fn computed_style_writes_are_ignored_and_resolve_back_to_inline_when_unlaid_out() {
2258        let (mut runtime, dom) = runtime_from_html(
2259            r#"<div id="xm" style="margin-left: 3px"></div><div id="result"></div>"#,
2260        );
2261        runtime.run_script(
2262            r#"
2263            const cs = getComputedStyle(document.getElementById("xm"));
2264            cs.opacity = "0.99";
2265            cs.cssText = "color: blue";
2266            cs.setProperty("color", "green");
2267            cs.removeProperty("color");
2268            document.getElementById("result").setAttribute(
2269                "data-inline",
2270                cs.opacity + ":" + cs.getPropertyValue("margin-left") + ":" + cs.getPropertyValue("color")
2271            );
2272            "#, None);
2273        let data = dom
2274            .get_element_by_id("result")
2275            .unwrap()
2276            .borrow()
2277            .value
2278            .get_attr("data-inline")
2279            .unwrap_or_default()
2280            .to_string();
2281        // The element never received a layout snapshot, so computed falls back
2282        // to the inline `style` attribute; writes must be no-ops.
2283        assert_eq!(data, ":3px:", "got: {data}");
2284    }
2285
2286    #[test]
2287    fn browser_environment_exposes_react_bootstrap_apis() {
2288        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2289        runtime.set_document_url("https://scratch.mit.edu/projects/editor/?tutorial=1#stage");
2290        runtime.run_script(
2291            r#"
2292            localStorage.setItem("answer", 42);
2293            sessionStorage.setItem("temporary", "yes");
2294            const query = matchMedia("(prefers-color-scheme: dark)");
2295            query.addEventListener("change", function () {});
2296            const event = new CustomEvent("ready", {detail: "loaded", cancelable: true});
2297            event.preventDefault();
2298            const frame = requestAnimationFrame(function () {});
2299            cancelAnimationFrame(frame);
2300            document.getElementById("result").setAttribute(
2301                "data-environment",
2302                navigator.language + ":" + localStorage.getItem("answer") + ":" +
2303                    localStorage.length + ":" + query.matches + ":" + (frame > 0) + ":" +
2304                    location.pathname + ":" + event.detail + ":" + event.defaultPrevented + ":" +
2305                    (typeof Intl === "object") + ":" + ("Locale" in Intl) + ":" +
2306                    Intl.getCanonicalLocales(["EN-us", "ja"])[0] + ":" +
2307                    new Intl.Locale("und-x-private").toString()
2308            );
2309            "#,
2310            None,
2311        );
2312
2313        let node = dom.get_element_by_id("result").unwrap();
2314        assert_eq!(
2315            node.borrow().value.get_attr("data-environment"),
2316            Some(
2317                "en-US:42:1:false:true:/projects/editor/:loaded:true:true:true:en-US:und-x-private"
2318            )
2319        );
2320    }
2321
2322    #[test]
2323    fn browser_origin_exposed_consistently_across_window_location_and_document() {
2324        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2325        runtime.set_page_origin("https://example.test");
2326        runtime.run_script(
2327            r#"document.getElementById("result").setAttribute(
2328                "data-origins",
2329                window.origin + ":" + location.origin + ":" + document.origin
2330            );"#,
2331            None,
2332        );
2333
2334        let node = dom.get_element_by_id("result").unwrap();
2335        assert_eq!(
2336            node.borrow().value.get_attr("data-origins"),
2337            Some("https://example.test:https://example.test:https://example.test")
2338        );
2339    }
2340
2341    #[test]
2342    fn opaque_page_reports_null_origin_everywhere() {
2343        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2344        runtime.set_page_origin("null");
2345        runtime.run_script(
2346            r#"document.getElementById("result").setAttribute(
2347                "data-origins",
2348                window.origin + ":" + location.origin + ":" + document.origin
2349            );"#,
2350            None,
2351        );
2352
2353        let node = dom.get_element_by_id("result").unwrap();
2354        assert_eq!(
2355            node.borrow().value.get_attr("data-origins"),
2356            Some("null:null:null")
2357        );
2358    }
2359
2360    #[test]
2361    fn browser_language_preferences_follow_the_host() {
2362        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2363        runtime.set_language("ja-JP");
2364        runtime.run_script(
2365            r#"
2366            document.getElementById("result").setAttribute(
2367                "data-languages",
2368                navigator.language + ":" + navigator.languages[0] + ":" +
2369                    navigator.languages[1] + ":" + navigator.languages[2]
2370            );
2371            "#,
2372            None,
2373        );
2374
2375        assert_eq!(
2376            dom.get_element_by_id("result")
2377                .unwrap()
2378                .borrow()
2379                .value
2380                .get_attr("data-languages"),
2381            Some("ja-JP:ja-JP:ja:en-US")
2382        );
2383    }
2384
2385    #[test]
2386    fn document_cookie_is_a_string_and_supports_assignment_and_expiry() {
2387        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2388        runtime.run_script(
2389            r#"
2390            const result = document.getElementById("result");
2391            result.setAttribute("data-empty-cookie", typeof document.cookie + ":" + document.cookie);
2392            document.cookie = "scratchlanguage=ja; Path=/";
2393            result.setAttribute("data-cookie", document.cookie);
2394            document.cookie = "scratchlanguage=; Max-Age=0; Path=/";
2395            result.setAttribute("data-expired-cookie", document.cookie);
2396            "#, None);
2397
2398        let result = dom.get_element_by_id("result").unwrap();
2399        let result = result.borrow();
2400        assert_eq!(result.value.get_attr("data-empty-cookie"), Some("string:"));
2401        assert_eq!(
2402            result.value.get_attr("data-cookie"),
2403            Some("scratchlanguage=ja")
2404        );
2405        assert_eq!(result.value.get_attr("data-expired-cookie"), Some(""));
2406    }
2407
2408    #[test]
2409    fn url_apis_resolve_assets_and_manage_query_parameters() {
2410        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2411        runtime.run_script(
2412            r#"
2413            const asset = new URL("../assets/stage.svg?locale=ja#costume", "https://scratch.mit.edu/projects/editor/");
2414            const params = new URLSearchParams("project=123&mode=editor");
2415            params.set("mode", "fullscreen");
2416            params.append("cloud", "on");
2417            params.delete("project");
2418            document.getElementById("result").setAttribute(
2419                "data-url",
2420                asset.origin + ":" + asset.pathname + ":" + asset.searchParams.get("locale") +
2421                    ":" + params.has("cloud") + ":" + params.toString()
2422            );
2423            "#, None);
2424
2425        let result = dom.get_element_by_id("result").unwrap();
2426        assert_eq!(
2427            result.borrow().value.get_attr("data-url"),
2428            Some(
2429                "https://scratch.mit.edu:/projects/assets/stage.svg:ja:true:mode=fullscreen&cloud=on"
2430            )
2431        );
2432    }
2433
2434    #[test]
2435    fn encoding_apis_round_trip_utf8_and_base64() {
2436        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
2437        runtime.run_script(
2438            r#"
2439            const encoder = new TextEncoder();
2440            const decoder = new TextDecoder("utf-8");
2441            const bytes = encoder.encode("Scratch 日本");
2442            document.getElementById("result").setAttribute(
2443                "data-encoding",
2444                decoder.decode(bytes) + ":" + bytes.length + ":" + atob(btoa("Scratch")) +
2445                    ":" + decodeURIComponent(encodeURIComponent("日本 語"))
2446            );
2447            "#,
2448            None,
2449        );
2450
2451        let result = dom.get_element_by_id("result").unwrap();
2452        assert_eq!(
2453            result.borrow().value.get_attr("data-encoding"),
2454            Some("Scratch 日本:14:Scratch:日本 語")
2455        );
2456    }
2457
2458    #[test]
2459    fn layout_measurement_and_resize_observer_report_element_size() {
2460        let (mut runtime, dom) = runtime_from_html(
2461            r#"<canvas id="stage" width="480" height="360"></canvas><div id="result"></div>"#,
2462        );
2463        runtime.run_script(
2464            r#"
2465            const stage = document.getElementById("stage");
2466            const rect = stage.getBoundingClientRect();
2467            const observer = new ResizeObserver(function (entries) {
2468                const observed = entries[0].contentRect;
2469                document.getElementById("result").setAttribute(
2470                    "data-resize",
2471                    observed.width + ":" + observed.height
2472                );
2473            });
2474            observer.observe(stage);
2475            document.getElementById("result").setAttribute(
2476                "data-measure",
2477                rect.width + ":" + rect.height + ":" + stage.clientWidth + ":" + stage.offsetHeight
2478            );
2479            "#,
2480            None,
2481        );
2482
2483        let result = dom.get_element_by_id("result").unwrap();
2484        let result = result.borrow();
2485        assert_eq!(
2486            result.value.get_attr("data-measure"),
2487            Some("480:360:480:360")
2488        );
2489        assert_eq!(result.value.get_attr("data-resize"), Some("480:360"));
2490    }
2491
2492    #[test]
2493    fn layout_offsets_have_a_numeric_fallback() {
2494        let (mut runtime, dom) = runtime_from_html(
2495            r#"<div id="list" class="carousel slick-list"></div><div id="plain"></div><div id="result"></div>"#,
2496        );
2497        runtime.run_script(
2498            r#"
2499            document.getElementById("result").setAttribute(
2500                "data-widths",
2501                document.getElementById("list").offsetWidth + ":" +
2502                    document.getElementById("plain").offsetWidth + ":" +
2503                    document.getElementById("list").offsetLeft + ":" +
2504                    document.getElementById("list").offsetTop
2505            );
2506            "#,
2507            None,
2508        );
2509
2510        assert_eq!(
2511            dom.get_element_by_id("result")
2512                .unwrap()
2513                .borrow()
2514                .value
2515                .get_attr("data-widths"),
2516            Some("800:0:0:0")
2517        );
2518    }
2519
2520    #[test]
2521    fn dom_measurements_prefer_committed_layout_geometry() {
2522        let (mut runtime, dom) = runtime_from_html(
2523            r#"<div id="target" style="width: 1px; height: 2px"></div><div id="result"></div>"#,
2524        );
2525        let target = dom.get_element_by_id("target").unwrap();
2526        runtime.set_layout_metrics(HashMap::from([(
2527            Rc::as_ptr(&target) as usize,
2528            JsLayoutMetrics {
2529                offset_left: 12.0,
2530                offset_top: 34.0,
2531                offset_width: 222.0,
2532                offset_height: 111.0,
2533                client_width: 218.0,
2534                client_height: 107.0,
2535                rect_left: 42.5,
2536                rect_top: 64.25,
2537                rect_width: 222.0,
2538                rect_height: 111.0,
2539            },
2540        )]));
2541        runtime.run_script(
2542            r#"
2543            const target = document.getElementById("target");
2544            const rect = target.getBoundingClientRect();
2545            document.getElementById("result").setAttribute(
2546                "data-layout",
2547                target.offsetLeft + ":" + target.offsetTop + ":" +
2548                    target.offsetWidth + ":" + target.offsetHeight + ":" +
2549                    target.clientWidth + ":" + target.clientHeight + ":" +
2550                    rect.left + ":" + rect.top + ":" + rect.right + ":" + rect.bottom
2551            );
2552            "#,
2553            None,
2554        );
2555
2556        assert_eq!(
2557            dom.get_element_by_id("result")
2558                .unwrap()
2559                .borrow()
2560                .value
2561                .get_attr("data-layout"),
2562            Some("12:34:222:111:218:107:42.5:64.25:264.5:175.25")
2563        );
2564    }
2565
2566    #[test]
2567    fn inserting_script_element_queues_dynamic_resource_load() {
2568        let (mut runtime, _dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
2569        runtime.run_script(
2570            r#"
2571            const script = document.createElement("script");
2572            script.src = "/static/chunks/editor.js";
2573            script.async = true;
2574            document.head.appendChild(script);
2575            "#,
2576            None,
2577        );
2578
2579        let requests = runtime.take_dynamic_script_requests();
2580        assert_eq!(requests.len(), 1);
2581        assert!(requests[0].node_id > 0);
2582        match &requests[0].source {
2583            JsDynamicScriptSource::External(source) => {
2584                assert_eq!(source, "/static/chunks/editor.js")
2585            }
2586            JsDynamicScriptSource::Inline(_) => panic!("expected an external script request"),
2587        }
2588    }
2589
2590    #[test]
2591    fn inserting_stylesheet_link_queues_dynamic_resource_load() {
2592        let (mut runtime, _dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
2593        runtime.run_script(
2594            r#"
2595            const link = document.createElement("link");
2596            link.rel = "stylesheet";
2597            link.href = "/static/css/editor.css";
2598            document.head.appendChild(link);
2599            "#,
2600            None,
2601        );
2602
2603        let requests = runtime.take_dynamic_style_requests();
2604        assert_eq!(requests.len(), 1);
2605        assert!(requests[0].node_id > 0);
2606        assert_eq!(requests[0].url, "/static/css/editor.css");
2607    }
2608
2609    #[test]
2610    fn inserting_image_queues_dynamic_resource_load_once() {
2611        let (mut runtime, _dom) = runtime_from_html(r#"<html><body></body></html>"#);
2612        runtime.run_script(
2613            r#"
2614            const image = document.createElement("img");
2615            image.src = "/images/scratch-logo.svg";
2616            document.body.appendChild(image);
2617            "#,
2618            None,
2619        );
2620
2621        let requests = runtime.take_dynamic_image_requests();
2622        assert_eq!(requests.len(), 1);
2623        assert_eq!(requests[0].source, "/images/scratch-logo.svg");
2624    }
2625
2626    #[test]
2627    fn canvas_2d_context_records_visible_rectangle_commands() {
2628        let (mut runtime, dom) = runtime_from_html(r#"<canvas id="stage"></canvas>"#);
2629        runtime.run_script(
2630            r##"
2631            const canvas = document.getElementById("stage");
2632            canvas.width = 480;
2633            canvas.height = 360;
2634            const context = canvas.getContext("2d");
2635            context.fillStyle = "#ff8800";
2636            context.fillRect(10, 20, 30, 40);
2637            context.strokeStyle = "blue";
2638            context.strokeRect(0, 0, 480, 360);
2639            canvas.setAttribute("data-metrics", context.measureText("Scratch").width);
2640            "##,
2641            None,
2642        );
2643
2644        let canvas = dom.get_element_by_id("stage").unwrap();
2645        let canvas = canvas.borrow();
2646        assert_eq!(canvas.value.get_attr("width"), Some("480"));
2647        assert_eq!(canvas.value.get_attr("height"), Some("360"));
2648        assert_eq!(canvas.value.get_attr("data-metrics"), Some("42"));
2649        assert_eq!(
2650            canvas.value.get_attr("data-orinium-canvas-commands"),
2651            Some("fillRect|#ff8800|10|20|30|40\nstrokeRect|blue|0|0|480|360")
2652        );
2653    }
2654
2655    #[test]
2656    fn canvas_exposes_webgl_capability_surface() {
2657        let (mut runtime, dom) =
2658            runtime_from_html(r#"<canvas id="stage" width="480" height="360"></canvas>"#);
2659        runtime.run_script(
2660            r#"
2661            const canvas = document.getElementById("stage");
2662            const gl = canvas.getContext("webgl");
2663            const shader = gl.createShader(gl.VERTEX_SHADER);
2664            gl.shaderSource(shader, "void main() {}");
2665            gl.compileShader(shader);
2666            const program = gl.createProgram();
2667            gl.attachShader(program, shader);
2668            gl.linkProgram(program);
2669            canvas.setAttribute(
2670                "data-webgl",
2671                gl.getShaderParameter(shader, gl.COMPILE_STATUS) + ":" +
2672                    gl.getProgramParameter(program, gl.LINK_STATUS) + ":" +
2673                    gl.getParameter(gl.MAX_TEXTURE_SIZE) + ":" + gl.drawingBufferWidth
2674            );
2675            "#,
2676            None,
2677        );
2678
2679        let canvas = dom.get_element_by_id("stage").unwrap();
2680        assert_eq!(
2681            canvas.borrow().value.get_attr("data-webgl"),
2682            Some("true:true:4096:480")
2683        );
2684    }
2685
2686    #[test]
2687    fn mutation_observer_can_register_for_dom_changes() {
2688        let (mut runtime, dom) =
2689            runtime_from_html(r#"<html><body><div id="target"></div></body></html>"#);
2690        runtime.run_script(
2691            r#"
2692            const observer = new MutationObserver(function () {
2693                document.getElementById("target").setAttribute("data-observed", "yes");
2694            });
2695            observer.observe(document.documentElement, { childList: true, subtree: true });
2696            const records = observer.takeRecords();
2697            observer.disconnect();
2698            document.getElementById("target").setAttribute("data-records", records.length);
2699            "#,
2700            None,
2701        );
2702
2703        let node = dom.get_element_by_id("target").unwrap();
2704        assert_eq!(node.borrow().value.get_attr("data-records"), Some("0"));
2705        assert_eq!(node.borrow().value.get_attr("data-observed"), Some("yes"));
2706    }
2707
2708    #[test]
2709    fn element_id_is_a_live_reflected_property() {
2710        let (mut runtime, dom) = runtime_from_html(r#"<main id="root"></main>"#);
2711        runtime.run_script(
2712            r#"
2713            const child = document.createElement("button");
2714            child.id = "first";
2715            document.getElementById("root").appendChild(child);
2716            child.setAttribute("id", "second");
2717            child.setAttribute("data-current-id", child.id);
2718            "#,
2719            None,
2720        );
2721
2722        let child = dom.get_element_by_id("second").unwrap();
2723        assert_eq!(
2724            child.borrow().value.get_attr("data-current-id"),
2725            Some("second")
2726        );
2727        assert!(runtime.needs_redraw());
2728    }
2729
2730    #[test]
2731    fn form_properties_reflect_to_dom_attributes() {
2732        let (mut runtime, dom) = runtime_from_html(
2733            r#"<input id="field"><option id="option"></option><select id="select"></select>"#,
2734        );
2735        runtime.run_script(
2736            r#"
2737            const field = document.getElementById("field");
2738            field.value = "hello";
2739            field.checked = true;
2740            field.disabled = true;
2741            field.checked = false;
2742            const option = document.getElementById("option");
2743            option.selected = true;
2744            const select = document.getElementById("select");
2745            select.multiple = true;
2746            "#,
2747            None,
2748        );
2749
2750        let field = dom.get_element_by_id("field").unwrap();
2751        let field = field.borrow();
2752        assert_eq!(field.value.get_attr("value"), Some("hello"));
2753        assert_eq!(field.value.get_attr("checked"), None);
2754        assert_eq!(field.value.get_attr("disabled"), Some(""));
2755        drop(field);
2756        let option = dom.get_element_by_id("option").unwrap();
2757        assert_eq!(option.borrow().value.get_attr("selected"), Some(""));
2758        let select = dom.get_element_by_id("select").unwrap();
2759        assert_eq!(select.borrow().value.get_attr("multiple"), Some(""));
2760        assert!(runtime.needs_redraw());
2761    }
2762
2763    #[test]
2764    fn form_properties_are_accessors_on_the_element_prototype() {
2765        let (mut runtime, dom) = runtime_from_html(r#"<input id="field">"#);
2766        runtime.run_script(
2767            r#"
2768            const field = document.getElementById("field");
2769            const prototype = field.constructor.prototype;
2770            const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
2771            field.setAttribute("data-prototype", Object.getPrototypeOf(field) === prototype);
2772            field.setAttribute("data-interface", field.constructor === HTMLElement && field instanceof Element);
2773            field.setAttribute("data-own-value", field.hasOwnProperty("value"));
2774            field.setAttribute("data-accessor", typeof descriptor.get + ":" + typeof descriptor.set);
2775            descriptor.set.call(field, "tracked");
2776            field.setAttribute("data-read", descriptor.get.call(field));
2777            "#, None);
2778
2779        let field = dom.get_element_by_id("field").unwrap();
2780        let field = field.borrow();
2781        assert_eq!(field.value.get_attr("data-prototype"), Some("true"));
2782        assert_eq!(field.value.get_attr("data-interface"), Some("true"));
2783        assert_eq!(field.value.get_attr("data-own-value"), Some("false"));
2784        assert_eq!(
2785            field.value.get_attr("data-accessor"),
2786            Some("function:function")
2787        );
2788        assert_eq!(field.value.get_attr("data-read"), Some("tracked"));
2789        assert_eq!(field.value.get_attr("value"), Some("tracked"));
2790    }
2791
2792    #[test]
2793    fn exposes_document_and_node_metadata_used_by_react_dom() {
2794        let (mut runtime, dom) = runtime_from_html(
2795            r#"<html><body><main id="root"><span id="child">text</span></main></body></html>"#,
2796        );
2797        runtime.run_script(
2798            r#"
2799            const root = document.getElementById("root");
2800            const child = document.getElementById("child");
2801            child.setAttribute("data-default-view", document.defaultView === window);
2802            child.setAttribute("data-ready-before", document.readyState);
2803            child.setAttribute("data-local-name", child.localName);
2804            child.setAttribute("data-parent-element", child.parentElement === root);
2805            child.setAttribute("data-connected", child.isConnected);
2806            child.setAttribute("data-text-connected", child.firstChild.isConnected);
2807            "#,
2808            None,
2809        );
2810
2811        let child = dom.get_element_by_id("child").unwrap();
2812        let child = child.borrow();
2813        assert_eq!(child.value.get_attr("data-default-view"), Some("true"));
2814        assert_eq!(child.value.get_attr("data-ready-before"), Some("loading"));
2815        assert_eq!(child.value.get_attr("data-local-name"), Some("span"));
2816        assert_eq!(child.value.get_attr("data-parent-element"), Some("true"));
2817        assert_eq!(child.value.get_attr("data-connected"), Some("true"));
2818        assert_eq!(child.value.get_attr("data-text-connected"), Some("true"));
2819        drop(child);
2820
2821        assert!(runtime.dispatch_dom_content_loaded());
2822        runtime.run_script(
2823            r#"
2824            document.getElementById("child").setAttribute(
2825                "data-ready-after",
2826                document.readyState
2827            );
2828            "#,
2829            None,
2830        );
2831        assert_eq!(
2832            dom.get_element_by_id("child")
2833                .unwrap()
2834                .borrow()
2835                .value
2836                .get_attr("data-ready-after"),
2837            Some("complete")
2838        );
2839    }
2840
2841    #[test]
2842    fn style_declaration_mutates_inline_style() {
2843        let (mut runtime, dom) = runtime_from_html(r#"<div id="target"></div>"#);
2844        runtime.run_script(
2845            r#"
2846            const target = document.getElementById("target");
2847            target.style.backgroundColor = "red";
2848            target.style.setProperty("--accent", "blue");
2849            target.style.marginTop = "4px";
2850            target.style.removeProperty("background-color");
2851            "#,
2852            None,
2853        );
2854
2855        let node = dom.get_element_by_id("target").unwrap();
2856        assert_eq!(
2857            node.borrow().value.get_attr("style"),
2858            Some("--accent: blue; margin-top: 4px;")
2859        );
2860        assert!(runtime.needs_redraw());
2861    }
2862
2863    #[test]
2864    fn inner_html_parses_replaces_and_serializes_children() {
2865        let (mut runtime, dom) = runtime_from_html(
2866            r#"<div id="target"><em id="old">old</em></div><div id="result"></div>"#,
2867        );
2868        runtime.run_script(
2869            r#"
2870            const target = document.getElementById("target");
2871            const old = document.getElementById("old");
2872            target.innerHTML = '<span id="child" data-label="a&b">hello</span><br>';
2873            old.setAttribute("data-detached", "yes");
2874            document.getElementById("result").setAttribute("data-html", target.innerHTML);
2875            "#,
2876            None,
2877        );
2878
2879        let target = dom.get_element_by_id("target").unwrap();
2880        assert_eq!(target.borrow().children().len(), 2);
2881        assert!(dom.get_element_by_id("old").is_none());
2882        assert_eq!(
2883            dom.get_element_by_id("result")
2884                .unwrap()
2885                .borrow()
2886                .value
2887                .get_attr("data-html"),
2888            Some("<span id=\"child\" data-label=\"a&amp;b\">hello</span><br>")
2889        );
2890        assert!(runtime.needs_redraw());
2891    }
2892
2893    #[test]
2894    fn style_property_names_follow_cssom_spelling() {
2895        assert_eq!(style_property_name("backgroundColor"), "background-color");
2896        assert_eq!(style_property_name("msTransition"), "-ms-transition");
2897        assert_eq!(style_property_name("WebkitTransform"), "-webkit-transform");
2898        assert_eq!(style_property_name("cssFloat"), "float");
2899        assert_eq!(style_property_name("--accent"), "--accent");
2900    }
2901
2902    #[test]
2903    fn get_attribute_reads_dom() {
2904        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello" data-x="v"></div>"#);
2905        runtime.run_script(
2906            r#"const el = document.getElementById("hello"); if (el.getAttribute("data-x") !== "v") { throw new Error("mismatch"); }"#, None);
2907    }
2908
2909    #[test]
2910    fn missing_id_returns_null() {
2911        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello"></div>"#);
2912        runtime.run_script(
2913            r#"const el = document.getElementById("missing"); if (el !== null) { throw new Error("expected null"); }"#, None);
2914    }
2915
2916    #[test]
2917    fn console_log_does_not_panic() {
2918        let (mut runtime, _dom) = runtime_from_html(r#"<html></html>"#);
2919        runtime.run_script(
2920            r#"console.log("a", 1, undefined); console.warn("w"); console.error("e");"#,
2921            None,
2922        );
2923    }
2924
2925    #[test]
2926    fn syntax_error_is_logged_not_panicked() {
2927        let (mut runtime, _dom) = runtime_from_html(r#"<html></html>"#);
2928        runtime.run_script("this is not valid js ((", None);
2929    }
2930
2931    #[test]
2932    fn accessor_reads_text_content() {
2933        let (mut runtime, _dom) = runtime_from_html(r#"<div id="hello">hi</div>"#);
2934        runtime.run_script(
2935            r#"const el = document.getElementById("hello"); if (el.textContent !== "hi") { throw new Error("mismatch"); }"#, None);
2936    }
2937
2938    #[test]
2939    fn click_invokes_onclick_and_mutates_dom() {
2940        let (mut runtime, dom) =
2941            runtime_from_html(r#"<button id="b">click me</button><p id="result">not clicked</p>"#);
2942        runtime.run_script(
2943            r#"
2944            const button = document.getElementById("b");
2945            const result = document.getElementById("result");
2946            button.onclick = function () {
2947                result.textContent = "clicked!";
2948                result.setAttribute("data-clicked", "true");
2949            };
2950            "#,
2951            None,
2952        );
2953
2954        let button = dom.get_element_by_id("b").unwrap();
2955        assert!(runtime.click(&button));
2956        assert!(runtime.needs_redraw());
2957        assert!(runtime.take_needs_redraw());
2958
2959        let result = dom.get_element_by_id("result").unwrap();
2960        assert_eq!(DomTree::inner_text(&result), "clicked!");
2961        assert_eq!(result.borrow().value.get_attr("data-clicked"), Some("true"));
2962    }
2963
2964    #[test]
2965    fn click_without_handler_is_noop() {
2966        let (mut runtime, dom) = runtime_from_html(r#"<div id="x"></div>"#);
2967        runtime.run_script(r#"document.getElementById("x");"#, None);
2968        let node = dom.get_element_by_id("x").unwrap();
2969        assert!(!runtime.click(&node));
2970        assert!(!runtime.needs_redraw());
2971    }
2972
2973    #[test]
2974    fn click_invokes_element_event_listeners_in_registration_order() {
2975        let (mut runtime, dom) =
2976            runtime_from_html(r#"<button id="button">click</button><div id="result"></div>"#);
2977        runtime.run_script(
2978            r#"
2979            const button = document.getElementById("button");
2980            const result = document.getElementById("result");
2981            let order = "";
2982            button.addEventListener("click", function (event) {
2983                order = order + "a";
2984                result.setAttribute("data-event-type", event.type);
2985            });
2986            button.addEventListener("click", function () {
2987                order = order + "b";
2988                result.setAttribute("data-order", order);
2989            });
2990            "#,
2991            None,
2992        );
2993
2994        let button = dom.get_element_by_id("button").unwrap();
2995        assert!(runtime.click(&button));
2996
2997        let result = dom.get_element_by_id("result").unwrap();
2998        assert_eq!(result.borrow().value.get_attr("data-order"), Some("ab"));
2999        assert_eq!(
3000            result.borrow().value.get_attr("data-event-type"),
3001            Some("click")
3002        );
3003        assert!(runtime.needs_redraw());
3004    }
3005
3006    #[test]
3007    fn event_listeners_are_deduplicated_and_removable() {
3008        let (mut runtime, dom) =
3009            runtime_from_html(r#"<button id="button">click</button><div id="result"></div>"#);
3010        runtime.run_script(
3011            r#"
3012            const button = document.getElementById("button");
3013            const result = document.getElementById("result");
3014            function listener() {
3015                const count = result.getAttribute("data-count");
3016                result.setAttribute("data-count", count === null ? 1 : Number(count) + 1);
3017            }
3018            button.addEventListener("click", listener);
3019            button.addEventListener("click", listener);
3020            "#,
3021            None,
3022        );
3023
3024        let button = dom.get_element_by_id("button").unwrap();
3025        assert!(runtime.click(&button));
3026        let result = dom.get_element_by_id("result").unwrap();
3027        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
3028
3029        runtime.run_script(
3030            r#"
3031            button.removeEventListener("click", listener);
3032            window.addEventListener("test", listener);
3033            window.removeEventListener("test", listener);
3034            "#,
3035            None,
3036        );
3037        assert!(!runtime.click(&button));
3038        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
3039    }
3040
3041    #[test]
3042    fn document_event_listeners_can_be_removed() {
3043        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3044        runtime.run_script(
3045            r#"
3046            const result = document.getElementById("result");
3047            function listener() { result.setAttribute("data-ran", "yes"); }
3048            document.addEventListener("DOMContentLoaded", listener);
3049            document.removeEventListener("DOMContentLoaded", listener);
3050            "#,
3051            None,
3052        );
3053
3054        assert!(runtime.dispatch_dom_content_loaded());
3055        let result = dom.get_element_by_id("result").unwrap();
3056        assert_eq!(result.borrow().value.get_attr("data-ran"), None);
3057    }
3058
3059    #[test]
3060    fn click_bubbles_to_delegated_ancestor_listeners() {
3061        let (mut runtime, dom) = runtime_from_html(
3062            r#"<main id="root"><button id="button">click</button></main><div id="result"></div>"#,
3063        );
3064        runtime.run_script(
3065            r#"
3066            const root = document.getElementById("root");
3067            const result = document.getElementById("result");
3068            root.addEventListener("click", function (event) {
3069                result.setAttribute("data-target", event.target.id);
3070                result.setAttribute("data-current", event.currentTarget.id);
3071                result.setAttribute("data-this", this.id);
3072                event.preventDefault();
3073                result.setAttribute("data-prevented", event.defaultPrevented);
3074            });
3075            "#,
3076            None,
3077        );
3078
3079        let button = dom.get_element_by_id("button").unwrap();
3080        assert!(runtime.click(&button));
3081        let result = dom.get_element_by_id("result").unwrap();
3082        let result = result.borrow();
3083        assert_eq!(result.value.get_attr("data-target"), Some("button"));
3084        assert_eq!(result.value.get_attr("data-current"), Some("root"));
3085        assert_eq!(result.value.get_attr("data-this"), Some("root"));
3086        assert_eq!(result.value.get_attr("data-prevented"), Some("true"));
3087    }
3088
3089    #[test]
3090    fn click_propagation_can_be_stopped() {
3091        let (mut runtime, dom) = runtime_from_html(
3092            r#"<main id="root"><button id="button">click</button></main><div id="result"></div>"#,
3093        );
3094        runtime.run_script(
3095            r#"
3096            const root = document.getElementById("root");
3097            const button = document.getElementById("button");
3098            const result = document.getElementById("result");
3099            button.addEventListener("click", function (event) {
3100                result.setAttribute("data-child", "ran");
3101                event.stopPropagation();
3102            });
3103            root.addEventListener("click", function () {
3104                result.setAttribute("data-root", "ran");
3105            });
3106            "#,
3107            None,
3108        );
3109
3110        let button = dom.get_element_by_id("button").unwrap();
3111        assert!(runtime.click(&button));
3112        let result = dom.get_element_by_id("result").unwrap();
3113        let result = result.borrow();
3114        assert_eq!(result.value.get_attr("data-child"), Some("ran"));
3115        assert_eq!(result.value.get_attr("data-root"), None);
3116    }
3117
3118    #[test]
3119    fn click_does_not_invoke_other_event_types() {
3120        let (mut runtime, dom) = runtime_from_html(r#"<button id="button">click</button>"#);
3121        runtime.run_script(
3122            r#"
3123            const button = document.getElementById("button");
3124            button.addEventListener("mouseover", function () {
3125                button.setAttribute("data-ran", "yes");
3126            });
3127            "#,
3128            None,
3129        );
3130
3131        let button = dom.get_element_by_id("button").unwrap();
3132        assert!(!runtime.click(&button));
3133        assert_eq!(button.borrow().value.get_attr("data-ran"), None);
3134    }
3135
3136    #[test]
3137    fn scroll_invokes_onscroll_and_mutates_dom() {
3138        let (mut runtime, dom) =
3139            runtime_from_html(r#"<div id="s">scroll</div><p id="result">not scrolled</p>"#);
3140        runtime.run_script(
3141            r#"
3142            const s = document.getElementById("s");
3143            const result = document.getElementById("result");
3144            window.__scrolls = 0;
3145            s.onscroll = function () {
3146                window.__scrolls = (window.__scrolls || 0) + 1;
3147                result.textContent = "scrolled!";
3148            };
3149            "#,
3150            None,
3151        );
3152
3153        let s = dom.get_element_by_id("s").unwrap();
3154        assert!(runtime.scroll(&s));
3155        let result = dom.get_element_by_id("result").unwrap();
3156        assert_eq!(DomTree::inner_text(&result), "scrolled!");
3157    }
3158
3159    #[test]
3160    fn scroll_without_handler_is_noop() {
3161        let (mut runtime, dom) = runtime_from_html(r#"<div id="x"></div>"#);
3162        runtime.run_script(r#"document.getElementById("x");"#, None);
3163        let node = dom.get_element_by_id("x").unwrap();
3164        assert!(!runtime.scroll(&node));
3165    }
3166
3167    #[test]
3168    fn scroll_invokes_element_listeners_in_registration_order() {
3169        let (mut runtime, dom) =
3170            runtime_from_html(r#"<div id="s">scroll</div><div id="result"></div>"#);
3171        runtime.run_script(
3172            r#"
3173            const s = document.getElementById("s");
3174            const result = document.getElementById("result");
3175            let order = "";
3176            s.addEventListener("scroll", function (event) {
3177                order = order + "a";
3178                result.setAttribute("data-event-type", event.type);
3179            });
3180            s.addEventListener("scroll", function () {
3181                order = order + "b";
3182                result.setAttribute("data-order", order);
3183            });
3184            "#,
3185            None,
3186        );
3187
3188        let s = dom.get_element_by_id("s").unwrap();
3189        assert!(runtime.scroll(&s));
3190
3191        let result = dom.get_element_by_id("result").unwrap();
3192        assert_eq!(result.borrow().value.get_attr("data-order"), Some("ab"));
3193        assert_eq!(
3194            result.borrow().value.get_attr("data-event-type"),
3195            Some("scroll")
3196        );
3197    }
3198
3199    #[test]
3200    fn scroll_bubbles_to_delegated_ancestor_listeners() {
3201        let (mut runtime, dom) = runtime_from_html(
3202            r#"<main id="root"><div id="s">scroll</div></main><div id="result"></div>"#,
3203        );
3204        runtime.run_script(
3205            r#"
3206            const root = document.getElementById("root");
3207            const result = document.getElementById("result");
3208            root.addEventListener("scroll", function (event) {
3209                result.setAttribute("data-target", event.target.id);
3210            });
3211            "#,
3212            None,
3213        );
3214
3215        let s = dom.get_element_by_id("s").unwrap();
3216        assert!(runtime.scroll(&s));
3217
3218        let result = dom.get_element_by_id("result").unwrap();
3219        assert_eq!(result.borrow().value.get_attr("data-target"), Some("s"));
3220    }
3221
3222    #[test]
3223    fn scroll_dom_id_dispatches_to_the_named_element() {
3224        let (mut runtime, dom) = runtime_from_html(r#"<div id="s">scroll</div>"#);
3225        runtime.run_script(
3226            r#"
3227            const s = document.getElementById("s");
3228            s.addEventListener("scroll", function () {
3229                s.setAttribute("data-ran", "yes");
3230            });
3231            "#,
3232            None,
3233        );
3234        let s = dom.get_element_by_id("s").unwrap();
3235        // mirror the browser's mapping: resolve the live node's hidden dom id
3236        // and route the scroll through it.
3237        let dom_id = with_host(runtime.engine.vm(), |host| {
3238            host.refs
3239                .iter()
3240                .find(|(_, weak)| {
3241                    weak.upgrade()
3242                        .is_some_and(|n| Rc::as_ptr(&n) == Rc::as_ptr(&s))
3243                })
3244                .map(|(id, _)| *id)
3245        })
3246        .flatten()
3247        .expect("element must be registered in the host refs");
3248        assert!(runtime.scroll_dom_id(dom_id));
3249        assert_eq!(s.borrow().value.get_attr("data-ran"), Some("yes"));
3250    }
3251
3252    #[test]
3253    fn scroll_does_not_invoke_other_event_types() {
3254        let (mut runtime, dom) = runtime_from_html(r#"<div id="s">scroll</div>"#);
3255        runtime.run_script(
3256            r#"
3257            const s = document.getElementById("s");
3258            s.addEventListener("mouseover", function () {
3259                s.setAttribute("data-ran", "yes");
3260            });
3261            "#,
3262            None,
3263        );
3264
3265        let s = dom.get_element_by_id("s").unwrap();
3266        assert!(!runtime.scroll(&s));
3267        assert_eq!(s.borrow().value.get_attr("data-ran"), None);
3268    }
3269
3270    #[test]
3271    fn get_element_by_id_reuses_the_same_object() {
3272        let (mut runtime, _dom) = runtime_from_html(r#"<div id="x"></div>"#);
3273        runtime.run_script(
3274            r#"
3275            const a = document.getElementById("x");
3276            const b = document.getElementById("x");
3277            a.onclick = function () {};
3278            if (a !== b) { throw new Error("expected the same object"); }
3279            "#,
3280            None,
3281        );
3282    }
3283
3284    #[test]
3285    fn dom_content_loaded_listener_runs_when_dispatched() {
3286        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3287        runtime.run_script(
3288            r#"
3289            document.addEventListener("DOMContentLoaded", function (event) {
3290                const result = document.getElementById("result");
3291                result.setAttribute("data-ready", "yes");
3292                result.setAttribute("data-event-type", event.type);
3293            });
3294            "#,
3295            None,
3296        );
3297
3298        let result = dom.get_element_by_id("result").unwrap();
3299        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
3300        assert!(runtime.dispatch_dom_content_loaded());
3301        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
3302        assert_eq!(
3303            result.borrow().value.get_attr("data-event-type"),
3304            Some("DOMContentLoaded")
3305        );
3306        assert!(runtime.needs_redraw());
3307    }
3308
3309    #[test]
3310    fn dom_content_loaded_is_dispatched_only_once() {
3311        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3312        runtime.run_script(
3313            r#"
3314            let dispatchCount = 0;
3315            document.addEventListener("DOMContentLoaded", function () {
3316                dispatchCount = dispatchCount + 1;
3317                document.getElementById("result").setAttribute("data-count", dispatchCount);
3318            });
3319            "#,
3320            None,
3321        );
3322
3323        assert!(runtime.dispatch_dom_content_loaded());
3324        assert!(!runtime.dispatch_dom_content_loaded());
3325        let result = dom.get_element_by_id("result").unwrap();
3326        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
3327    }
3328
3329    #[test]
3330    fn window_onload_runs_when_dispatched() {
3331        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3332        runtime.run_script(
3333            r#"
3334            window.onload = function (event) {
3335                const result = document.getElementById("result");
3336                result.setAttribute("data-ready", "yes");
3337                result.setAttribute("data-event-type", event.type);
3338            };
3339            "#,
3340            None,
3341        );
3342
3343        let result = dom.get_element_by_id("result").unwrap();
3344        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
3345        assert!(runtime.dispatch_window_load());
3346        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
3347        assert_eq!(
3348            result.borrow().value.get_attr("data-event-type"),
3349            Some("load")
3350        );
3351    }
3352
3353    #[test]
3354    fn window_load_listener_runs_when_dispatched_and_only_once() {
3355        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3356        runtime.run_script(
3357            r#"
3358            let dispatchCount = 0;
3359            window.addEventListener("load", function () {
3360                dispatchCount = dispatchCount + 1;
3361                document.getElementById("result").setAttribute("data-count", dispatchCount);
3362            });
3363            "#,
3364            None,
3365        );
3366
3367        assert!(runtime.dispatch_window_load());
3368        assert!(!runtime.dispatch_window_load());
3369        let result = dom.get_element_by_id("result").unwrap();
3370        assert_eq!(result.borrow().value.get_attr("data-count"), Some("1"));
3371    }
3372
3373    #[test]
3374    fn body_onload_registered_at_setup_runs_on_dispatch() {
3375        let (mut runtime, dom) = runtime_from_html(
3376            r#"<body onload="document.getElementById('result').setAttribute('data-onload', 'yes')"><div id="result"></div></body>"#,
3377        );
3378
3379        let result = dom.get_element_by_id("result").unwrap();
3380        assert_eq!(result.borrow().value.get_attr("data-onload"), None);
3381        assert!(runtime.dispatch_window_load());
3382        assert_eq!(result.borrow().value.get_attr("data-onload"), Some("yes"));
3383        // The handler is registered at setup time, not re-scanned at dispatch.
3384        assert!(!runtime.dispatch_window_load());
3385    }
3386
3387    #[test]
3388    fn document_query_selector_and_query_selector_all_expose_elements() {
3389        let (mut runtime, dom) = runtime_from_html(
3390            r#"
3391            <div id="result"></div>
3392            <main><p class="item">first</p><p class="item featured">second</p></main>
3393            "#,
3394        );
3395        runtime.run_script(
3396            r#"
3397            const featured = document.querySelector("main > p.featured");
3398            featured.setAttribute("data-selected", "yes");
3399            const items = document.querySelectorAll("p.item");
3400            const classified = document.querySelectorAll("[class]");
3401            items[0].setAttribute("data-first", "yes");
3402            items.forEach(function (item, index) {
3403                item.setAttribute("data-index", index);
3404            });
3405            document.getElementById("result").setAttribute("data-count", items.length);
3406            document.getElementById("result").setAttribute("data-class-count", classified.length);
3407            "#,
3408            None,
3409        );
3410
3411        let items = dom.get_elements_by_class_name("item");
3412        assert_eq!(items[0].borrow().value.get_attr("data-first"), Some("yes"));
3413        assert_eq!(items[0].borrow().value.get_attr("data-index"), Some("0"));
3414        assert_eq!(items[1].borrow().value.get_attr("data-index"), Some("1"));
3415        assert_eq!(
3416            items[1].borrow().value.get_attr("data-selected"),
3417            Some("yes")
3418        );
3419        let result = dom.get_element_by_id("result").unwrap();
3420        assert_eq!(result.borrow().value.get_attr("data-count"), Some("2"));
3421        assert_eq!(
3422            result.borrow().value.get_attr("data-class-count"),
3423            Some("2")
3424        );
3425    }
3426
3427    #[test]
3428    fn element_query_selectors_are_scoped_to_descendants() {
3429        let (mut runtime, dom) = runtime_from_html(
3430            r#"
3431            <section id="scope"><span class="item">one</span><span class="item">two</span></section>
3432            <span class="item" id="outside">outside</span>
3433            "#,
3434        );
3435        runtime.run_script(
3436            r##"
3437            const scope = document.querySelector("#scope");
3438            scope.querySelector(".item").setAttribute("data-first", "yes");
3439            const items = scope.querySelectorAll(".item");
3440            items[1].setAttribute("data-second", "yes");
3441            scope.setAttribute("data-count", items.length);
3442            "##,
3443            None,
3444        );
3445
3446        let scope = dom.get_element_by_id("scope").unwrap();
3447        assert_eq!(scope.borrow().value.get_attr("data-count"), Some("2"));
3448        let items = DomTree::query_selector_all_within(&scope, ".item");
3449        assert_eq!(items[0].borrow().value.get_attr("data-first"), Some("yes"));
3450        assert_eq!(items[1].borrow().value.get_attr("data-second"), Some("yes"));
3451        let outside = dom.get_element_by_id("outside").unwrap();
3452        assert_eq!(outside.borrow().value.get_attr("data-first"), None);
3453        assert_eq!(outside.borrow().value.get_attr("data-second"), None);
3454    }
3455
3456    #[test]
3457    fn react_dom_collection_and_event_apis_are_available() {
3458        let (mut runtime, dom) = runtime_from_html(
3459            r#"
3460            <main id="root">
3461                <button class="control primary">one</button>
3462                <button class="control">two</button>
3463                <span class="primary">label</span>
3464            </main>
3465            <div id="result"></div>
3466            "#,
3467        );
3468        runtime.run_script(
3469            r#"
3470            const root = document.getElementById("root");
3471            const button = root.getElementsByTagName("button")[0];
3472            let received = "no";
3473            button.addEventListener("scratch-ready", function (event) {
3474                received = event.detail + ":" + (event.target === button);
3475                event.preventDefault();
3476            });
3477            const accepted = button.dispatchEvent(new CustomEvent(
3478                "scratch-ready", {detail: "yes", cancelable: true}
3479            ));
3480            document.getElementById("result").setAttribute(
3481                "data-dom-apis",
3482                document.getElementsByTagName("button").length + ":" +
3483                    document.getElementsByClassName("control primary").length + ":" +
3484                    root.getElementsByClassName("primary").length + ":" + received + ":" + accepted
3485            );
3486            "#,
3487            None,
3488        );
3489
3490        let result = dom.get_element_by_id("result").unwrap();
3491        assert_eq!(
3492            result.borrow().value.get_attr("data-dom-apis"),
3493            Some("2:1:2:yes:true:false")
3494        );
3495    }
3496
3497    #[test]
3498    fn create_and_append_element_and_text_nodes() {
3499        let (mut runtime, dom) = runtime_from_html(r#"<ul id="list"></ul>"#);
3500        runtime.run_script(
3501            r##"
3502            const item = document.createElement("li");
3503            item.setAttribute("class", "dynamic");
3504            const text = document.createTextNode("created by JavaScript");
3505            item.appendChild(text);
3506            document.querySelector("#list").appendChild(item);
3507            "##,
3508            None,
3509        );
3510
3511        let item = dom.query_selector("li.dynamic").unwrap();
3512        assert_eq!(DomTree::inner_text(&item), "created by JavaScript");
3513        assert!(runtime.needs_redraw());
3514    }
3515
3516    #[test]
3517    fn document_head_and_element_append_insert_dynamic_styles() {
3518        let (mut runtime, dom) = runtime_from_html(r#"<html><head></head><body></body></html>"#);
3519        runtime.run_script(
3520            r#"
3521            const style = document.createElement("style");
3522            style.append("body { color: red; }");
3523            document.head.append(style);
3524            "#,
3525            None,
3526        );
3527
3528        let style = dom.query_selector("head style").unwrap();
3529        assert_eq!(DomTree::inner_text(&style), "body { color: red; }");
3530        assert!(runtime.needs_redraw());
3531    }
3532
3533    #[test]
3534    fn namespace_dom_apis_create_svg_elements_and_attributes() {
3535        let (mut runtime, dom) =
3536            runtime_from_html(r#"<main id="root"></main><div id="result"></div>"#);
3537        runtime.run_script(
3538            r##"
3539            const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3540            const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
3541            path.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", "#shape");
3542            svg.appendChild(path);
3543            document.querySelector("#root").appendChild(svg);
3544
3545            const result = document.querySelector("#result");
3546            result.setAttribute("data-svg-ns", svg.namespaceURI);
3547            result.setAttribute("data-path-ns", path.namespaceURI);
3548            result.setAttribute("data-html-ns", result.namespaceURI);
3549            "##,
3550            None,
3551        );
3552
3553        let path = dom.query_selector("path").unwrap();
3554        assert_eq!(path.borrow().value.get_attr("xlink:href"), Some("#shape"));
3555        let result = dom.get_element_by_id("result").unwrap();
3556        let result = result.borrow();
3557        assert_eq!(result.value.get_attr("data-svg-ns"), Some(SVG_NAMESPACE));
3558        assert_eq!(result.value.get_attr("data-path-ns"), Some(SVG_NAMESPACE));
3559        assert_eq!(result.value.get_attr("data-html-ns"), Some(HTML_NAMESPACE));
3560        assert!(runtime.needs_redraw());
3561    }
3562
3563    #[test]
3564    fn element_contains_checks_self_descendants_and_unrelated_nodes() {
3565        let (mut runtime, dom) = runtime_from_html(
3566            r#"<main id="root"><section id="child"><span id="nested"></span></section></main><aside id="other"></aside>"#,
3567        );
3568        runtime.run_script(
3569            r##"
3570            const root = document.querySelector("#root");
3571            const child = document.querySelector("#child");
3572            const nested = document.querySelector("#nested");
3573            const other = document.querySelector("#other");
3574            root.setAttribute("data-self", root.contains(root));
3575            root.setAttribute("data-child", root.contains(child));
3576            root.setAttribute("data-nested", root.contains(nested));
3577            root.setAttribute("data-other", root.contains(other));
3578            root.setAttribute("data-null", root.contains(null));
3579            "##,
3580            None,
3581        );
3582
3583        let root = dom.get_element_by_id("root").unwrap();
3584        let root = root.borrow();
3585        assert_eq!(root.value.get_attr("data-self"), Some("true"));
3586        assert_eq!(root.value.get_attr("data-child"), Some("true"));
3587        assert_eq!(root.value.get_attr("data-nested"), Some("true"));
3588        assert_eq!(root.value.get_attr("data-other"), Some("false"));
3589        assert_eq!(root.value.get_attr("data-null"), Some("false"));
3590    }
3591
3592    #[test]
3593    fn document_tracks_the_focused_element() {
3594        let (mut runtime, dom) = runtime_from_html(
3595            r#"<body><input id="field"><button id="other"></button><div id="result"></div></body>"#,
3596        );
3597        runtime.run_script(
3598            r##"
3599            const field = document.querySelector("#field");
3600            const other = document.querySelector("#other");
3601            const result = document.querySelector("#result");
3602            result.setAttribute("data-initial", document.activeElement === document.body);
3603            field.focus();
3604            result.setAttribute("data-field", document.activeElement === field);
3605            other.focus();
3606            result.setAttribute("data-other", document.activeElement === other);
3607            other.blur();
3608            result.setAttribute("data-blurred", document.activeElement === document.body);
3609            result.setAttribute("data-has-focus", document.hasFocus());
3610            "##,
3611            None,
3612        );
3613
3614        let result = dom.get_element_by_id("result").unwrap();
3615        let result = result.borrow();
3616        assert_eq!(result.value.get_attr("data-initial"), Some("true"));
3617        assert_eq!(result.value.get_attr("data-field"), Some("true"));
3618        assert_eq!(result.value.get_attr("data-other"), Some("true"));
3619        assert_eq!(result.value.get_attr("data-blurred"), Some("true"));
3620        assert_eq!(result.value.get_attr("data-has-focus"), Some("true"));
3621    }
3622
3623    #[test]
3624    fn react_dom_node_primitives_identify_and_reorder_nodes() {
3625        let (mut runtime, dom) =
3626            runtime_from_html(r#"<main id="root"></main><div id="result"></div>"#);
3627        runtime.run_script(
3628            r##"
3629            const root = document.querySelector("#root");
3630            const first = document.createElement("span");
3631            first.setAttribute("data-name", "first");
3632            const second = document.createElement("span");
3633            second.setAttribute("data-name", "second");
3634            const text = document.createTextNode("before");
3635            text.nodeValue = "after";
3636            second.appendChild(text);
3637            root.appendChild(first);
3638            root.insertBefore(second, first);
3639            root.removeChild(first);
3640            second.className = "react-node";
3641            second.setAttribute("data-remove", "yes");
3642            second.removeAttribute("data-remove");
3643
3644            const result = document.querySelector("#result");
3645            result.setAttribute("data-document-type", document.nodeType);
3646            result.setAttribute("data-root-type", root.nodeType);
3647            result.setAttribute("data-root-name", root.nodeName);
3648            result.setAttribute("data-owner", root.ownerDocument === document);
3649            result.setAttribute("data-first", root.firstChild.getAttribute("data-name"));
3650            result.setAttribute("data-last", root.lastChild.getAttribute("data-name"));
3651            result.setAttribute("data-count", root.childNodes.length);
3652            result.setAttribute("data-text", root.firstChild.firstChild.data);
3653            result.setAttribute("data-class", root.firstChild.className);
3654            result.setAttribute("data-removed", root.firstChild.hasAttribute("data-remove"));
3655            "##,
3656            None,
3657        );
3658
3659        let result = dom.get_element_by_id("result").unwrap();
3660        let result = result.borrow();
3661        assert_eq!(result.value.get_attr("data-document-type"), Some("9"));
3662        assert_eq!(result.value.get_attr("data-root-type"), Some("1"));
3663        assert_eq!(result.value.get_attr("data-root-name"), Some("MAIN"));
3664        assert_eq!(result.value.get_attr("data-owner"), Some("true"));
3665        assert_eq!(result.value.get_attr("data-first"), Some("second"));
3666        assert_eq!(result.value.get_attr("data-last"), Some("second"));
3667        assert_eq!(result.value.get_attr("data-count"), Some("1"));
3668        assert_eq!(result.value.get_attr("data-text"), Some("after"));
3669        assert_eq!(result.value.get_attr("data-class"), Some("react-node"));
3670        assert_eq!(result.value.get_attr("data-removed"), Some("false"));
3671
3672        let root = dom.get_element_by_id("root").unwrap();
3673        assert_eq!(root.borrow().children().len(), 1);
3674        assert_eq!(
3675            root.borrow().children()[0]
3676                .borrow()
3677                .value
3678                .get_attr("data-remove"),
3679            None
3680        );
3681    }
3682
3683    #[test]
3684    fn html_iframe_element_supports_host_instance_checks() {
3685        let (mut runtime, dom) = runtime_from_html(
3686            r#"<body><iframe id="frame"></iframe><div id="result"></div></body>"#,
3687        );
3688        runtime.run_script(
3689            r#"
3690            const frame = document.getElementById("frame");
3691            const result = document.getElementById("result");
3692            result.setAttribute("data-frame", frame instanceof HTMLIFrameElement);
3693            result.setAttribute("data-body", document.body instanceof HTMLIFrameElement);
3694            "#,
3695            None,
3696        );
3697
3698        let result = dom.get_element_by_id("result").unwrap();
3699        let result = result.borrow();
3700        assert_eq!(result.value.get_attr("data-frame"), Some("true"));
3701        assert_eq!(result.value.get_attr("data-body"), Some("false"));
3702    }
3703
3704    #[test]
3705    fn markup_declared_iframes_queue_loads_once_and_failures_are_not_retried() {
3706        let (mut runtime, dom) = runtime_from_html(
3707            r#"<html><body>
3708                <iframe src="https://example.test/frame-a.html"></iframe>
3709                <iframe src="frames/frame-b.html"></iframe>
3710                <iframe id="placeholder"></iframe>
3711            </body></html>"#,
3712        );
3713        runtime.set_document_url("https://example.test/dir/page.html");
3714
3715        // Register a stable dom id per node, as the processor's initial
3716        // `apply_dom` does, so the markup iframes are visible to the scan.
3717        let ids: HashMap<usize, u64> = {
3718            let mut ids = HashMap::new();
3719            let mut next_id = 1u64;
3720            dom.traverse(|node| {
3721                ids.insert(Rc::as_ptr(node) as usize, next_id);
3722                next_id += 1;
3723            });
3724            ids
3725        };
3726        let snapshot = DomSnapshot::from_mirror(&dom.root, &ids);
3727        runtime.apply_dom(&snapshot);
3728
3729        // The two iframes with a src are queued exactly once; the src-less
3730        // placeholder is skipped.
3731        assert_eq!(runtime.queue_markup_iframe_loads(), 2);
3732        let requests = runtime.take_iframe_fetch_requests();
3733        assert_eq!(requests.len(), 2);
3734        let by_url: HashMap<String, u64> = requests
3735            .into_iter()
3736            .map(|req| (req.url, req.dom_id))
3737            .collect();
3738        assert_eq!(
3739            by_url.len(),
3740            2,
3741            "one request per src-ified iframe, absolute or relative"
3742        );
3743        // Absolute src stays as-is; relative src resolves against the document.
3744        assert!(by_url.contains_key("https://example.test/frame-a.html"));
3745        assert!(by_url.contains_key("https://example.test/dir/frames/frame-b.html"));
3746        let frame_a = by_url["https://example.test/frame-a.html"];
3747        let frame_b = by_url["https://example.test/dir/frames/frame-b.html"];
3748
3749        // A second scan finds nothing new to queue.
3750        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
3751        assert!(runtime.take_iframe_fetch_requests().is_empty());
3752
3753        // A resolved load is not re-queued on later scans.
3754        runtime.resolve_iframe_fetch(
3755            frame_a,
3756            r#"<html><body><p>frame content</p></body></html>"#.to_string(),
3757            "https://example.test/frame-a.html".to_string(),
3758        );
3759        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
3760        assert!(runtime.take_iframe_fetch_requests().is_empty());
3761
3762        // A failed load is remembered so it is not refetched every scan.
3763        runtime.reject_iframe_fetch(frame_b);
3764        assert_eq!(runtime.queue_markup_iframe_loads(), 0);
3765        assert!(runtime.take_iframe_fetch_requests().is_empty());
3766    }
3767
3768    #[test]
3769    fn remove_detaches_node_but_keeps_it_available_for_reappend() {
3770        let (mut runtime, dom) = runtime_from_html(
3771            r#"<div id="first"><span id="moving">move</span></div><div id="second"></div>"#,
3772        );
3773        runtime.run_script(
3774            r##"
3775            const moving = document.querySelector("#moving");
3776            moving.remove();
3777            document.querySelector("#second").appendChild(moving);
3778            "##,
3779            None,
3780        );
3781
3782        let first = dom.get_element_by_id("first").unwrap();
3783        let second = dom.get_element_by_id("second").unwrap();
3784        assert!(DomTree::query_selector_within(&first, "#moving").is_none());
3785        assert!(DomTree::query_selector_within(&second, "#moving").is_some());
3786        assert!(runtime.needs_redraw());
3787    }
3788
3789    #[test]
3790    fn parent_node_and_children_expose_tree_relationships() {
3791        let (mut runtime, dom) = runtime_from_html(
3792            r#"<div id="parent">text<span id="first"></span><span id="second"></span></div>"#,
3793        );
3794        runtime.run_script(
3795            r##"
3796            const first = document.querySelector("#first");
3797            first.parentNode.setAttribute("data-parent", "yes");
3798            const children = first.parentNode.children;
3799            children[1].setAttribute("data-second", "yes");
3800            first.parentNode.setAttribute("data-child-count", children.length);
3801
3802            const text = document.createTextNode("dynamic");
3803            first.appendChild(text);
3804            text.parentNode.setAttribute("data-text-parent", "yes");
3805            "##,
3806            None,
3807        );
3808
3809        let parent = dom.get_element_by_id("parent").unwrap();
3810        assert_eq!(parent.borrow().value.get_attr("data-parent"), Some("yes"));
3811        assert_eq!(
3812            parent.borrow().value.get_attr("data-child-count"),
3813            Some("2")
3814        );
3815        let first = dom.get_element_by_id("first").unwrap();
3816        assert_eq!(
3817            first.borrow().value.get_attr("data-text-parent"),
3818            Some("yes")
3819        );
3820        let second = dom.get_element_by_id("second").unwrap();
3821        assert_eq!(second.borrow().value.get_attr("data-second"), Some("yes"));
3822    }
3823
3824    #[test]
3825    fn class_list_mutates_class_attribute_and_reports_membership() {
3826        let (mut runtime, dom) = runtime_from_html(r#"<div id="target" class="one two"></div>"#);
3827        runtime.run_script(
3828            r##"
3829            const target = document.querySelector("#target");
3830            let initial = "";
3831            for (const token of target.classList) initial += token + ",";
3832            target.setAttribute("data-initial-classes", initial);
3833            target.classList.add("two", "three");
3834            target.classList.remove("one", "missing");
3835            target.setAttribute("data-has-three", target.classList.contains("three"));
3836            target.setAttribute("data-removed-three", target.classList.toggle("three"));
3837            target.setAttribute("data-added-four", target.classList.toggle("four"));
3838            target.setAttribute("data-forced-off", target.classList.toggle("four", false));
3839            target.setAttribute("data-forced-on", target.classList.toggle("five", true));
3840            "##,
3841            None,
3842        );
3843
3844        let target = dom.get_element_by_id("target").unwrap();
3845        let target = target.borrow();
3846        assert_eq!(target.value.get_attr("class"), Some("two five"));
3847        assert_eq!(
3848            target.value.get_attr("data-initial-classes"),
3849            Some("one,two,")
3850        );
3851        assert_eq!(target.value.get_attr("data-has-three"), Some("true"));
3852        assert_eq!(target.value.get_attr("data-removed-three"), Some("false"));
3853        assert_eq!(target.value.get_attr("data-added-four"), Some("true"));
3854        assert_eq!(target.value.get_attr("data-forced-off"), Some("false"));
3855        assert_eq!(target.value.get_attr("data-forced-on"), Some("true"));
3856        assert!(runtime.needs_redraw());
3857    }
3858
3859    #[test]
3860    fn timeout_runs_once_with_additional_arguments_and_can_be_cancelled() {
3861        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3862        runtime.run_script(
3863            r##"
3864            setTimeout(function (value) {
3865                document.querySelector("#result").setAttribute("data-value", value);
3866            }, 0, "done");
3867            const cancelled = setTimeout(function () {
3868                document.querySelector("#result").setAttribute("data-cancelled", "no");
3869            }, 0);
3870            clearTimeout(cancelled);
3871            "##,
3872            None,
3873        );
3874
3875        assert!(runtime.run_due_timers());
3876        assert!(!runtime.run_due_timers());
3877        let result = dom.get_element_by_id("result").unwrap();
3878        assert_eq!(result.borrow().value.get_attr("data-value"), Some("done"));
3879        assert_eq!(result.borrow().value.get_attr("data-cancelled"), None);
3880    }
3881
3882    #[test]
3883    fn interval_can_clear_itself() {
3884        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3885        runtime.run_script(
3886            r##"
3887            const intervalId = setInterval(function () {
3888                document.querySelector("#result").setAttribute("data-ran", "once");
3889                clearInterval(intervalId);
3890            }, 0);
3891            "##,
3892            None,
3893        );
3894
3895        assert!(runtime.run_due_timers());
3896        assert!(!runtime.run_due_timers());
3897        let result = dom.get_element_by_id("result").unwrap();
3898        assert_eq!(result.borrow().value.get_attr("data-ran"), Some("once"));
3899    }
3900
3901    #[test]
3902    fn performance_now_exposes_monotonic_runtime_time() {
3903        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3904        runtime.run_script(
3905            r#"
3906            const first = performance.now();
3907            const second = performance.now();
3908            document.getElementById("result").setAttribute(
3909                "data-monotonic",
3910                typeof first === "number" && second >= first
3911            );
3912            "#,
3913            None,
3914        );
3915
3916        let result = dom.get_element_by_id("result").unwrap();
3917        assert_eq!(
3918            result.borrow().value.get_attr("data-monotonic"),
3919            Some("true")
3920        );
3921    }
3922
3923    #[test]
3924    fn microtasks_run_in_fifo_order_after_script_evaluation() {
3925        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3926        runtime.run_script(
3927            r##"
3928            const result = document.querySelector("#result");
3929            queueMicrotask(function () {
3930                result.setAttribute("data-order", result.getAttribute("data-order") + "-first");
3931                queueMicrotask(function () {
3932                    result.setAttribute("data-order", result.getAttribute("data-order") + "-second");
3933                });
3934            });
3935            result.setAttribute("data-order", "sync");
3936            "##, None);
3937
3938        let result = dom.get_element_by_id("result").unwrap();
3939        assert_eq!(
3940            result.borrow().value.get_attr("data-order"),
3941            Some("sync-first-second")
3942        );
3943    }
3944
3945    #[test]
3946    fn timer_microtasks_run_before_the_next_timer_task() {
3947        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3948        runtime.run_script(
3949            r##"
3950            const result = document.querySelector("#result");
3951            setTimeout(function () {
3952                result.setAttribute("data-order", "timer");
3953                queueMicrotask(function () {
3954                    result.setAttribute("data-order", "timer-microtask");
3955                });
3956            }, 0);
3957            setTimeout(function () {
3958                result.setAttribute("data-observed", result.getAttribute("data-order"));
3959            }, 0);
3960            "##,
3961            None,
3962        );
3963
3964        assert!(runtime.run_due_timers());
3965        let result = dom.get_element_by_id("result").unwrap();
3966        assert_eq!(
3967            result.borrow().value.get_attr("data-observed"),
3968            Some("timer-microtask")
3969        );
3970    }
3971
3972    #[test]
3973    fn promise_reactions_share_fifo_order_with_queued_microtasks() {
3974        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
3975        runtime.run_script(
3976            r##"
3977            const result = document.querySelector("#result");
3978            result.setAttribute("data-order", "sync");
3979            queueMicrotask(function () {
3980                result.setAttribute("data-order", result.getAttribute("data-order") + "-first");
3981            });
3982            new Promise(function (resolve) {
3983                resolve("promise");
3984            }).then(function (value) {
3985                result.setAttribute("data-order", result.getAttribute("data-order") + "-" + value);
3986            });
3987            queueMicrotask(function () {
3988                result.setAttribute("data-order", result.getAttribute("data-order") + "-last");
3989            });
3990            "##,
3991            None,
3992        );
3993
3994        let result = dom.get_element_by_id("result").unwrap();
3995        assert_eq!(
3996            result.borrow().value.get_attr("data-order"),
3997            Some("sync-first-promise-last")
3998        );
3999    }
4000
4001    #[test]
4002    fn a_failed_microtask_does_not_block_later_jobs() {
4003        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4004        runtime.run_script(
4005            r##"
4006            queueMicrotask(function () {
4007                missingFunction();
4008            });
4009            queueMicrotask(function () {
4010                document.querySelector("#result").setAttribute("data-ran", "yes");
4011            });
4012            "##,
4013            None,
4014        );
4015
4016        let result = dom.get_element_by_id("result").unwrap();
4017        assert_eq!(result.borrow().value.get_attr("data-ran"), Some("yes"));
4018    }
4019
4020    #[test]
4021    fn promise_static_methods_complete_during_script_checkpoint() {
4022        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4023        runtime.run_script(
4024            r##"
4025            const result = document.querySelector("#result");
4026            Promise.all([Promise.resolve("first"), "second"])
4027                .then(function (values) {
4028                    result.setAttribute("data-all", values[0] + "-" + values[1]);
4029                    return Promise.reject("expected");
4030                })
4031                .catch(function (reason) {
4032                    result.setAttribute("data-catch", reason);
4033                });
4034            "##,
4035            None,
4036        );
4037
4038        let result = dom.get_element_by_id("result").unwrap();
4039        assert_eq!(
4040            result.borrow().value.get_attr("data-all"),
4041            Some("first-second")
4042        );
4043        assert_eq!(
4044            result.borrow().value.get_attr("data-catch"),
4045            Some("expected")
4046        );
4047    }
4048
4049    #[test]
4050    fn arrow_callbacks_work_with_promises_and_lexical_this() {
4051        let (mut runtime, dom) =
4052            runtime_from_html(r#"<button id="target"></button><div id="other"></div>"#);
4053        runtime.run_script(
4054            r##"
4055            const target = document.querySelector("#target");
4056            Promise.resolve("promise").then(value => {
4057                target.setAttribute("data-promise", value);
4058            });
4059            target.addEventListener("click", function () {
4060                const update = () => this.setAttribute("data-this", "target");
4061                update.call(document.querySelector("#other"));
4062            });
4063            "##,
4064            None,
4065        );
4066
4067        let target = dom.get_element_by_id("target").unwrap();
4068        assert!(runtime.click(&target));
4069        assert_eq!(
4070            target.borrow().value.get_attr("data-promise"),
4071            Some("promise")
4072        );
4073        assert_eq!(target.borrow().value.get_attr("data-this"), Some("target"));
4074        let other = dom.get_element_by_id("other").unwrap();
4075        assert_eq!(other.borrow().value.get_attr("data-this"), None);
4076    }
4077
4078    #[test]
4079    fn queue_microtask_accepts_an_arrow_callback() {
4080        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4081        runtime.run_script(
4082            r##"
4083            const result = document.querySelector("#result");
4084            queueMicrotask(() => {
4085                result.setAttribute("data-microtask", "yes");
4086            });
4087            "##,
4088            None,
4089        );
4090
4091        let result = dom.get_element_by_id("result").unwrap();
4092        assert_eq!(
4093            result.borrow().value.get_attr("data-microtask"),
4094            Some("yes")
4095        );
4096    }
4097
4098    #[test]
4099    fn browser_global_aliases_share_window_properties() {
4100        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4101        runtime.run_script(
4102            r##"
4103            const result = window.document.querySelector("#result");
4104            result.setAttribute("data-same-self", window === self);
4105            result.setAttribute("data-same-global", window === globalThis);
4106            result.setAttribute("data-document", window.document === document);
4107            window.queueMicrotask(() => {
4108                result.setAttribute("data-microtask", "yes");
4109            });
4110            "##,
4111            None,
4112        );
4113
4114        let result = dom.get_element_by_id("result").unwrap();
4115        let result = result.borrow();
4116        assert_eq!(result.value.get_attr("data-same-self"), Some("true"));
4117        assert_eq!(result.value.get_attr("data-same-global"), Some("true"));
4118        assert_eq!(result.value.get_attr("data-document"), Some("true"));
4119        assert_eq!(result.value.get_attr("data-microtask"), Some("yes"));
4120    }
4121
4122    #[test]
4123    fn fetch_resolves_response_metadata_and_text_promise() {
4124        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4125        runtime.run_script(
4126            r##"
4127            fetch("data:text/plain,hello").then(response => {
4128                const result = document.querySelector("#result");
4129                result.setAttribute("data-ok", response.ok);
4130                result.setAttribute("data-status", response.status);
4131                result.setAttribute("data-status-text", response.statusText);
4132                result.setAttribute("data-url", response.url);
4133                result.setAttribute("data-redirected", response.redirected);
4134                result.setAttribute("data-body-used-before", response.bodyUsed);
4135                const body = response.text();
4136                result.setAttribute("data-body-used-after", response.bodyUsed);
4137                return body;
4138            }).then(text => {
4139                document.querySelector("#result").setAttribute("data-text", text);
4140            });
4141            "##,
4142            None,
4143        );
4144
4145        let requests = runtime.take_fetch_requests();
4146        assert_eq!(requests.len(), 1);
4147        assert_eq!(requests[0].url, "data:text/plain,hello");
4148        runtime.resolve_fetch(
4149            requests[0].id,
4150            JsFetchResponse {
4151                url: "data:text/plain,hello".to_string(),
4152                status: 200,
4153                status_text: "All Good".to_string(),
4154                redirected: true,
4155                body: b"hello".to_vec(),
4156                headers: Vec::new(),
4157            },
4158        );
4159
4160        let result = dom.get_element_by_id("result").unwrap();
4161        let result = result.borrow();
4162        assert_eq!(result.value.get_attr("data-ok"), Some("true"));
4163        assert_eq!(result.value.get_attr("data-status"), Some("200"));
4164        assert_eq!(result.value.get_attr("data-status-text"), Some("All Good"));
4165        assert_eq!(result.value.get_attr("data-redirected"), Some("true"));
4166        assert_eq!(
4167            result.value.get_attr("data-body-used-before"),
4168            Some("false")
4169        );
4170        assert_eq!(result.value.get_attr("data-body-used-after"), Some("true"));
4171        assert_eq!(
4172            result.value.get_attr("data-url"),
4173            Some("data:text/plain,hello")
4174        );
4175        assert_eq!(result.value.get_attr("data-text"), Some("hello"));
4176    }
4177
4178    #[test]
4179    fn fetch_array_buffer_preserves_binary_bytes() {
4180        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4181        runtime.run_script(
4182            r##"
4183            fetch("https://assets.scratch.mit.edu/project.sb3")
4184                .then(response => response.arrayBuffer())
4185                .then(buffer => {
4186                    const bytes = new Uint8Array(buffer);
4187                    document.querySelector("#result").setAttribute(
4188                        "data-bytes",
4189                        buffer.byteLength + ":" + bytes.length + ":" + bytes[0] + ":" + bytes[3]
4190                    );
4191                });
4192            "##,
4193            None,
4194        );
4195
4196        let requests = runtime.take_fetch_requests();
4197        runtime.resolve_fetch(
4198            requests[0].id,
4199            JsFetchResponse {
4200                url: requests[0].url.clone(),
4201                status: 200,
4202                status_text: "OK".to_string(),
4203                redirected: false,
4204                body: vec![0, 127, 128, 255],
4205                headers: Vec::new(),
4206            },
4207        );
4208
4209        let result = dom.get_element_by_id("result").unwrap();
4210        assert_eq!(
4211            result.borrow().value.get_attr("data-bytes"),
4212            Some("4:4:0:255")
4213        );
4214    }
4215
4216    #[test]
4217    fn response_body_cannot_be_consumed_twice() {
4218        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4219        runtime.run_script(
4220            r##"
4221            fetch("data:text/plain,hello").then(response => {
4222                return response.text().then(() => response.text());
4223            }).catch(reason => {
4224                document.querySelector("#result").setAttribute("data-error", reason);
4225            });
4226            "##,
4227            None,
4228        );
4229
4230        let requests = runtime.take_fetch_requests();
4231        runtime.resolve_fetch(
4232            requests[0].id,
4233            JsFetchResponse {
4234                url: "data:text/plain,hello".to_string(),
4235                status: 200,
4236                status_text: "OK".to_string(),
4237                redirected: false,
4238                body: b"hello".to_vec(),
4239                headers: Vec::new(),
4240            },
4241        );
4242
4243        let result = dom.get_element_by_id("result").unwrap();
4244        assert_eq!(
4245            result.borrow().value.get_attr("data-error"),
4246            Some("Response body has already been consumed")
4247        );
4248    }
4249
4250    #[test]
4251    fn fetch_captures_method_headers_and_body() {
4252        let (mut runtime, _dom) = runtime_from_html("<div></div>");
4253        runtime.run_script(
4254            r#"
4255            const headers = {};
4256            headers["Content-Type"] = "application/json";
4257            headers["X-Test"] = "yes";
4258            fetch("https://example.test/messages", {
4259                method: "post",
4260                headers: headers,
4261                body: "{\"message\":\"hello\"}"
4262            });
4263            "#,
4264            None,
4265        );
4266
4267        let requests = runtime.take_fetch_requests();
4268        assert_eq!(requests.len(), 1);
4269        assert_eq!(requests[0].method, "POST");
4270        assert!(
4271            requests[0]
4272                .headers
4273                .contains(&("Content-Type".to_string(), "application/json".to_string()))
4274        );
4275        assert!(
4276            requests[0]
4277                .headers
4278                .contains(&("X-Test".to_string(), "yes".to_string()))
4279        );
4280        assert_eq!(requests[0].body, br#"{"message":"hello"}"#);
4281    }
4282
4283    #[test]
4284    fn xml_http_request_captures_request_and_dispatches_load() {
4285        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4286        runtime.run_script(
4287            r##"
4288            const request = new XMLHttpRequest();
4289            request.open("post", "https://example.test/messages");
4290            request.setRequestHeader("Content-Type", "text/plain");
4291            request.onload = function () {
4292                const result = document.querySelector("#result");
4293                result.setAttribute("data-state", this.readyState);
4294                result.setAttribute("data-status", this.status);
4295                result.setAttribute("data-text", this.responseText);
4296                result.setAttribute("data-headers", this.getAllResponseHeaders());
4297            };
4298            request.send("hello");
4299            "##,
4300            None,
4301        );
4302
4303        let requests = runtime.take_fetch_requests();
4304        assert_eq!(requests.len(), 1);
4305        assert_eq!(requests[0].url, "https://example.test/messages");
4306        assert_eq!(requests[0].method, "POST");
4307        assert_eq!(requests[0].body, b"hello");
4308        assert!(
4309            requests[0]
4310                .headers
4311                .contains(&("Content-Type".to_string(), "text/plain".to_string()))
4312        );
4313
4314        runtime.resolve_fetch(
4315            requests[0].id,
4316            JsFetchResponse {
4317                url: requests[0].url.clone(),
4318                status: 201,
4319                status_text: "Created".to_string(),
4320                redirected: false,
4321                body: b"saved".to_vec(),
4322                headers: vec![("X-Test".to_string(), "yes".to_string())],
4323            },
4324        );
4325
4326        let result = dom.get_element_by_id("result").unwrap();
4327        let result = result.borrow();
4328        assert_eq!(result.value.get_attr("data-state"), Some("4"));
4329        assert_eq!(result.value.get_attr("data-status"), Some("201"));
4330        assert_eq!(result.value.get_attr("data-text"), Some("saved"));
4331        assert_eq!(
4332            result.value.get_attr("data-headers"),
4333            Some("X-Test: yes\r\n")
4334        );
4335    }
4336
4337    #[test]
4338    fn headers_are_case_insensitive_and_mutable() {
4339        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4340        runtime.run_script(
4341            r##"
4342            const headers = new Headers({ Accept: "application/json" });
4343            headers.append("X-Test", "one");
4344            headers.append("x-test", "two");
4345            headers.set("X-Replace", "before");
4346            headers.set("x-replace", "after");
4347            headers.delete("ACCEPT");
4348
4349            const result = document.querySelector("#result");
4350            result.setAttribute("data-test", headers.get("X-TEST"));
4351            result.setAttribute("data-replace", headers.get("X-Replace"));
4352            result.setAttribute("data-has-accept", headers.has("accept"));
4353            result.setAttribute("data-missing", headers.get("missing") === null);
4354
4355            fetch("https://example.test/", { headers: headers });
4356            "##,
4357            None,
4358        );
4359
4360        let result = dom.get_element_by_id("result").unwrap();
4361        let result = result.borrow();
4362        assert_eq!(result.value.get_attr("data-test"), Some("one, two"));
4363        assert_eq!(result.value.get_attr("data-replace"), Some("after"));
4364        assert_eq!(result.value.get_attr("data-has-accept"), Some("false"));
4365        assert_eq!(result.value.get_attr("data-missing"), Some("true"));
4366
4367        let requests = runtime.take_fetch_requests();
4368        assert!(
4369            requests[0]
4370                .headers
4371                .contains(&("x-test".to_string(), "one, two".to_string()))
4372        );
4373        assert!(
4374            requests[0]
4375                .headers
4376                .contains(&("x-replace".to_string(), "after".to_string()))
4377        );
4378    }
4379
4380    #[test]
4381    fn response_exposes_read_only_headers() {
4382        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4383        runtime.run_script(
4384            r##"
4385            fetch("data:text/plain,hello").then(response => {
4386                const result = document.querySelector("#result");
4387                result.setAttribute("data-type", response.headers.get("Content-Type"));
4388                result.setAttribute("data-has", response.headers.has("X-Test"));
4389            });
4390            "##,
4391            None,
4392        );
4393
4394        let requests = runtime.take_fetch_requests();
4395        runtime.resolve_fetch(
4396            requests[0].id,
4397            JsFetchResponse {
4398                url: "data:text/plain,hello".to_string(),
4399                status: 200,
4400                status_text: "OK".to_string(),
4401                redirected: false,
4402                body: b"hello".to_vec(),
4403                headers: vec![
4404                    ("content-type".to_string(), "text/plain".to_string()),
4405                    ("X-Test".to_string(), "yes".to_string()),
4406                ],
4407            },
4408        );
4409
4410        let result = dom.get_element_by_id("result").unwrap();
4411        let result = result.borrow();
4412        assert_eq!(result.value.get_attr("data-type"), Some("text/plain"));
4413        assert_eq!(result.value.get_attr("data-has"), Some("true"));
4414    }
4415
4416    #[test]
4417    fn request_objects_can_be_copied_and_passed_to_fetch() {
4418        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4419        runtime.run_script(
4420            r##"
4421            const headers = new Headers({ Accept: "application/json" });
4422            const original = new Request("https://example.test/messages", {
4423                method: "post",
4424                headers: headers,
4425                body: "hello"
4426            });
4427            const copied = new Request(original);
4428            const result = document.querySelector("#result");
4429            result.setAttribute("data-url", copied.url);
4430            result.setAttribute("data-method", copied.method);
4431            result.setAttribute("data-accept", copied.headers.get("accept"));
4432            fetch(copied, { method: "put" });
4433            "##,
4434            None,
4435        );
4436
4437        let result = dom.get_element_by_id("result").unwrap();
4438        let result = result.borrow();
4439        assert_eq!(
4440            result.value.get_attr("data-url"),
4441            Some("https://example.test/messages")
4442        );
4443        assert_eq!(result.value.get_attr("data-method"), Some("POST"));
4444        assert_eq!(
4445            result.value.get_attr("data-accept"),
4446            Some("application/json")
4447        );
4448
4449        let requests = runtime.take_fetch_requests();
4450        assert_eq!(requests[0].url, "https://example.test/messages");
4451        assert_eq!(requests[0].method, "PUT");
4452        assert_eq!(requests[0].body, b"hello");
4453        assert!(
4454            requests[0]
4455                .headers
4456                .contains(&("accept".to_string(), "application/json".to_string()))
4457        );
4458    }
4459
4460    #[test]
4461    fn response_json_resolves_objects_and_arrays() {
4462        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4463        runtime.run_script(
4464            r##"
4465            fetch("data:application/json,pending").then(response => response.json()).then(value => {
4466                const result = document.querySelector("#result");
4467                result.setAttribute("data-name", value.name);
4468                result.setAttribute("data-second", value.items[1]);
4469                result.setAttribute("data-enabled", value.enabled);
4470                result.setAttribute("data-empty", value.empty === null);
4471            });
4472            "##,
4473            None,
4474        );
4475
4476        let requests = runtime.take_fetch_requests();
4477        runtime.resolve_fetch(
4478            requests[0].id,
4479            JsFetchResponse {
4480                url: "data:application/json,pending".to_string(),
4481                status: 200,
4482                status_text: "OK".to_string(),
4483                redirected: false,
4484                body: br#"{"name":"Orinium","items":[1,2],"enabled":true,"empty":null}"#.to_vec(),
4485                headers: Vec::new(),
4486            },
4487        );
4488
4489        let result = dom.get_element_by_id("result").unwrap();
4490        let result = result.borrow();
4491        assert_eq!(result.value.get_attr("data-name"), Some("Orinium"));
4492        assert_eq!(result.value.get_attr("data-second"), Some("2"));
4493        assert_eq!(result.value.get_attr("data-enabled"), Some("true"));
4494        assert_eq!(result.value.get_attr("data-empty"), Some("true"));
4495    }
4496
4497    #[test]
4498    fn response_json_rejects_invalid_json() {
4499        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4500        runtime.run_script(
4501            r##"
4502            fetch("data:application/json,invalid")
4503                .then(response => response.json())
4504                .catch(reason => {
4505                    document.querySelector("#result").setAttribute("data-error", reason);
4506                });
4507            "##,
4508            None,
4509        );
4510
4511        let requests = runtime.take_fetch_requests();
4512        runtime.resolve_fetch(
4513            requests[0].id,
4514            JsFetchResponse {
4515                url: "data:application/json,invalid".to_string(),
4516                status: 200,
4517                status_text: "OK".to_string(),
4518                redirected: false,
4519                body: b"not json".to_vec(),
4520                headers: Vec::new(),
4521            },
4522        );
4523
4524        let result = dom.get_element_by_id("result").unwrap();
4525        assert!(
4526            result
4527                .borrow()
4528                .value
4529                .get_attr("data-error")
4530                .unwrap()
4531                .starts_with("Failed to parse JSON:")
4532        );
4533    }
4534
4535    #[test]
4536    fn fetch_rejection_runs_catch_reaction() {
4537        let (mut runtime, dom) = runtime_from_html(r#"<div id="result"></div>"#);
4538        runtime.run_script(
4539            r##"
4540            fetch("https://invalid.test/").catch(reason => {
4541                document.querySelector("#result").setAttribute("data-error", reason);
4542            });
4543            "##,
4544            None,
4545        );
4546
4547        let requests = runtime.take_fetch_requests();
4548        runtime.reject_fetch(requests[0].id, "network failed".to_string());
4549        let result = dom.get_element_by_id("result").unwrap();
4550        assert_eq!(
4551            result.borrow().value.get_attr("data-error"),
4552            Some("network failed")
4553        );
4554    }
4555
4556    #[test]
4557    fn intersection_observer_fires_on_observe() {
4558        let (mut runtime, dom) = runtime_from_html(
4559            r#"<div id="target" style="width: 100px; height: 50px"></div><div id="result"></div>"#,
4560        );
4561        runtime.run_script(
4562            r#"
4563            const target = document.getElementById("target");
4564            const result = document.getElementById("result");
4565            const observer = new IntersectionObserver(function (entries) {
4566                const entry = entries[0];
4567                result.setAttribute("data-target", entry.target === target);
4568                result.setAttribute("data-is-intersecting", entry.isIntersecting);
4569                result.setAttribute("data-ratio", entry.intersectionRatio);
4570                result.setAttribute("data-has-root-bounds", entry.rootBounds !== null);
4571                result.setAttribute("data-bcr-width", entry.boundingClientRect.width);
4572            });
4573            observer.observe(target);
4574            "#,
4575            None,
4576        );
4577
4578        let result = dom.get_element_by_id("result").unwrap();
4579        let result = result.borrow();
4580        assert_eq!(result.value.get_attr("data-target"), Some("true"));
4581        // The element has style width/height so it is visible within the viewport.
4582        assert_eq!(result.value.get_attr("data-is-intersecting"), Some("true"));
4583        assert_eq!(result.value.get_attr("data-ratio"), Some("1"));
4584        assert_eq!(result.value.get_attr("data-has-root-bounds"), Some("true"));
4585        assert_eq!(result.value.get_attr("data-bcr-width"), Some("100"));
4586    }
4587
4588    #[test]
4589    fn custom_elements_define_and_connect() {
4590        let (mut runtime, dom) =
4591            runtime_from_html(r#"<html><body><div id="result"></div></body></html>"#);
4592        runtime.run_script(
4593            r#"
4594            class MyElement extends HTMLElement {
4595                connectedCallback() {
4596                    document.getElementById("result").setAttribute("data-connected", "yes");
4597                }
4598                disconnectedCallback() {
4599                    document.getElementById("result").setAttribute("data-disconnected", "yes");
4600                }
4601            }
4602            customElements.define("my-element", MyElement);
4603            document.getElementById("result").setAttribute(
4604                "data-proto",
4605                typeof MyElement.prototype.connectedCallback
4606            );
4607            const el = document.createElement("my-element");
4608            document.body.appendChild(el);
4609            document.body.removeChild(el);
4610            "#,
4611            None,
4612        );
4613
4614        let result = dom.get_element_by_id("result").unwrap();
4615        let result = result.borrow();
4616        // The prototype lookup finds the function.
4617        assert_eq!(result.value.get_attr("data-proto"), Some("function"));
4618        // Lifecycle callbacks fire via enqueue_job + microtask checkpoint.
4619        assert_eq!(result.value.get_attr("data-connected"), Some("yes"));
4620        assert_eq!(result.value.get_attr("data-disconnected"), Some("yes"));
4621    }
4622
4623    #[test]
4624    fn custom_elements_define_getters_work() {
4625        let (mut runtime, _dom) = runtime_from_html(r#"<html><body></body></html>"#);
4626        runtime.run_script(
4627            r#"
4628            class MyEl extends HTMLElement {}
4629            customElements.define("my-el", MyEl);
4630            if (customElements.get("my-el") === undefined) throw new Error("get failed");
4631            if (customElements.get("no-such") !== undefined) throw new Error("get should be undefined");
4632            "#, None);
4633    }
4634
4635    #[test]
4636    fn custom_elements_attribute_changed_and_when_defined() {
4637        let (mut runtime, dom) =
4638            runtime_from_html(r#"<html><body><div id="result"></div></body></html>"#);
4639        runtime.run_script(
4640            r#"
4641            globalThis.__attrLog = [];
4642            class AttrEl extends HTMLElement {
4643                attributeChangedCallback(name, oldVal, newVal) {
4644                    globalThis.__attrLog.push(name + ":" + (oldVal === null ? "null" : oldVal) + ":" + (newVal === null ? "null" : newVal));
4645                }
4646            }
4647            AttrEl.observedAttributes = ["data-val"];
4648            customElements.define("attr-el", AttrEl);
4649            const el = document.createElement("attr-el");
4650            document.body.appendChild(el);
4651            el.setAttribute("data-val", "first");
4652            el.setAttribute("data-val", "second");
4653            el.removeAttribute("data-val");
4654            // whenDefined resolves immediately for an already-defined name.
4655            let wdResolved = false;
4656            customElements.whenDefined("attr-el").then(function () {
4657                wdResolved = true;
4658            });
4659            document.getElementById("result").setAttribute(
4660                "data-wd", wdResolved
4661            );
4662            "#, None);
4663
4664        // Read the log after microtasks have fired the callbacks.
4665        runtime.run_script(
4666            r#"document.getElementById("result").setAttribute(
4667                "data-log", globalThis.__attrLog.join("|")
4668            );"#,
4669            None,
4670        );
4671
4672        let result = dom.get_element_by_id("result").unwrap();
4673        let result = result.borrow();
4674        // Three callbacks fire via microtask: first set, second set, remove.
4675        // 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"));
4676        assert_eq!(result.value.get_attr("data-wd"), Some("true"));
4677    }
4678
4679    #[test]
4680    fn shadow_dom_attach_and_query() {
4681        let (mut runtime, dom) =
4682            runtime_from_html(r#"<html><body><div id="host"></div></body></html>"#);
4683        // First: verify attachShadow works at all
4684        runtime.run_script(
4685            r##"
4686            var host = document.getElementById("host");
4687            host.setAttribute("data-step1", "ready");
4688            "##,
4689            None,
4690        );
4691        let result = dom.get_element_by_id("host").unwrap();
4692        assert_eq!(result.borrow().value.get_attr("data-step1"), Some("ready"));
4693
4694        // Now try attachShadow in its own script
4695        runtime.run_script(
4696            r##"
4697            var host = document.getElementById("host");
4698            host.attachShadow({ mode: "open" });
4699            host.setAttribute("data-step2", "shadow-attached");
4700            "##,
4701            None,
4702        );
4703        assert_eq!(
4704            result.borrow().value.get_attr("data-step2"),
4705            Some("shadow-attached")
4706        );
4707
4708        // Now test the rest
4709        runtime.run_script(
4710            r##"
4711            var host = document.getElementById("host");
4712            try {
4713                var sr = host.shadowRoot;
4714                host.setAttribute("data-sr", sr !== null ? "true" : "false");
4715                var span = document.createElement("span");
4716                span.id = "inner";
4717                span.textContent = "shadow text";
4718                sr.appendChild(span);
4719                var found = sr.querySelector("#inner");
4720                host.setAttribute("data-found", found !== null ? found.textContent : "NOT_FOUND");
4721                var notFound = host.querySelector("#inner");
4722                host.setAttribute("data-boundary", notFound === null ? "true" : "false");
4723                host.setAttribute("data-text", host.textContent.trim() === "" ? "true" : "false");
4724            } catch(e) {
4725                host.setAttribute("data-error", e.toString());
4726            }
4727            "##,
4728            None,
4729        );
4730        let result = dom.get_element_by_id("host").unwrap();
4731        let result = result.borrow();
4732        assert_eq!(result.value.get_attr("data-sr"), Some("true"));
4733        assert_eq!(result.value.get_attr("data-found"), Some("shadow text"));
4734        assert_eq!(result.value.get_attr("data-boundary"), Some("true"));
4735        assert_eq!(result.value.get_attr("data-text"), Some("true"));
4736    }
4737
4738    #[test]
4739    fn shadow_dom_closed_root() {
4740        let (mut runtime, dom) =
4741            runtime_from_html(r#"<html><body><div id="host"></div></body></html>"#);
4742        runtime.run_script(
4743            r##"
4744            var host = document.getElementById("host");
4745            host.attachShadow({ mode: "closed" });
4746            host.setAttribute("data-closed", host.shadowRoot === null ? "true" : "false");
4747            "##,
4748            None,
4749        );
4750        let result = dom.get_element_by_id("host").unwrap();
4751        let result = result.borrow();
4752        assert_eq!(result.value.get_attr("data-closed"), Some("true"));
4753    }
4754
4755    #[test]
4756    fn document_write_inserts_parsed_html_into_body() {
4757        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
4758        runtime.run_script(r#"document.write("<p>Hello</p>");"#, None);
4759        let p = dom.query_selector("body p").unwrap();
4760        assert_eq!(DomTree::inner_text(&p), "Hello");
4761        assert!(runtime.needs_redraw());
4762    }
4763
4764    #[test]
4765    fn document_writeln_appends_content_with_newline() {
4766        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
4767        runtime.run_script(r#"document.writeln("<span>A</span>");"#, None);
4768        let span = dom.query_selector("body span").unwrap();
4769        assert_eq!(DomTree::inner_text(&span), "A");
4770    }
4771
4772    #[test]
4773    fn dom_exception_has_name_message_and_code() {
4774        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4775        runtime.run_script(
4776            r#"
4777        try {
4778            throw new DOMException("test error", "SyntaxError");
4779        } catch (e) {
4780            document.getElementById("r").setAttribute("data-name", e.name);
4781            document.getElementById("r").setAttribute("data-msg", e.message);
4782            document.getElementById("r").setAttribute("data-code", e.code);
4783        }
4784        "#,
4785            None,
4786        );
4787        let r = dom.get_element_by_id("r").unwrap();
4788        let r = r.borrow();
4789        assert_eq!(r.value.get_attr("data-name"), Some("SyntaxError"));
4790        assert_eq!(r.value.get_attr("data-msg"), Some("test error"));
4791        assert_eq!(r.value.get_attr("data-code"), Some("12"));
4792    }
4793
4794    #[test]
4795    fn dom_exception_static_constants_are_exposed() {
4796        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4797        runtime.run_script(
4798            r#"
4799        document.getElementById("r").setAttribute(
4800            "data-codes",
4801            DOMException.SYNTAX_ERR + ":" +
4802            DOMException.HIERARCHY_REQUEST_ERR + ":" +
4803            DOMException.NOT_FOUND_ERR + ":" +
4804            DOMException.INVALID_STATE_ERR
4805        );
4806        "#,
4807            None,
4808        );
4809        let r = dom.get_element_by_id("r").unwrap();
4810        assert_eq!(r.borrow().value.get_attr("data-codes"), Some("12:3:8:11"));
4811    }
4812
4813    #[test]
4814    fn dom_exception_to_string_formats_name_and_message() {
4815        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4816        runtime.run_script(
4817            r#"
4818        var e = new DOMException("oops", "NotFoundError");
4819        document.getElementById("r").setAttribute("data-str", e.toString());
4820        "#,
4821            None,
4822        );
4823        let r = dom.get_element_by_id("r").unwrap();
4824        assert_eq!(
4825            r.borrow().value.get_attr("data-str"),
4826            Some("NotFoundError: oops")
4827        );
4828    }
4829
4830    #[test]
4831    fn create_element_throws_invalid_character_error_for_empty_name() {
4832        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4833        runtime.run_script(
4834            r#"
4835        try {
4836            document.createElement("");
4837            document.getElementById("r").setAttribute("data-error", "no-throw");
4838        } catch (e) {
4839            document.getElementById("r").setAttribute("data-error", e.name);
4840        }
4841        "#,
4842            None,
4843        );
4844        let r = dom.get_element_by_id("r").unwrap();
4845        assert_eq!(
4846            r.borrow().value.get_attr("data-error"),
4847            Some("InvalidCharacterError")
4848        );
4849    }
4850
4851    #[test]
4852    fn create_element_throws_invalid_character_error_for_invalid_name() {
4853        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4854        runtime.run_script(
4855            r#"
4856        try {
4857            document.createElement("123bad");
4858            document.getElementById("r").setAttribute("data-error", "no-throw");
4859        } catch (e) {
4860            document.getElementById("r").setAttribute("data-error", e.name);
4861        }
4862        "#,
4863            None,
4864        );
4865        let r = dom.get_element_by_id("r").unwrap();
4866        assert_eq!(
4867            r.borrow().value.get_attr("data-error"),
4868            Some("InvalidCharacterError")
4869        );
4870    }
4871
4872    #[test]
4873    fn create_element_valid_name_works() {
4874        let (mut runtime, dom) = runtime_from_html(r#"<html><body></body></html>"#);
4875        runtime.run_script(
4876            r#"
4877        var el = document.createElement("div");
4878        el.id = "created";
4879        document.body.appendChild(el);
4880        "#,
4881            None,
4882        );
4883        assert!(dom.get_element_by_id("created").is_some());
4884    }
4885
4886    #[test]
4887    fn create_element_ns_throws_invalid_character_error_for_invalid_name() {
4888        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4889        runtime.run_script(
4890            r#"
4891        try {
4892            document.createElementNS("http://www.w3.org/2000/svg", "123bad");
4893            document.getElementById("r").setAttribute("data-error", "no-throw");
4894        } catch (e) {
4895            document.getElementById("r").setAttribute("data-error", e.name);
4896        }
4897        "#,
4898            None,
4899        );
4900        let r = dom.get_element_by_id("r").unwrap();
4901        assert_eq!(
4902            r.borrow().value.get_attr("data-error"),
4903            Some("InvalidCharacterError")
4904        );
4905    }
4906
4907    #[test]
4908    fn node_constants_are_exposed_on_global() {
4909        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4910        runtime.run_script(
4911            r#"
4912        document.getElementById("r").setAttribute("data-elem", Node.ELEMENT_NODE);
4913        document.getElementById("r").setAttribute("data-text", Node.TEXT_NODE);
4914        document.getElementById("r").setAttribute("data-doc", Node.DOCUMENT_NODE);
4915        document.getElementById("r").setAttribute("data-frag", Node.DOCUMENT_FRAGMENT_NODE);
4916        "#,
4917            None,
4918        );
4919        let r = dom.get_element_by_id("r").unwrap();
4920        let r = r.borrow();
4921        assert_eq!(r.value.get_attr("data-elem"), Some("1"));
4922        assert_eq!(r.value.get_attr("data-text"), Some("3"));
4923        assert_eq!(r.value.get_attr("data-doc"), Some("9"));
4924        assert_eq!(r.value.get_attr("data-frag"), Some("11"));
4925    }
4926
4927    #[test]
4928    fn node_constants_are_exposed_on_instance_nodes() {
4929        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4930        runtime.run_script(
4931            r#"
4932        document.getElementById("r").setAttribute("data-doc-frag", document.DOCUMENT_FRAGMENT_NODE);
4933        document.getElementById("r").setAttribute("data-cmt", document.body.COMMENT_NODE);
4934        document.getElementById("r").setAttribute("data-txt", document.createTextNode("").ELEMENT_NODE);
4935        document.getElementById("r").setAttribute("data-frag", document.createElement("div").DOCUMENT_FRAGMENT_NODE);
4936        "#, None);
4937        let r = dom.get_element_by_id("r").unwrap();
4938        let r = r.borrow();
4939        assert_eq!(r.value.get_attr("data-doc-frag"), Some("11"));
4940        assert_eq!(r.value.get_attr("data-cmt"), Some("8"));
4941        assert_eq!(r.value.get_attr("data-txt"), Some("1"));
4942        assert_eq!(r.value.get_attr("data-frag"), Some("11"));
4943    }
4944
4945    #[test]
4946    fn document_first_child_is_doctype_node() {
4947        let (mut runtime, dom) = runtime_from_html(
4948            r#"<!DOCTYPE html><html><body><div id="r"><span>x</span></div></body></html>"#,
4949        );
4950        runtime.run_script(
4951            r#"
4952        document.getElementById("r").setAttribute("data-doc-type", document.nodeType);
4953        document.getElementById("r").setAttribute("data-doctype-node", document.firstChild.nodeType);
4954        document.getElementById("r").setAttribute("data-doctype-name", document.firstChild.nodeName);
4955        var span = document.getElementById("r").firstChild;
4956        document.getElementById("r").setAttribute("data-text-type", span.firstChild.nodeType);
4957        "#, None);
4958        let r = dom.get_element_by_id("r").unwrap();
4959        let r = r.borrow();
4960        assert_eq!(r.value.get_attr("data-doc-type"), Some("9"));
4961        assert_eq!(r.value.get_attr("data-doctype-node"), Some("10"));
4962        assert_eq!(r.value.get_attr("data-doctype-name"), Some("html"));
4963        assert_eq!(r.value.get_attr("data-text-type"), Some("3"));
4964    }
4965
4966    #[test]
4967    fn create_element_ns_preserves_qualified_name() {
4968        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
4969        runtime.run_script(
4970            r#"
4971        var el = document.createElementNS("http://ns.example.com/", "prefix:localname");
4972        document.getElementById("r").setAttribute("data-tag", el.tagName);
4973        document.getElementById("r").setAttribute("data-local", el.localName);
4974        document.getElementById("r").setAttribute("data-prefix", el.prefix);
4975        document.getElementById("r").setAttribute("data-ns", el.namespaceURI);
4976        "#,
4977            None,
4978        );
4979        let r = dom.get_element_by_id("r").unwrap();
4980        let r = r.borrow();
4981        assert_eq!(r.value.get_attr("data-tag"), Some("prefix:localname"));
4982        assert_eq!(r.value.get_attr("data-local"), Some("localname"));
4983        assert_eq!(r.value.get_attr("data-prefix"), Some("prefix"));
4984        assert_eq!(r.value.get_attr("data-ns"), Some("http://ns.example.com/"));
4985    }
4986
4987    #[test]
4988    fn document_close_returns_undefined() {
4989        let (mut runtime, _) = runtime_from_html(r#"<html><body></body></html>"#);
4990        runtime.run_script(
4991            r#"
4992        var result = document.close();
4993        document.getElementById("r").setAttribute("data-close", typeof result);
4994        "#,
4995            None,
4996        );
4997        // document.close() returns undefined, and there is no element "r" yet
4998        // so we test it differently
4999        let (mut runtime2, dom2) = runtime_from_html(r#"<div id="r"></div>"#);
5000        runtime2.run_script(
5001            r#"
5002        document.close();
5003        document.getElementById("r").setAttribute("data-close", "ok");
5004        "#,
5005            None,
5006        );
5007        let r = dom2.get_element_by_id("r").unwrap();
5008        assert_eq!(r.borrow().value.get_attr("data-close"), Some("ok"));
5009    }
5010
5011    #[test]
5012    fn dom_exception_instanceof_error() {
5013        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5014        runtime.run_script(
5015            r#"
5016        var e = new DOMException("test", "SyntaxError");
5017        document.getElementById("r").setAttribute("data-is-error", e instanceof Error);
5018        document.getElementById("r").setAttribute("data-is-domexc", e instanceof DOMException);
5019        "#,
5020            None,
5021        );
5022        let r = dom.get_element_by_id("r").unwrap();
5023        let r = r.borrow();
5024        assert_eq!(r.value.get_attr("data-is-error"), Some("true"));
5025        assert_eq!(r.value.get_attr("data-is-domexc"), Some("true"));
5026    }
5027
5028    #[test]
5029    fn create_comment_returns_comment_node() {
5030        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5031        runtime.run_script(
5032            r#"
5033        var c = document.createComment("hello");
5034        document.getElementById("r").setAttribute("data-type", c.nodeType);
5035        document.getElementById("r").setAttribute("data-name", c.nodeName);
5036        document.getElementById("r").setAttribute("data-data", c.data);
5037        "#,
5038            None,
5039        );
5040        let r = dom.get_element_by_id("r").unwrap();
5041        let r = r.borrow();
5042        assert_eq!(r.value.get_attr("data-type"), Some("8"));
5043        assert_eq!(r.value.get_attr("data-name"), Some("#comment"));
5044        assert_eq!(r.value.get_attr("data-data"), Some("hello"));
5045    }
5046
5047    #[test]
5048    fn create_processing_instruction_returns_pi_node() {
5049        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5050        runtime.run_script(
5051            r#"
5052        var pi = document.createProcessingInstruction("xml-stylesheet", "href=\"style.css\"");
5053        document.getElementById("r").setAttribute("data-type", pi.nodeType);
5054        document.getElementById("r").setAttribute("data-data", pi.data);
5055        "#,
5056            None,
5057        );
5058        let r = dom.get_element_by_id("r").unwrap();
5059        let r = r.borrow();
5060        assert_eq!(r.value.get_attr("data-type"), Some("7"));
5061        assert_eq!(r.value.get_attr("data-data"), Some("href=\"style.css\""));
5062    }
5063
5064    #[test]
5065    fn create_processing_instruction_empty_target_throws() {
5066        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5067        runtime.run_script(
5068            r#"
5069        try {
5070            document.createProcessingInstruction("", "data");
5071            document.getElementById("r").setAttribute("data-error", "no-throw");
5072        } catch (e) {
5073            document.getElementById("r").setAttribute("data-error", e.name);
5074        }
5075        "#,
5076            None,
5077        );
5078        let r = dom.get_element_by_id("r").unwrap();
5079        assert_eq!(r.borrow().value.get_attr("data-error"), Some("SyntaxError"));
5080    }
5081
5082    #[test]
5083    fn innerhtml_setter_uses_parser_and_replaces_children() {
5084        let (mut runtime, dom) = runtime_from_html(r#"<div id="target"></div><div id="r"></div>"#);
5085        runtime.run_script(r#"
5086        var t = document.getElementById("target");
5087        t.innerHTML = "<p>A</p><span>B</span>";
5088        document.getElementById("r").setAttribute("data-count", t.childNodes.length);
5089        document.getElementById("r").setattr || document.getElementById("r").setAttribute("data-tags",
5090            t.children[0].tagName + ":" + t.children[1].tagName
5091        );
5092        "#, None);
5093        let r = dom.get_element_by_id("r").unwrap();
5094        let r = r.borrow();
5095        assert_eq!(r.value.get_attr("data-count"), Some("2"));
5096    }
5097
5098    // -----------------------------------------------------------------------
5099    // Formerly missing Web Platform APIs (detector list)
5100    // -----------------------------------------------------------------------
5101
5102    #[test]
5103    fn all_detector_listed_apis_are_present() {
5104        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5105        runtime.run_script(
5106            r#"
5107        var missing = [];
5108        var globals = ["AbortController", "AbortSignal", "Blob", "CustomElementRegistry",
5109            "FileReader", "Response", "ShadowRoot"];
5110        for (var i = 0; i < globals.length; i++) {
5111            if (typeof window[globals[i]] === "undefined") missing.push("window." + globals[i]);
5112        }
5113        var docMethods = ["elementFromPoint", "elementsFromPoint", "execCommand", "getSelection",
5114            "pictureInPictureElement"];
5115        for (var j = 0; j < docMethods.length; j++) {
5116            if (typeof document[docMethods[j]] === "undefined") missing.push("document." + docMethods[j]);
5117        }
5118        var elementMethods = ["getAnimations", "requestFullscreen", "scrollIntoView", "scrollTo"];
5119        for (var k = 0; k < elementMethods.length; k++) {
5120            if (typeof Element.prototype[elementMethods[k]] === "undefined")
5121                missing.push("Element.prototype." + elementMethods[k]);
5122        }
5123        document.getElementById("r").setAttribute("data-missing", missing.join(","));
5124        "#, None);
5125        let r = dom.get_element_by_id("r").unwrap();
5126        assert_eq!(r.borrow().value.get_attr("data-missing"), Some(""));
5127    }
5128
5129    #[test]
5130    fn element_prototype_methods_are_callable_noops() {
5131        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5132        runtime.run_script(
5133            r#"
5134        var el = document.getElementById("r");
5135        var out = [];
5136        out.push("anims:" + (el.getAnimations() instanceof Array));
5137        out.push("scrollIntoView:" + el.scrollIntoView());
5138        out.push("scrollTo:" + el.scrollTo(0, 10));
5139        out.push("scrollToOptions:" + el.scrollTo({left: 1, top: 2}));
5140        el.requestFullscreen().then(function () {
5141            el.setAttribute("data-fs", "resolved:" + (document.fullscreenElement === el));
5142        });
5143        el.setAttribute("data-out", out.join(";"));
5144        "#,
5145            None,
5146        );
5147        let r = dom.get_element_by_id("r").unwrap();
5148        let attrs = r.borrow();
5149        assert_eq!(
5150            attrs.value.get_attr("data-out"),
5151            Some(
5152                "anims:true;scrollIntoView:undefined;scrollTo:undefined;scrollToOptions:undefined"
5153            )
5154        );
5155        assert_eq!(attrs.value.get_attr("data-fs"), Some("resolved:true"));
5156    }
5157
5158    #[test]
5159    fn document_selection_and_point_apis_behave() {
5160        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5161        runtime.run_script(
5162            r#"
5163        var out = [];
5164        var sel = document.getSelection();
5165        out.push("selSingleton:" + (document.getSelection() === sel));
5166        out.push("rangeCount:" + sel.rangeCount);
5167        out.push("type:" + sel.type);
5168        out.push("exec:" + document.execCommand("bold"));
5169        out.push("execEmpty:" + document.execCommand(""));
5170        out.push("pipType:" + typeof document.pictureInPictureElement);
5171        out.push("pipNull:" + (document.pictureInPictureElement === null));
5172        out.push("point:" + (document.elementFromPoint(5, 5) === null));
5173        out.push("stack:" + document.elementsFromPoint(5, 5).length);
5174        document.getElementById("r").setAttribute("data-out", out.join(";"));
5175        "#,
5176            None,
5177        );
5178        let r = dom.get_element_by_id("r").unwrap();
5179        assert_eq!(
5180            r.borrow().value.get_attr("data-out"),
5181            Some(
5182                "selSingleton:true;rangeCount:0;type:None;exec:true;execEmpty:false;pipType:object;pipNull:true;point:true;stack:0"
5183            )
5184        );
5185    }
5186
5187    #[test]
5188    fn document_scripts_fonts_and_adjacent_insertion_work() {
5189        let (mut runtime, dom) = runtime_from_html(
5190            r#"<html><head><script id="s1"></script></head><body><div id="r"></div><div id="anchor"></div></body></html>"#,
5191        );
5192        runtime.run_script(
5193            r#"
5194        var out = [];
5195        out.push("scripts:" + document.scripts.length);
5196        out.push("scriptId:" + document.scripts[0].id);
5197        out.push("current:" + (document.currentScript === null));
5198        out.push("fonts:" + document.fonts.status + ":" + document.fonts.check("12px sans-serif"));
5199        out.push("fontsReady:" + typeof document.fonts.ready.then);
5200        document.adoptedStyleSheets = [];
5201        out.push("adopted:" + document.adoptedStyleSheets.length);
5202        var inserted = document.getElementById("anchor").insertAdjacentElement("beforebegin", document.createElement("span"));
5203        out.push("inserted:" + (inserted && inserted.tagName));
5204        document.getElementById("r").setAttribute("data-out", out.join(";"));
5205        "#, None);
5206        let r = dom.get_element_by_id("r").unwrap();
5207        assert_eq!(
5208            r.borrow().value.get_attr("data-out"),
5209            Some(
5210                "scripts:1;scriptId:s1;current:true;fonts:loaded:true;fontsReady:function;adopted:0;inserted:SPAN"
5211            )
5212        );
5213    }
5214
5215    #[test]
5216    fn abort_controller_and_signal_follow_platform_semantics() {
5217        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5218        runtime.run_script(
5219            r#"
5220        var out = [];
5221        var controller = new AbortController();
5222        out.push("fresh:" + controller.signal.aborted);
5223        var fired = 0;
5224        controller.signal.addEventListener("abort", function () { fired++; });
5225        controller.abort();
5226        out.push("aborted:" + controller.signal.aborted);
5227        out.push("reasonName:" + controller.signal.reason.name);
5228        out.push("fired:" + fired);
5229        var custom = new AbortController();
5230        custom.abort("because");
5231        out.push("customReason:" + custom.signal.reason);
5232        out.push("reabort:" + (controller.abort(), controller.signal.reason.name));
5233        try { controller.signal.throwIfAborted(); } catch (e) { out.push("throw:" + e.name); }
5234        var staticAborted = AbortSignal.abort();
5235        out.push("static:" + staticAborted.aborted + ":" + staticAborted.reason.name);
5236        var any = AbortSignal.any([controller.signal, new AbortController().signal]);
5237        out.push("any:" + any.aborted);
5238        document.getElementById("r").setAttribute("data-out", out.join(";"));
5239        "#,
5240            None,
5241        );
5242        runtime.perform_microtask_checkpoint_public();
5243        let r = dom.get_element_by_id("r").unwrap();
5244        assert_eq!(
5245            r.borrow().value.get_attr("data-out"),
5246            Some(
5247                "fresh:false;aborted:true;reasonName:AbortError;fired:1;customReason:because;reabort:AbortError;throw:AbortError;static:true:AbortError;any:true"
5248            )
5249        );
5250    }
5251
5252    #[test]
5253    fn fetch_with_preaborted_signal_rejects_without_request() {
5254        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5255        runtime.run_script(
5256            r#"
5257        var controller = new AbortController();
5258        controller.abort();
5259        fetch("https://example.com/data", { signal: controller.signal })
5260            .then(function () {
5261                document.getElementById("r").setAttribute("data-result", "resolved");
5262            })
5263            .catch(function (error) {
5264                document.getElementById("r").setAttribute("data-result", "rejected:" + error.name);
5265            });
5266        "#,
5267            None,
5268        );
5269        runtime.perform_microtask_checkpoint_public();
5270        let requests = runtime.take_fetch_requests();
5271        assert!(
5272            requests.is_empty(),
5273            "no fetch should be queued for an aborted signal"
5274        );
5275        let r = dom.get_element_by_id("r").unwrap();
5276        assert_eq!(
5277            r.borrow().value.get_attr("data-result"),
5278            Some("rejected:AbortError")
5279        );
5280    }
5281
5282    #[test]
5283    fn fetch_registers_signal_and_abort_rejects_pending_request() {
5284        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5285        runtime.run_script(
5286            r#"
5287        var controller = new AbortController();
5288        fetch("https://example.com/slow", { signal: controller.signal })
5289            .then(function () {
5290                document.getElementById("r").setAttribute("data-result", "resolved");
5291            })
5292            .catch(function (error) {
5293                document.getElementById("r").setAttribute("data-result", "rejected:" + error.name);
5294            });
5295        controller.abort();
5296        "#,
5297            None,
5298        );
5299        runtime.perform_microtask_checkpoint_public();
5300        let requests = runtime.take_fetch_requests();
5301        assert!(
5302            requests.is_empty(),
5303            "aborted fetch must be removed from the queue"
5304        );
5305        let r = dom.get_element_by_id("r").unwrap();
5306        assert_eq!(
5307            r.borrow().value.get_attr("data-result"),
5308            Some("rejected:AbortError")
5309        );
5310    }
5311
5312    #[test]
5313    fn blob_and_file_reader_round_trip() {
5314        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5315        runtime.run_script(
5316            r#"
5317        var out = [];
5318        var blob = new Blob(["hello", " ", "world"], { type: "text/plain" });
5319        out.push("size:" + blob.size);
5320        out.push("type:" + blob.type);
5321        blob.text().then(function (text) { out.push("text:" + text); });
5322        blob.arrayBuffer().then(function (buffer) { out.push("bytes:" + buffer.byteLength); });
5323        var sliced = blob.slice(0, 5);
5324        sliced.text().then(function (text) { out.push("slice:" + text); });
5325        var reader = new FileReader();
5326        var loaded = null;
5327        reader.onload = function (event) { loaded = event.type; };
5328        reader.readAsText(blob);
5329        out.push("read:" + reader.result);
5330        out.push("loadEvent:" + loaded);
5331        document.getElementById("r").setAttribute("data-out", out.join(";"));
5332        window.__blob_out = out;
5333        "#,
5334            None,
5335        );
5336        runtime.perform_microtask_checkpoint_public();
5337        // Re-run to capture the results produced by microtasks.
5338        runtime.run_script(
5339            r#"
5340        document.getElementById("r").setAttribute("data-out", window.__blob_out.join(";"));
5341        "#,
5342            None,
5343        );
5344        let r = dom.get_element_by_id("r").unwrap();
5345        let observed = r
5346            .borrow()
5347            .value
5348            .get_attr("data-out")
5349            .unwrap_or_default()
5350            .to_string();
5351        assert!(observed.contains("size:11"), "got {observed}");
5352        assert!(observed.contains("type:text/plain"), "got {observed}");
5353        assert!(observed.contains("text:hello world"), "got {observed}");
5354        assert!(observed.contains("bytes:11"), "got {observed}");
5355        assert!(observed.contains("slice:hello"), "got {observed}");
5356        assert!(observed.contains("read:hello world"), "got {observed}");
5357        assert!(observed.contains("loadEvent:load"), "got {observed}");
5358    }
5359
5360    #[test]
5361    fn response_constructor_produces_a_working_response() {
5362        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5363        runtime.run_script(
5364            r#"
5365        var out = [];
5366        var response = new Response("body text", { status: 201, statusText: "Created" });
5367        out.push("status:" + response.status);
5368        out.push("statusText:" + response.statusText);
5369        out.push("ok:" + response.ok);
5370        response.text().then(function (text) { out.push("text:" + text); });
5371        var jsonResponse = new Response("{}", { headers: { "content-type": "application/json" } });
5372        jsonResponse.json().then(function (value) { out.push("json:" + (typeof value === "object")); });
5373        window.__response_out = out;
5374        "#, None);
5375        runtime.perform_microtask_checkpoint_public();
5376        runtime.run_script(
5377            r#"
5378        document.getElementById("r").setAttribute("data-out", window.__response_out.join(";"));
5379        "#,
5380            None,
5381        );
5382        let r = dom.get_element_by_id("r").unwrap();
5383        let observed = r
5384            .borrow()
5385            .value
5386            .get_attr("data-out")
5387            .unwrap_or_default()
5388            .to_string();
5389        assert!(observed.contains("status:201"), "got {observed}");
5390        assert!(observed.contains("statusText:Created"), "got {observed}");
5391        assert!(observed.contains("ok:true"), "got {observed}");
5392        assert!(observed.contains("text:body text"), "got {observed}");
5393        assert!(observed.contains("json:true"), "got {observed}");
5394    }
5395
5396    #[test]
5397    fn custom_element_registry_and_shadow_root_are_instanciable_checks() {
5398        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5399        runtime.run_script(
5400            r#"
5401        var out = [];
5402        out.push("ceType:" + typeof CustomElementRegistry);
5403        out.push("instance:" + (customElements instanceof CustomElementRegistry));
5404        out.push("define:" + typeof customElements.define);
5405        out.push("whenDefined:" + typeof customElements.whenDefined);
5406        out.push("srType:" + typeof ShadowRoot);
5407        out.push("hostHook:" + typeof ShadowRoot.prototype);
5408        var host = document.getElementById("r");
5409        var shadow = host.attachShadow({ mode: "open" });
5410        out.push("attach:" + (shadow instanceof ShadowRoot));
5411        try { new ShadowRoot(); out.push("ctor:no-throw"); }
5412        catch (e) { out.push("ctor:" + (e instanceof TypeError)); }
5413        document.getElementById("r").setAttribute("data-out", out.join(";"));
5414        "#,
5415            None,
5416        );
5417        let r = dom.get_element_by_id("r").unwrap();
5418        assert_eq!(
5419            r.borrow().value.get_attr("data-out"),
5420            Some(
5421                "ceType:function;instance:true;define:function;whenDefined:function;srType:function;hostHook:object;attach:true;ctor:true"
5422            )
5423        );
5424    }
5425
5426    #[test]
5427    fn promise_finally_race_any_and_all_settled_compose() {
5428        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5429        runtime.run_script(
5430            r#"
5431        window.__out = [];
5432        function log(s) { window.__out.push(s); }
5433        Promise.race([new Promise(function (_, rej) { rej("early"); })])
5434            .catch(function (e) { log("race:" + e); });
5435        Promise.allSettled([Promise.resolve(1), Promise.reject("bad")])
5436            .then(function (rs) {
5437                log("settled:" + rs[0].status + ":" + rs[1].status + ":" + rs[1].reason);
5438            });
5439        Promise.any([Promise.reject("a"), Promise.resolve("ok")])
5440            .then(function (v) { log("any:" + v); });
5441        Promise.any([Promise.reject("x"), Promise.reject("y")])
5442            .catch(function (e) {
5443                log("anyReject:" + e.name + ":" + e.errors.length + ":" + e.errors[0]);
5444            });
5445        Promise.resolve("base")
5446            .finally(function () { log("finally:ran"); })
5447            .then(function (v) { log("finally:" + v); });
5448        Promise.reject("original")
5449            .finally(function () { log("finallyReject:ran"); })
5450            .catch(function (e) { log("finallyReject:" + e); });
5451        "#,
5452            None,
5453        );
5454        runtime.perform_microtask_checkpoint_public();
5455        runtime.run_script(
5456            r#"
5457        document.getElementById("r").setAttribute("data-out", window.__out.join(";"));
5458        "#,
5459            None,
5460        );
5461        let r = dom.get_element_by_id("r").unwrap();
5462        assert_eq!(
5463            r.borrow().value.get_attr("data-out"),
5464            Some(
5465                "finally:ran;finallyReject:ran;race:early;settled:fulfilled:rejected:bad;any:ok;anyReject:AggregateError:2:x;finally:base;finallyReject:original"
5466            )
5467        );
5468    }
5469
5470    #[test]
5471    fn promise_rejections_and_host_errors_preserve_error_instances() {
5472        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5473        runtime.run_script(
5474            r#"
5475        window.__out = [];
5476        function log(s) { window.__out.push(s); }
5477        Promise.resolve(1)
5478            .then(function () { throw new TypeError("boom"); })
5479            .catch(function (e) {
5480                log("thrown:" + (e instanceof TypeError) + ":" + e.name + ":" + e.message);
5481            });
5482        Promise.reject(new RangeError("nope"))
5483            .catch(function (e) {
5484                log("rejected:" + (e instanceof RangeError) + ":" + e.message);
5485            });
5486        try { JSON.parse("{not json"); }
5487        catch (e) { log("hostSyntax:" + (e instanceof SyntaxError) + ":" + e.name); }
5488        try { new ShadowRoot(); }
5489        catch (e) { log("hostType:" + (e instanceof TypeError)); }
5490        "#,
5491            None,
5492        );
5493        runtime.perform_microtask_checkpoint_public();
5494        runtime.run_script(
5495            r#"
5496        document.getElementById("r").setAttribute("data-out", window.__out.join(";"));
5497        "#,
5498            None,
5499        );
5500        let r = dom.get_element_by_id("r").unwrap();
5501        assert_eq!(
5502            r.borrow().value.get_attr("data-out"),
5503            Some(
5504                "hostSyntax:true:SyntaxError;hostType:true;rejected:true:nope;thrown:true:TypeError:boom"
5505            )
5506        );
5507    }
5508
5509    #[test]
5510    fn try_run_script_distinguishes_parse_failures_from_clean_runs() {
5511        let (mut runtime, _dom) = runtime_from_html(r#""#);
5512        assert!(runtime.try_run_script("var x = 1; 1 + 1;", None).is_ok());
5513        let err = runtime
5514            .try_run_script("var x = ;", None)
5515            .expect_err("syntax error must surface");
5516        assert!(!err.is_empty(), "parse error still needs a message");
5517        // A host error thrown by the script itself also surfaces.
5518        runtime
5519            .try_run_script("throw new TypeError('boom');", None)
5520            .expect_err("thrown value must surface");
5521    }
5522
5523    #[test]
5524    fn drain_microtasks_settles_job_queued_callbacks() {
5525        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5526        runtime.run_script(
5527            r#"queueMicrotask(function () {
5528                document.getElementById("r").setAttribute("data-out", "drained");
5529            });"#,
5530            None,
5531        );
5532        runtime.drain_microtasks();
5533        let r = dom.get_element_by_id("r").unwrap();
5534        assert_eq!(
5535            r.borrow().value.get_attr("data-out"),
5536            Some("drained"),
5537            "queueMicrotask work must settle without a timer"
5538        );
5539    }
5540
5541    #[test]
5542    fn element_prototype_exposes_selector_and_adjacency_helpers() {
5543        let (mut runtime, dom) = runtime_from_html(
5544            r#"<div id="host" class="outer"><span id="target" class="inner"></span></div>"#,
5545        );
5546        runtime.run_script(
5547            r#"
5548            var out = [];
5549            out.push("matches:" + Element.prototype.matches.call(document.getElementById("target"), ".inner"));
5550            out.push("closest:" + Element.prototype.closest.call(document.getElementById("target"), ".outer").id);
5551            out.push("insert:" + typeof Element.prototype.insertAdjacentElement);
5552            var added = document.getElementById("target").insertAdjacentElement("beforebegin", document.createElement("i"));
5553            out.push("inserted:" + (added && added.tagName));
5554            document.getElementById("host").setAttribute("data-out", out.join(";"));
5555            "#, None);
5556        let host = dom.get_element_by_id("host").unwrap();
5557        assert_eq!(
5558            host.borrow().value.get_attr("data-out"),
5559            Some("matches:true;closest:host;insert:function;inserted:I")
5560        );
5561    }
5562
5563    #[test]
5564    fn array_buffer_applies_to_index_length_semantics() {
5565        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5566        runtime.run_script(
5567            r#"
5568            var out = [];
5569            out.push("empty:" + new ArrayBuffer().byteLength);
5570            out.push("undefined:" + new ArrayBuffer(undefined).byteLength);
5571            out.push("nan:" + new ArrayBuffer(NaN).byteLength);
5572            out.push("trunc:" + new ArrayBuffer(2.9).byteLength);
5573            try {
5574                new ArrayBuffer(-1);
5575                out.push("negative:no-throw");
5576            } catch (e) {
5577                out.push("negative:" + e.name);
5578            }
5579            document.getElementById("r").setAttribute("data-out", out.join(";"));
5580            "#,
5581            None,
5582        );
5583        let r = dom.get_element_by_id("r").unwrap();
5584        assert_eq!(
5585            r.borrow().value.get_attr("data-out"),
5586            Some("empty:0;undefined:0;nan:0;trunc:2;negative:RangeError")
5587        );
5588    }
5589
5590    #[test]
5591    fn host_constructed_encoders_and_views_pass_instanceof() {
5592        let (mut runtime, dom) = runtime_from_html(r#"<div id="r"></div>"#);
5593        runtime.run_script(
5594            r#"
5595            var results = [];
5596            var enc = new TextEncoder();
5597            var dec = new TextDecoder();
5598            var buf = new ArrayBuffer(8);
5599            var view = new Uint8Array(buf);
5600            var dv = new DataView(buf);
5601            results.push("enc:" + (enc instanceof TextEncoder));
5602            results.push("dec:" + (dec instanceof TextDecoder));
5603            results.push("decNotEnc:" + (dec instanceof TextEncoder));
5604            results.push("buf:" + (buf instanceof ArrayBuffer));
5605            results.push("view:" + (view instanceof Uint8Array));
5606            results.push("dv:" + (dv instanceof DataView));
5607            results.push("plain:" + ({} instanceof TextEncoder));
5608            document.getElementById("r").setAttribute("data-out", results.join(";"));
5609            "#,
5610            None,
5611        );
5612        let r = dom.get_element_by_id("r").unwrap();
5613        assert_eq!(
5614            r.borrow().value.get_attr("data-out"),
5615            Some("enc:true;dec:true;decNotEnc:false;buf:true;view:true;dv:true;plain:false")
5616        );
5617    }
5618}