we (web engine): Experimental web browser project to understand the limits of Claude
1//! DOM-JS bridge: exposes the DOM `Document` to JavaScript.
2//!
3//! Registers the `document` global object and provides native functions for
4//! element access, query methods, factory methods, and element wrapper
5//! properties.
6
7use crate::builtins::{make_native, set_builtin_prop};
8use crate::gc::{Gc, GcRef};
9use crate::vm::*;
10use std::rc::Rc;
11use we_css::parser::Parser as CssParser;
12use we_dom::{Document, NodeData, NodeId};
13use we_html::parse_html;
14use we_style::matching::matches_selector_list;
15
16/// Native callback type alias.
17type NativeMethod = (
18 &'static str,
19 fn(&[Value], &mut NativeContext) -> Result<Value, RuntimeError>,
20);
21
22// ── Internal property key for storing NodeId on wrapper objects ──────
23
24const NODE_ID_KEY: &str = "__node_id__";
25
26// ── Node wrapper creation ───────────────────────────────────────────
27
28/// Get or create a JS wrapper object for a DOM node. Returns the same
29/// `GcRef` for the same `NodeId` (wrapper identity).
30fn get_or_create_wrapper(
31 node_id: NodeId,
32 gc: &mut Gc<HeapObject>,
33 bridge: &DomBridge,
34 object_proto: Option<GcRef>,
35) -> GcRef {
36 let idx = node_id.index();
37 if let Some(&existing) = bridge.node_wrappers.borrow().get(&idx) {
38 // Verify the GcRef is still alive.
39 if gc.get(existing).is_some() {
40 return existing;
41 }
42 }
43
44 let doc = bridge.document.borrow();
45 let mut data = ObjectData::new();
46 if let Some(proto) = object_proto {
47 data.prototype = Some(proto);
48 }
49
50 // Store the NodeId index as an internal property.
51 data.properties.insert(
52 NODE_ID_KEY.to_string(),
53 Property::builtin(Value::Number(idx as f64)),
54 );
55
56 // Populate properties based on node type.
57 match doc.node_data(node_id) {
58 NodeData::Element {
59 tag_name,
60 attributes,
61 ..
62 } => {
63 let upper_tag = tag_name.to_ascii_uppercase();
64 data.properties.insert(
65 "tagName".to_string(),
66 Property::builtin(Value::String(upper_tag.clone())),
67 );
68 data.properties.insert(
69 "nodeName".to_string(),
70 Property::builtin(Value::String(upper_tag)),
71 );
72 data.properties.insert(
73 "nodeType".to_string(),
74 Property::builtin(Value::Number(1.0)),
75 );
76
77 // id attribute
78 let id_val = attributes
79 .iter()
80 .find(|a| a.name == "id")
81 .map(|a| Value::String(a.value.clone()))
82 .unwrap_or(Value::String(String::new()));
83 data.properties
84 .insert("id".to_string(), Property::builtin(id_val));
85
86 // className attribute
87 let class_val = attributes
88 .iter()
89 .find(|a| a.name == "class")
90 .map(|a| Value::String(a.value.clone()))
91 .unwrap_or(Value::String(String::new()));
92 data.properties
93 .insert("className".to_string(), Property::builtin(class_val));
94 }
95 NodeData::Text { .. } => {
96 data.properties.insert(
97 "nodeName".to_string(),
98 Property::builtin(Value::String("#text".to_string())),
99 );
100 data.properties.insert(
101 "nodeType".to_string(),
102 Property::builtin(Value::Number(3.0)),
103 );
104 }
105 NodeData::Comment { .. } => {
106 data.properties.insert(
107 "nodeName".to_string(),
108 Property::builtin(Value::String("#comment".to_string())),
109 );
110 data.properties.insert(
111 "nodeType".to_string(),
112 Property::builtin(Value::Number(8.0)),
113 );
114 }
115 NodeData::Document => {
116 data.properties.insert(
117 "nodeName".to_string(),
118 Property::builtin(Value::String("#document".to_string())),
119 );
120 data.properties.insert(
121 "nodeType".to_string(),
122 Property::builtin(Value::Number(9.0)),
123 );
124 }
125 }
126
127 let gc_ref = gc.alloc(HeapObject::Object(data));
128
129 // Register DOM methods on the wrapper based on node type.
130 register_node_methods(gc, gc_ref, &doc, node_id);
131
132 bridge.node_wrappers.borrow_mut().insert(idx, gc_ref);
133 gc_ref
134}
135
136// ── Helper: walk DOM tree ───────────────────────────────────────────
137
138fn walk_tree(doc: &Document, root: NodeId, visitor: &mut dyn FnMut(NodeId) -> bool) {
139 let mut stack = vec![root];
140 while let Some(node) = stack.pop() {
141 if visitor(node) {
142 return;
143 }
144 // Push children in reverse order so first child is visited first.
145 let mut children: Vec<NodeId> = doc.children(node).collect();
146 children.reverse();
147 stack.extend(children);
148 }
149}
150
151// ── Helper: make an array of wrapper Values ─────────────────────────
152
153fn make_wrapper_array(
154 nodes: &[NodeId],
155 gc: &mut Gc<HeapObject>,
156 bridge: &DomBridge,
157 object_proto: Option<GcRef>,
158) -> Value {
159 let mut obj = ObjectData::new();
160 for (i, &nid) in nodes.iter().enumerate() {
161 let wrapper = get_or_create_wrapper(nid, gc, bridge, object_proto);
162 obj.properties
163 .insert(i.to_string(), Property::data(Value::Object(wrapper)));
164 }
165 obj.properties.insert(
166 "length".to_string(),
167 Property {
168 value: Value::Number(nodes.len() as f64),
169 writable: true,
170 enumerable: false,
171 configurable: false,
172 },
173 );
174 Value::Object(gc.alloc(HeapObject::Object(obj)))
175}
176
177// ── Document global setup ───────────────────────────────────────────
178
179/// Register the `document` global object on the VM. Called from
180/// `Vm::attach_document`.
181pub fn init_document_object(vm: &mut Vm) {
182 let mut data = ObjectData::new();
183 if let Some(proto) = vm.object_prototype {
184 data.prototype = Some(proto);
185 }
186
187 // nodeType 9 = Document
188 data.properties.insert(
189 "nodeType".to_string(),
190 Property::builtin(Value::Number(9.0)),
191 );
192 data.properties.insert(
193 "nodeName".to_string(),
194 Property::builtin(Value::String("#document".to_string())),
195 );
196
197 // Set document.title from the DOM.
198 if let Some(bridge) = &vm.dom_bridge {
199 let doc = bridge.document.borrow();
200 let title = find_title_text(&doc);
201 data.properties
202 .insert("title".to_string(), Property::builtin(Value::String(title)));
203 }
204
205 let doc_ref = vm.gc.alloc(HeapObject::Object(data));
206
207 // Set documentElement, head, body as wrapper properties.
208 if let Some(bridge) = &vm.dom_bridge {
209 let (html_id, head_id, body_id) = {
210 let doc = bridge.document.borrow();
211 find_structural_elements(&doc)
212 };
213 if let Some(html) = html_id {
214 let wrapper = get_or_create_wrapper(html, &mut vm.gc, bridge, vm.object_prototype);
215 set_builtin_prop(
216 &mut vm.gc,
217 doc_ref,
218 "documentElement",
219 Value::Object(wrapper),
220 );
221 }
222 if let Some(head) = head_id {
223 let wrapper = get_or_create_wrapper(head, &mut vm.gc, bridge, vm.object_prototype);
224 set_builtin_prop(&mut vm.gc, doc_ref, "head", Value::Object(wrapper));
225 }
226 if let Some(body) = body_id {
227 let wrapper = get_or_create_wrapper(body, &mut vm.gc, bridge, vm.object_prototype);
228 set_builtin_prop(&mut vm.gc, doc_ref, "body", Value::Object(wrapper));
229 }
230 }
231
232 // Register methods on the document object.
233 let methods: &[NativeMethod] = &[
234 ("getElementById", doc_get_element_by_id),
235 ("getElementsByTagName", doc_get_elements_by_tag_name),
236 ("getElementsByClassName", doc_get_elements_by_class_name),
237 ("querySelector", doc_query_selector),
238 ("querySelectorAll", doc_query_selector_all),
239 ("createElement", doc_create_element),
240 ("createTextNode", doc_create_text_node),
241 ];
242 for &(name, callback) in methods {
243 let func = make_native(&mut vm.gc, name, callback);
244 set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func));
245 }
246
247 vm.set_global("document", Value::Object(doc_ref));
248}
249
250/// Find `<html>`, `<head>`, and `<body>` elements in the document.
251fn find_structural_elements(doc: &Document) -> (Option<NodeId>, Option<NodeId>, Option<NodeId>) {
252 let mut html = None;
253 let mut head = None;
254 let mut body = None;
255
256 for child in doc.children(doc.root()) {
257 if let NodeData::Element { tag_name, .. } = doc.node_data(child) {
258 if tag_name.eq_ignore_ascii_case("html") {
259 html = Some(child);
260 for inner in doc.children(child) {
261 if let NodeData::Element { tag_name, .. } = doc.node_data(inner) {
262 if tag_name.eq_ignore_ascii_case("head") {
263 head = Some(inner);
264 } else if tag_name.eq_ignore_ascii_case("body") {
265 body = Some(inner);
266 }
267 }
268 }
269 break;
270 }
271 }
272 }
273
274 (html, head, body)
275}
276
277/// Extract the text content of `<title>` from the document.
278fn find_title_text(doc: &Document) -> String {
279 let mut result = String::new();
280 walk_tree(doc, doc.root(), &mut |node| {
281 if let NodeData::Element { tag_name, .. } = doc.node_data(node) {
282 if tag_name.eq_ignore_ascii_case("title") {
283 // Collect text children.
284 for child in doc.children(node) {
285 if let Some(text) = doc.text_content(child) {
286 result.push_str(text);
287 }
288 }
289 return true; // stop walking
290 }
291 }
292 false
293 });
294 result
295}
296
297// ── Document methods ────────────────────────────────────────────────
298
299fn doc_get_element_by_id(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
300 let id = args
301 .first()
302 .map(|v| v.to_js_string(ctx.gc))
303 .unwrap_or_default();
304
305 let bridge = match ctx.dom_bridge {
306 Some(b) => b,
307 None => return Ok(Value::Null),
308 };
309
310 let found = {
311 let doc = bridge.document.borrow();
312 let mut found = None;
313 walk_tree(&doc, doc.root(), &mut |node| {
314 if let Some(attr_val) = doc.get_attribute(node, "id") {
315 if attr_val == id {
316 found = Some(node);
317 return true;
318 }
319 }
320 false
321 });
322 found
323 };
324
325 match found {
326 Some(node_id) => {
327 let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None);
328 Ok(Value::Object(wrapper))
329 }
330 None => Ok(Value::Null),
331 }
332}
333
334fn doc_get_elements_by_tag_name(
335 args: &[Value],
336 ctx: &mut NativeContext,
337) -> Result<Value, RuntimeError> {
338 let tag = args
339 .first()
340 .map(|v| v.to_js_string(ctx.gc))
341 .unwrap_or_default();
342
343 let bridge = match ctx.dom_bridge {
344 Some(b) => b,
345 None => return Ok(make_empty_array(ctx.gc)),
346 };
347
348 let matches = {
349 let doc = bridge.document.borrow();
350 let mut matches = Vec::new();
351 walk_tree(&doc, doc.root(), &mut |node| {
352 if let NodeData::Element { tag_name: tn, .. } = doc.node_data(node) {
353 if tn.eq_ignore_ascii_case(&tag) || tag == "*" {
354 matches.push(node);
355 }
356 }
357 false
358 });
359 matches
360 };
361
362 Ok(make_wrapper_array(&matches, ctx.gc, bridge, None))
363}
364
365fn doc_get_elements_by_class_name(
366 args: &[Value],
367 ctx: &mut NativeContext,
368) -> Result<Value, RuntimeError> {
369 let class_name = args
370 .first()
371 .map(|v| v.to_js_string(ctx.gc))
372 .unwrap_or_default();
373
374 let bridge = match ctx.dom_bridge {
375 Some(b) => b,
376 None => return Ok(make_empty_array(ctx.gc)),
377 };
378
379 let matches = {
380 let doc = bridge.document.borrow();
381 let mut matches = Vec::new();
382 walk_tree(&doc, doc.root(), &mut |node| {
383 if let Some(cls) = doc.get_attribute(node, "class") {
384 if cls.split_whitespace().any(|c| c == class_name) {
385 matches.push(node);
386 }
387 }
388 false
389 });
390 matches
391 };
392
393 Ok(make_wrapper_array(&matches, ctx.gc, bridge, None))
394}
395
396fn doc_query_selector(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
397 let selector_str = args
398 .first()
399 .map(|v| v.to_js_string(ctx.gc))
400 .unwrap_or_default();
401
402 let bridge = match ctx.dom_bridge {
403 Some(b) => b,
404 None => return Ok(Value::Null),
405 };
406
407 let selector_list = CssParser::parse_selectors(&selector_str);
408 if selector_list.selectors.is_empty() {
409 return Ok(Value::Null);
410 }
411
412 let found = {
413 let doc = bridge.document.borrow();
414 let mut found = None;
415 walk_tree(&doc, doc.root(), &mut |node| {
416 if matches!(doc.node_data(node), NodeData::Element { .. })
417 && matches_selector_list(&doc, node, &selector_list)
418 {
419 found = Some(node);
420 return true;
421 }
422 false
423 });
424 found
425 };
426
427 match found {
428 Some(node_id) => {
429 let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None);
430 Ok(Value::Object(wrapper))
431 }
432 None => Ok(Value::Null),
433 }
434}
435
436fn doc_query_selector_all(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
437 let selector_str = args
438 .first()
439 .map(|v| v.to_js_string(ctx.gc))
440 .unwrap_or_default();
441
442 let bridge = match ctx.dom_bridge {
443 Some(b) => b,
444 None => return Ok(make_empty_array(ctx.gc)),
445 };
446
447 let selector_list = CssParser::parse_selectors(&selector_str);
448
449 let matches = {
450 let doc = bridge.document.borrow();
451 let mut matches = Vec::new();
452 walk_tree(&doc, doc.root(), &mut |node| {
453 if matches!(doc.node_data(node), NodeData::Element { .. })
454 && matches_selector_list(&doc, node, &selector_list)
455 {
456 matches.push(node);
457 }
458 false
459 });
460 matches
461 };
462
463 Ok(make_wrapper_array(&matches, ctx.gc, bridge, None))
464}
465
466fn doc_create_element(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
467 let tag = args
468 .first()
469 .map(|v| v.to_js_string(ctx.gc))
470 .unwrap_or_default();
471
472 let bridge = match ctx.dom_bridge {
473 Some(b) => b,
474 None => return Err(RuntimeError::type_error("no document attached")),
475 };
476
477 let node_id = bridge.document.borrow_mut().create_element(&tag);
478 let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None);
479 Ok(Value::Object(wrapper))
480}
481
482fn doc_create_text_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
483 let text = args
484 .first()
485 .map(|v| v.to_js_string(ctx.gc))
486 .unwrap_or_default();
487
488 let bridge = match ctx.dom_bridge {
489 Some(b) => b,
490 None => return Err(RuntimeError::type_error("no document attached")),
491 };
492
493 let node_id = bridge.document.borrow_mut().create_text(&text);
494 let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None);
495 Ok(Value::Object(wrapper))
496}
497
498/// Create an empty JS array.
499fn make_empty_array(gc: &mut Gc<HeapObject>) -> Value {
500 let mut obj = ObjectData::new();
501 obj.properties.insert(
502 "length".to_string(),
503 Property {
504 value: Value::Number(0.0),
505 writable: true,
506 enumerable: false,
507 configurable: false,
508 },
509 );
510 Value::Object(gc.alloc(HeapObject::Object(obj)))
511}
512
513// ── Method registration on wrappers ──────────────────────────────────
514
515/// Register DOM methods on a wrapper object based on node type.
516fn register_node_methods(gc: &mut Gc<HeapObject>, wrapper: GcRef, doc: &Document, node_id: NodeId) {
517 // Methods available on all node types.
518 let node_methods: &[NativeMethod] = &[
519 ("appendChild", node_append_child),
520 ("removeChild", node_remove_child),
521 ("insertBefore", node_insert_before),
522 ("replaceChild", node_replace_child),
523 ("cloneNode", node_clone_node),
524 ("hasChildNodes", node_has_child_nodes),
525 ("addEventListener", event_target_add_listener),
526 ("removeEventListener", event_target_remove_listener),
527 ("dispatchEvent", event_target_dispatch_event),
528 ];
529 for &(name, callback) in node_methods {
530 let func = make_native(gc, name, callback);
531 set_builtin_prop(gc, wrapper, name, Value::Function(func));
532 }
533
534 // Methods available only on Element nodes.
535 if matches!(doc.node_data(node_id), NodeData::Element { .. }) {
536 let element_methods: &[NativeMethod] = &[
537 ("getAttribute", element_get_attribute),
538 ("setAttribute", element_set_attribute),
539 ("removeAttribute", element_remove_attribute),
540 ("hasAttribute", element_has_attribute),
541 ];
542 for &(name, callback) in element_methods {
543 let func = make_native(gc, name, callback);
544 set_builtin_prop(gc, wrapper, name, Value::Function(func));
545 }
546 }
547}
548
549// ── Helper: extract NodeId from a wrapper ───────────────────────────
550
551fn get_node_id(gc: &Gc<HeapObject>, wrapper: GcRef) -> Option<NodeId> {
552 match gc.get(wrapper) {
553 Some(HeapObject::Object(data)) => match data.properties.get(NODE_ID_KEY) {
554 Some(Property {
555 value: Value::Number(n),
556 ..
557 }) => Some(NodeId::from_index(*n as usize)),
558 _ => None,
559 },
560 _ => None,
561 }
562}
563
564/// Extract a `NodeId` from a JS Value that should be a DOM wrapper object.
565fn value_to_node_id(gc: &Gc<HeapObject>, val: &Value) -> Option<NodeId> {
566 match val {
567 Value::Object(r) => get_node_id(gc, *r),
568 _ => None,
569 }
570}
571
572// ── Node manipulation methods ───────────────────────────────────────
573
574fn node_append_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
575 let bridge = ctx
576 .dom_bridge
577 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
578 let parent_id = match &ctx.this {
579 Value::Object(r) => get_node_id(ctx.gc, *r),
580 _ => None,
581 }
582 .ok_or_else(|| RuntimeError::type_error("appendChild called on non-node"))?;
583 let child_val = args
584 .first()
585 .ok_or_else(|| RuntimeError::type_error("appendChild requires an argument"))?;
586 let child_id = value_to_node_id(ctx.gc, child_val)
587 .ok_or_else(|| RuntimeError::type_error("appendChild argument is not a node"))?;
588
589 bridge
590 .document
591 .borrow_mut()
592 .append_child(parent_id, child_id);
593 Ok(child_val.clone())
594}
595
596fn node_remove_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
597 let bridge = ctx
598 .dom_bridge
599 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
600 let parent_id = match &ctx.this {
601 Value::Object(r) => get_node_id(ctx.gc, *r),
602 _ => None,
603 }
604 .ok_or_else(|| RuntimeError::type_error("removeChild called on non-node"))?;
605 let child_val = args
606 .first()
607 .ok_or_else(|| RuntimeError::type_error("removeChild requires an argument"))?;
608 let child_id = value_to_node_id(ctx.gc, child_val)
609 .ok_or_else(|| RuntimeError::type_error("removeChild argument is not a node"))?;
610
611 bridge
612 .document
613 .borrow_mut()
614 .remove_child(parent_id, child_id);
615 Ok(child_val.clone())
616}
617
618fn node_insert_before(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
619 let bridge = ctx
620 .dom_bridge
621 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
622 let parent_id = match &ctx.this {
623 Value::Object(r) => get_node_id(ctx.gc, *r),
624 _ => None,
625 }
626 .ok_or_else(|| RuntimeError::type_error("insertBefore called on non-node"))?;
627 let new_node_val = args
628 .first()
629 .ok_or_else(|| RuntimeError::type_error("insertBefore requires two arguments"))?;
630 let new_node_id = value_to_node_id(ctx.gc, new_node_val)
631 .ok_or_else(|| RuntimeError::type_error("insertBefore: first argument is not a node"))?;
632
633 let ref_val = args.get(1).cloned().unwrap_or(Value::Null);
634 if matches!(ref_val, Value::Null) {
635 // insertBefore with null reference = appendChild
636 bridge
637 .document
638 .borrow_mut()
639 .append_child(parent_id, new_node_id);
640 } else {
641 let ref_id = value_to_node_id(ctx.gc, &ref_val).ok_or_else(|| {
642 RuntimeError::type_error("insertBefore: second argument is not a node")
643 })?;
644 bridge
645 .document
646 .borrow_mut()
647 .insert_before(parent_id, new_node_id, ref_id);
648 }
649 Ok(new_node_val.clone())
650}
651
652fn node_replace_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
653 let bridge = ctx
654 .dom_bridge
655 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
656 let parent_id = match &ctx.this {
657 Value::Object(r) => get_node_id(ctx.gc, *r),
658 _ => None,
659 }
660 .ok_or_else(|| RuntimeError::type_error("replaceChild called on non-node"))?;
661 let new_child_val = args
662 .first()
663 .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?;
664 let new_child_id = value_to_node_id(ctx.gc, new_child_val)
665 .ok_or_else(|| RuntimeError::type_error("replaceChild: first argument is not a node"))?;
666 let old_child_val = args
667 .get(1)
668 .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?;
669 let old_child_id = value_to_node_id(ctx.gc, old_child_val)
670 .ok_or_else(|| RuntimeError::type_error("replaceChild: second argument is not a node"))?;
671
672 bridge
673 .document
674 .borrow_mut()
675 .replace_child(parent_id, new_child_id, old_child_id);
676 Ok(old_child_val.clone())
677}
678
679fn node_clone_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
680 let bridge = ctx
681 .dom_bridge
682 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
683 let node_id = match &ctx.this {
684 Value::Object(r) => get_node_id(ctx.gc, *r),
685 _ => None,
686 }
687 .ok_or_else(|| RuntimeError::type_error("cloneNode called on non-node"))?;
688
689 let deep = args.first().map(|v| v.to_boolean()).unwrap_or(false);
690
691 let cloned_id = bridge.document.borrow_mut().clone_node(node_id, deep);
692 let wrapper = get_or_create_wrapper(cloned_id, ctx.gc, bridge, None);
693 Ok(Value::Object(wrapper))
694}
695
696fn node_has_child_nodes(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
697 let _ = args;
698 let bridge = ctx
699 .dom_bridge
700 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
701 let node_id = match &ctx.this {
702 Value::Object(r) => get_node_id(ctx.gc, *r),
703 _ => None,
704 }
705 .ok_or_else(|| RuntimeError::type_error("hasChildNodes called on non-node"))?;
706
707 let has = bridge.document.borrow().has_child_nodes(node_id);
708 Ok(Value::Boolean(has))
709}
710
711// ── Attribute methods ───────────────────────────────────────────────
712
713fn element_get_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
714 let bridge = ctx
715 .dom_bridge
716 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
717 let node_id = match &ctx.this {
718 Value::Object(r) => get_node_id(ctx.gc, *r),
719 _ => None,
720 }
721 .ok_or_else(|| RuntimeError::type_error("getAttribute called on non-element"))?;
722 let name = args
723 .first()
724 .map(|v| v.to_js_string(ctx.gc))
725 .unwrap_or_default();
726
727 let doc = bridge.document.borrow();
728 match doc.get_attribute(node_id, &name) {
729 Some(val) => Ok(Value::String(val.to_string())),
730 None => Ok(Value::Null),
731 }
732}
733
734fn element_set_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
735 let bridge = ctx
736 .dom_bridge
737 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
738 let node_id = match &ctx.this {
739 Value::Object(r) => get_node_id(ctx.gc, *r),
740 _ => None,
741 }
742 .ok_or_else(|| RuntimeError::type_error("setAttribute called on non-element"))?;
743 let name = args
744 .first()
745 .map(|v| v.to_js_string(ctx.gc))
746 .unwrap_or_default();
747 let value = args
748 .get(1)
749 .map(|v| v.to_js_string(ctx.gc))
750 .unwrap_or_default();
751
752 bridge
753 .document
754 .borrow_mut()
755 .set_attribute(node_id, &name, &value);
756
757 // Sync special attributes to wrapper properties.
758 if let Value::Object(wrapper) = &ctx.this {
759 if name == "id" {
760 set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(value.clone()));
761 } else if name == "class" {
762 set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(value.clone()));
763 }
764 }
765
766 Ok(Value::Undefined)
767}
768
769fn element_remove_attribute(
770 args: &[Value],
771 ctx: &mut NativeContext,
772) -> Result<Value, RuntimeError> {
773 let bridge = ctx
774 .dom_bridge
775 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
776 let node_id = match &ctx.this {
777 Value::Object(r) => get_node_id(ctx.gc, *r),
778 _ => None,
779 }
780 .ok_or_else(|| RuntimeError::type_error("removeAttribute called on non-element"))?;
781 let name = args
782 .first()
783 .map(|v| v.to_js_string(ctx.gc))
784 .unwrap_or_default();
785
786 bridge
787 .document
788 .borrow_mut()
789 .remove_attribute(node_id, &name);
790
791 // Sync to wrapper.
792 if let Value::Object(wrapper) = &ctx.this {
793 if name == "id" {
794 set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(String::new()));
795 } else if name == "class" {
796 set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(String::new()));
797 }
798 }
799
800 Ok(Value::Undefined)
801}
802
803fn element_has_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
804 let bridge = ctx
805 .dom_bridge
806 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
807 let node_id = match &ctx.this {
808 Value::Object(r) => get_node_id(ctx.gc, *r),
809 _ => None,
810 }
811 .ok_or_else(|| RuntimeError::type_error("hasAttribute called on non-element"))?;
812 let name = args
813 .first()
814 .map(|v| v.to_js_string(ctx.gc))
815 .unwrap_or_default();
816
817 let doc = bridge.document.borrow();
818 let has = doc.get_attribute(node_id, &name).is_some();
819 Ok(Value::Boolean(has))
820}
821
822// ── HTML serialization ──────────────────────────────────────────────
823
824/// Serialize the children of a node to HTML (for innerHTML getter).
825fn serialize_children(doc: &Document, node: NodeId) -> String {
826 let mut html = String::new();
827 for child in doc.children(node) {
828 serialize_node_to(doc, child, &mut html);
829 }
830 html
831}
832
833/// Serialize a node and its subtree to HTML (for outerHTML getter).
834fn serialize_node_html(doc: &Document, node: NodeId) -> String {
835 let mut html = String::new();
836 serialize_node_to(doc, node, &mut html);
837 html
838}
839
840fn serialize_node_to(doc: &Document, node: NodeId, out: &mut String) {
841 match doc.node_data(node) {
842 NodeData::Element {
843 tag_name,
844 attributes,
845 ..
846 } => {
847 out.push('<');
848 out.push_str(tag_name);
849 for attr in attributes {
850 out.push(' ');
851 out.push_str(&attr.name);
852 out.push_str("=\"");
853 escape_attr(&attr.value, out);
854 out.push('"');
855 }
856 out.push('>');
857
858 // Void elements don't have closing tags.
859 if !is_void_element(tag_name) {
860 for child in doc.children(node) {
861 serialize_node_to(doc, child, out);
862 }
863 out.push_str("</");
864 out.push_str(tag_name);
865 out.push('>');
866 }
867 }
868 NodeData::Text { data } => {
869 escape_text(data, out);
870 }
871 NodeData::Comment { data } => {
872 out.push_str("<!--");
873 out.push_str(data);
874 out.push_str("-->");
875 }
876 NodeData::Document => {
877 for child in doc.children(node) {
878 serialize_node_to(doc, child, out);
879 }
880 }
881 }
882}
883
884fn escape_text(s: &str, out: &mut String) {
885 for c in s.chars() {
886 match c {
887 '&' => out.push_str("&"),
888 '<' => out.push_str("<"),
889 '>' => out.push_str(">"),
890 _ => out.push(c),
891 }
892 }
893}
894
895fn escape_attr(s: &str, out: &mut String) {
896 for c in s.chars() {
897 match c {
898 '&' => out.push_str("&"),
899 '"' => out.push_str("""),
900 _ => out.push(c),
901 }
902 }
903}
904
905fn is_void_element(tag: &str) -> bool {
906 matches!(
907 tag.to_ascii_lowercase().as_str(),
908 "area"
909 | "base"
910 | "br"
911 | "col"
912 | "embed"
913 | "hr"
914 | "img"
915 | "input"
916 | "link"
917 | "meta"
918 | "param"
919 | "source"
920 | "track"
921 | "wbr"
922 )
923}
924
925// ── Dynamic DOM property resolution ─────────────────────────────────
926
927/// Resolve a dynamic DOM property for a wrapper object.
928/// Called from the VM when a property is not found in the static properties.
929/// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise.
930pub fn resolve_dom_get(
931 gc: &mut Gc<HeapObject>,
932 bridge: &Rc<DomBridge>,
933 gc_ref: GcRef,
934 key: &str,
935) -> Option<Value> {
936 let node_id = get_node_id(gc, gc_ref)?;
937 let doc = bridge.document.borrow();
938
939 match key {
940 // ── Navigation properties ────────────────
941 "parentNode" => {
942 let parent = doc.parent(node_id);
943 drop(doc);
944 Some(match parent {
945 Some(p) => Value::Object(get_or_create_wrapper(p, gc, bridge, None)),
946 None => Value::Null,
947 })
948 }
949 "parentElement" => {
950 let parent = doc.parent(node_id);
951 let is_element = parent
952 .map(|p| matches!(doc.node_data(p), NodeData::Element { .. }))
953 .unwrap_or(false);
954 drop(doc);
955 Some(if is_element {
956 Value::Object(get_or_create_wrapper(parent.unwrap(), gc, bridge, None))
957 } else {
958 Value::Null
959 })
960 }
961 "childNodes" => {
962 let children: Vec<NodeId> = doc.children(node_id).collect();
963 drop(doc);
964 Some(make_wrapper_array(&children, gc, bridge, None))
965 }
966 "children" => {
967 let children: Vec<NodeId> = doc
968 .children(node_id)
969 .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. }))
970 .collect();
971 drop(doc);
972 Some(make_wrapper_array(&children, gc, bridge, None))
973 }
974 "firstChild" => {
975 let fc = doc.first_child(node_id);
976 drop(doc);
977 Some(match fc {
978 Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)),
979 None => Value::Null,
980 })
981 }
982 "lastChild" => {
983 let lc = doc.last_child(node_id);
984 drop(doc);
985 Some(match lc {
986 Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)),
987 None => Value::Null,
988 })
989 }
990 "firstElementChild" => {
991 let fc = doc
992 .children(node_id)
993 .find(|&c| matches!(doc.node_data(c), NodeData::Element { .. }));
994 drop(doc);
995 Some(match fc {
996 Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)),
997 None => Value::Null,
998 })
999 }
1000 "lastElementChild" => {
1001 let children: Vec<NodeId> = doc
1002 .children(node_id)
1003 .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. }))
1004 .collect();
1005 let last = children.last().copied();
1006 drop(doc);
1007 Some(match last {
1008 Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)),
1009 None => Value::Null,
1010 })
1011 }
1012 "nextSibling" => {
1013 let ns = doc.next_sibling(node_id);
1014 drop(doc);
1015 Some(match ns {
1016 Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)),
1017 None => Value::Null,
1018 })
1019 }
1020 "previousSibling" => {
1021 let ps = doc.prev_sibling(node_id);
1022 drop(doc);
1023 Some(match ps {
1024 Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)),
1025 None => Value::Null,
1026 })
1027 }
1028 "nextElementSibling" => {
1029 let mut current = doc.next_sibling(node_id);
1030 while let Some(s) = current {
1031 if matches!(doc.node_data(s), NodeData::Element { .. }) {
1032 drop(doc);
1033 return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None)));
1034 }
1035 current = doc.next_sibling(s);
1036 }
1037 Some(Value::Null)
1038 }
1039 "previousElementSibling" => {
1040 let mut current = doc.prev_sibling(node_id);
1041 while let Some(s) = current {
1042 if matches!(doc.node_data(s), NodeData::Element { .. }) {
1043 drop(doc);
1044 return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None)));
1045 }
1046 current = doc.prev_sibling(s);
1047 }
1048 Some(Value::Null)
1049 }
1050
1051 // ── Content properties ───────────────────
1052 "textContent" => {
1053 let text = doc.deep_text_content(node_id);
1054 Some(Value::String(text))
1055 }
1056 "innerHTML" => {
1057 if !matches!(doc.node_data(node_id), NodeData::Element { .. }) {
1058 return None;
1059 }
1060 let html = serialize_children(&doc, node_id);
1061 Some(Value::String(html))
1062 }
1063 "outerHTML" => {
1064 if !matches!(doc.node_data(node_id), NodeData::Element { .. }) {
1065 return None;
1066 }
1067 let html = serialize_node_html(&doc, node_id);
1068 Some(Value::String(html))
1069 }
1070
1071 // ── Attribute array ──────────────────────
1072 "attributes" => {
1073 if let Some(attrs) = doc.attributes(node_id) {
1074 let mut obj = ObjectData::new();
1075 for (i, attr) in attrs.iter().enumerate() {
1076 let mut attr_obj = ObjectData::new();
1077 attr_obj.properties.insert(
1078 "name".to_string(),
1079 Property::data(Value::String(attr.name.clone())),
1080 );
1081 attr_obj.properties.insert(
1082 "value".to_string(),
1083 Property::data(Value::String(attr.value.clone())),
1084 );
1085 let attr_ref = gc.alloc(HeapObject::Object(attr_obj));
1086 obj.properties
1087 .insert(i.to_string(), Property::data(Value::Object(attr_ref)));
1088 }
1089 obj.properties.insert(
1090 "length".to_string(),
1091 Property {
1092 value: Value::Number(doc.attributes(node_id).map_or(0, |a| a.len()) as f64),
1093 writable: false,
1094 enumerable: false,
1095 configurable: false,
1096 },
1097 );
1098 drop(doc);
1099 Some(Value::Object(gc.alloc(HeapObject::Object(obj))))
1100 } else {
1101 None
1102 }
1103 }
1104
1105 // ── classList ────────────────────────────
1106 "classList" => {
1107 if !matches!(doc.node_data(node_id), NodeData::Element { .. }) {
1108 return None;
1109 }
1110 drop(doc);
1111 Some(create_class_list(gc, bridge, node_id))
1112 }
1113
1114 // ── style ────────────────────────────────
1115 "style" => {
1116 if !matches!(doc.node_data(node_id), NodeData::Element { .. }) {
1117 return None;
1118 }
1119 let style_str = doc
1120 .get_attribute(node_id, "style")
1121 .unwrap_or("")
1122 .to_string();
1123 drop(doc);
1124 Some(create_style_object(gc, bridge, node_id, &style_str))
1125 }
1126
1127 _ => None,
1128 }
1129}
1130
1131/// Handle a DOM property set on a wrapper object.
1132/// Returns `true` if the key was handled (caller should skip normal property set).
1133pub fn handle_dom_set(
1134 gc: &mut Gc<HeapObject>,
1135 bridge: &Rc<DomBridge>,
1136 gc_ref: GcRef,
1137 key: &str,
1138 val: &Value,
1139) -> bool {
1140 let node_id = match get_node_id(gc, gc_ref) {
1141 Some(id) => id,
1142 None => return false,
1143 };
1144
1145 match key {
1146 "textContent" => {
1147 let text = val.to_js_string(gc);
1148 bridge
1149 .document
1150 .borrow_mut()
1151 .set_element_text_content(node_id, &text);
1152 true
1153 }
1154 "innerHTML" => {
1155 let html_str = val.to_js_string(gc);
1156 let mut doc = bridge.document.borrow_mut();
1157 // Remove existing children.
1158 while let Some(child) = doc.first_child(node_id) {
1159 doc.remove_child(node_id, child);
1160 }
1161 drop(doc);
1162 // Parse the HTML fragment and adopt children into the real document.
1163 let fragment_doc = parse_html(&format!("<body>{html_str}</body>"));
1164 let frag_root = fragment_doc.root();
1165 // Find the <body> element in the parsed fragment.
1166 let body = fragment_doc.children(frag_root).find(|&c| {
1167 matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("html"))
1168 }).and_then(|html| {
1169 fragment_doc.children(html).find(|&c| {
1170 matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("body"))
1171 })
1172 });
1173 if let Some(body_id) = body {
1174 adopt_children(
1175 &fragment_doc,
1176 body_id,
1177 &mut bridge.document.borrow_mut(),
1178 node_id,
1179 );
1180 }
1181 true
1182 }
1183 "id" => {
1184 let id_val = val.to_js_string(gc);
1185 bridge
1186 .document
1187 .borrow_mut()
1188 .set_attribute(node_id, "id", &id_val);
1189 // Also update the wrapper's static `id` property.
1190 set_builtin_prop(gc, gc_ref, "id", Value::String(id_val));
1191 true
1192 }
1193 "className" => {
1194 let class_val = val.to_js_string(gc);
1195 bridge
1196 .document
1197 .borrow_mut()
1198 .set_attribute(node_id, "class", &class_val);
1199 set_builtin_prop(gc, gc_ref, "className", Value::String(class_val));
1200 true
1201 }
1202 _ => false,
1203 }
1204}
1205
1206/// Recursively copy children from a parsed fragment document into the real document.
1207fn adopt_children(
1208 src_doc: &Document,
1209 src_parent: NodeId,
1210 dst_doc: &mut Document,
1211 dst_parent: NodeId,
1212) {
1213 for child in src_doc.children(src_parent) {
1214 let new_node = match src_doc.node_data(child) {
1215 NodeData::Element {
1216 tag_name,
1217 attributes,
1218 ..
1219 } => {
1220 let el = dst_doc.create_element(tag_name);
1221 for attr in attributes {
1222 dst_doc.set_attribute(el, &attr.name, &attr.value);
1223 }
1224 el
1225 }
1226 NodeData::Text { data } => dst_doc.create_text(data),
1227 NodeData::Comment { data } => dst_doc.create_comment(data),
1228 NodeData::Document => continue,
1229 };
1230 dst_doc.append_child(dst_parent, new_node);
1231 // Recurse for element children.
1232 if matches!(src_doc.node_data(child), NodeData::Element { .. }) {
1233 adopt_children(src_doc, child, dst_doc, new_node);
1234 }
1235 }
1236}
1237
1238/// Check if a DOM property set should be intercepted for a style proxy object.
1239/// Returns `true` if handled.
1240pub fn handle_style_set(
1241 gc: &mut Gc<HeapObject>,
1242 bridge: &Rc<DomBridge>,
1243 gc_ref: GcRef,
1244 key: &str,
1245 val: &Value,
1246) -> bool {
1247 // Check for __style_node_id__ marker.
1248 let node_id = match gc.get(gc_ref) {
1249 Some(HeapObject::Object(data)) => match data.properties.get("__style_node_id__") {
1250 Some(Property {
1251 value: Value::Number(n),
1252 ..
1253 }) => NodeId::from_index(*n as usize),
1254 _ => return false,
1255 },
1256 _ => return false,
1257 };
1258
1259 // Set the property on the style object normally.
1260 if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) {
1261 data.properties
1262 .insert(key.to_string(), Property::data(val.clone()));
1263 }
1264
1265 // Serialize all style properties back to the element's style attribute.
1266 let style_str = serialize_style_object(gc, gc_ref);
1267 bridge
1268 .document
1269 .borrow_mut()
1270 .set_attribute(node_id, "style", &style_str);
1271
1272 true
1273}
1274
1275// ── classList helpers ───────────────────────────────────────────────
1276
1277fn create_class_list(gc: &mut Gc<HeapObject>, _bridge: &DomBridge, node_id: NodeId) -> Value {
1278 let mut data = ObjectData::new();
1279 // Store the node ID for method callbacks.
1280 data.properties.insert(
1281 NODE_ID_KEY.to_string(),
1282 Property::builtin(Value::Number(node_id.index() as f64)),
1283 );
1284
1285 let gc_ref = gc.alloc(HeapObject::Object(data));
1286
1287 let methods: &[NativeMethod] = &[
1288 ("add", class_list_add),
1289 ("remove", class_list_remove),
1290 ("toggle", class_list_toggle),
1291 ("contains", class_list_contains),
1292 ];
1293 for &(name, callback) in methods {
1294 let func = make_native(gc, name, callback);
1295 set_builtin_prop(gc, gc_ref, name, Value::Function(func));
1296 }
1297
1298 Value::Object(gc_ref)
1299}
1300
1301fn get_class_list_node_id(gc: &Gc<HeapObject>, this: &Value) -> Option<NodeId> {
1302 match this {
1303 Value::Object(r) => get_node_id(gc, *r),
1304 _ => None,
1305 }
1306}
1307
1308fn class_list_add(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1309 let bridge = ctx
1310 .dom_bridge
1311 .ok_or_else(|| RuntimeError::type_error("no document"))?;
1312 let node_id = get_class_list_node_id(ctx.gc, &ctx.this)
1313 .ok_or_else(|| RuntimeError::type_error("invalid classList"))?;
1314
1315 for arg in args {
1316 let class_name = arg.to_js_string(ctx.gc);
1317 let doc = bridge.document.borrow();
1318 let current = doc
1319 .get_attribute(node_id, "class")
1320 .unwrap_or("")
1321 .to_string();
1322 drop(doc);
1323 if !current.split_whitespace().any(|c| c == class_name) {
1324 let new_val = if current.is_empty() {
1325 class_name
1326 } else {
1327 format!("{current} {class_name}")
1328 };
1329 bridge
1330 .document
1331 .borrow_mut()
1332 .set_attribute(node_id, "class", &new_val);
1333 }
1334 }
1335 Ok(Value::Undefined)
1336}
1337
1338fn class_list_remove(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1339 let bridge = ctx
1340 .dom_bridge
1341 .ok_or_else(|| RuntimeError::type_error("no document"))?;
1342 let node_id = get_class_list_node_id(ctx.gc, &ctx.this)
1343 .ok_or_else(|| RuntimeError::type_error("invalid classList"))?;
1344
1345 for arg in args {
1346 let class_name = arg.to_js_string(ctx.gc);
1347 let doc = bridge.document.borrow();
1348 let current = doc
1349 .get_attribute(node_id, "class")
1350 .unwrap_or("")
1351 .to_string();
1352 drop(doc);
1353 let new_val: Vec<&str> = current
1354 .split_whitespace()
1355 .filter(|&c| c != class_name)
1356 .collect();
1357 bridge
1358 .document
1359 .borrow_mut()
1360 .set_attribute(node_id, "class", &new_val.join(" "));
1361 }
1362 Ok(Value::Undefined)
1363}
1364
1365fn class_list_toggle(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1366 let bridge = ctx
1367 .dom_bridge
1368 .ok_or_else(|| RuntimeError::type_error("no document"))?;
1369 let node_id = get_class_list_node_id(ctx.gc, &ctx.this)
1370 .ok_or_else(|| RuntimeError::type_error("invalid classList"))?;
1371 let class_name = args
1372 .first()
1373 .map(|v| v.to_js_string(ctx.gc))
1374 .unwrap_or_default();
1375
1376 let doc = bridge.document.borrow();
1377 let current = doc
1378 .get_attribute(node_id, "class")
1379 .unwrap_or("")
1380 .to_string();
1381 drop(doc);
1382
1383 let has_class = current.split_whitespace().any(|c| c == class_name);
1384 if has_class {
1385 let new_val: Vec<&str> = current
1386 .split_whitespace()
1387 .filter(|&c| c != class_name)
1388 .collect();
1389 bridge
1390 .document
1391 .borrow_mut()
1392 .set_attribute(node_id, "class", &new_val.join(" "));
1393 Ok(Value::Boolean(false))
1394 } else {
1395 let new_val = if current.is_empty() {
1396 class_name
1397 } else {
1398 format!("{current} {class_name}")
1399 };
1400 bridge
1401 .document
1402 .borrow_mut()
1403 .set_attribute(node_id, "class", &new_val);
1404 Ok(Value::Boolean(true))
1405 }
1406}
1407
1408fn class_list_contains(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1409 let bridge = ctx
1410 .dom_bridge
1411 .ok_or_else(|| RuntimeError::type_error("no document"))?;
1412 let node_id = get_class_list_node_id(ctx.gc, &ctx.this)
1413 .ok_or_else(|| RuntimeError::type_error("invalid classList"))?;
1414 let class_name = args
1415 .first()
1416 .map(|v| v.to_js_string(ctx.gc))
1417 .unwrap_or_default();
1418
1419 let doc = bridge.document.borrow();
1420 let current = doc.get_attribute(node_id, "class").unwrap_or("");
1421 let has = current.split_whitespace().any(|c| c == class_name);
1422 Ok(Value::Boolean(has))
1423}
1424
1425// ── Style object helpers ────────────────────────────────────────────
1426
1427fn create_style_object(
1428 gc: &mut Gc<HeapObject>,
1429 _bridge: &DomBridge,
1430 node_id: NodeId,
1431 style_str: &str,
1432) -> Value {
1433 let mut data = ObjectData::new();
1434 // Marker for style proxy interception.
1435 data.properties.insert(
1436 "__style_node_id__".to_string(),
1437 Property::builtin(Value::Number(node_id.index() as f64)),
1438 );
1439
1440 // Parse existing inline style into camelCase properties.
1441 for decl in style_str.split(';') {
1442 let decl = decl.trim();
1443 if decl.is_empty() {
1444 continue;
1445 }
1446 if let Some((prop, val)) = decl.split_once(':') {
1447 let camel = kebab_to_camel(prop.trim());
1448 data.properties
1449 .insert(camel, Property::data(Value::String(val.trim().to_string())));
1450 }
1451 }
1452
1453 Value::Object(gc.alloc(HeapObject::Object(data)))
1454}
1455
1456fn serialize_style_object(gc: &Gc<HeapObject>, style_ref: GcRef) -> String {
1457 let mut parts = Vec::new();
1458 if let Some(HeapObject::Object(data)) = gc.get(style_ref) {
1459 for (key, prop) in &data.properties {
1460 if key.starts_with("__") {
1461 continue;
1462 }
1463 let kebab = camel_to_kebab(key);
1464 let val = match &prop.value {
1465 Value::String(s) => s.clone(),
1466 Value::Number(n) => format!("{n}"),
1467 _ => continue,
1468 };
1469 parts.push(format!("{kebab}: {val}"));
1470 }
1471 }
1472 parts.join("; ")
1473}
1474
1475fn kebab_to_camel(s: &str) -> String {
1476 let mut result = String::new();
1477 let mut next_upper = false;
1478 for c in s.chars() {
1479 if c == '-' {
1480 next_upper = true;
1481 } else if next_upper {
1482 result.extend(c.to_uppercase());
1483 next_upper = false;
1484 } else {
1485 result.push(c);
1486 }
1487 }
1488 result
1489}
1490
1491fn camel_to_kebab(s: &str) -> String {
1492 let mut result = String::new();
1493 for c in s.chars() {
1494 if c.is_ascii_uppercase() {
1495 result.push('-');
1496 result.push(c.to_ascii_lowercase());
1497 } else {
1498 result.push(c);
1499 }
1500 }
1501 result
1502}
1503
1504// ── Event system ────────────────────────────────────────────────────
1505
1506// Internal property keys for Event objects.
1507const EVENT_TYPE_KEY: &str = "__event_type__";
1508const EVENT_BUBBLES_KEY: &str = "__event_bubbles__";
1509const EVENT_CANCELABLE_KEY: &str = "__event_cancelable__";
1510const EVENT_STOP_PROP_KEY: &str = "__event_stop_prop__";
1511const EVENT_STOP_IMMEDIATE_KEY: &str = "__event_stop_immediate__";
1512const EVENT_DEFAULT_PREVENTED_KEY: &str = "__event_default_prevented__";
1513const EVENT_PHASE_KEY: &str = "__event_phase__";
1514
1515/// Create an Event JS object with the given type string and options.
1516fn create_event_object(
1517 gc: &mut Gc<HeapObject>,
1518 event_type: &str,
1519 bubbles: bool,
1520 cancelable: bool,
1521) -> GcRef {
1522 let mut data = ObjectData::new();
1523
1524 // Public properties.
1525 data.properties.insert(
1526 "type".to_string(),
1527 Property::data(Value::String(event_type.to_string())),
1528 );
1529 data.properties.insert(
1530 "bubbles".to_string(),
1531 Property::data(Value::Boolean(bubbles)),
1532 );
1533 data.properties.insert(
1534 "cancelable".to_string(),
1535 Property::data(Value::Boolean(cancelable)),
1536 );
1537 data.properties.insert(
1538 "defaultPrevented".to_string(),
1539 Property::data(Value::Boolean(false)),
1540 );
1541 data.properties
1542 .insert("eventPhase".to_string(), Property::data(Value::Number(0.0)));
1543 data.properties
1544 .insert("target".to_string(), Property::data(Value::Null));
1545 data.properties
1546 .insert("currentTarget".to_string(), Property::data(Value::Null));
1547 data.properties
1548 .insert("timeStamp".to_string(), Property::data(Value::Number(0.0)));
1549
1550 // Internal state.
1551 data.properties.insert(
1552 EVENT_TYPE_KEY.to_string(),
1553 Property::builtin(Value::String(event_type.to_string())),
1554 );
1555 data.properties.insert(
1556 EVENT_BUBBLES_KEY.to_string(),
1557 Property::builtin(Value::Boolean(bubbles)),
1558 );
1559 data.properties.insert(
1560 EVENT_CANCELABLE_KEY.to_string(),
1561 Property::builtin(Value::Boolean(cancelable)),
1562 );
1563 data.properties.insert(
1564 EVENT_STOP_PROP_KEY.to_string(),
1565 Property::builtin(Value::Boolean(false)),
1566 );
1567 data.properties.insert(
1568 EVENT_STOP_IMMEDIATE_KEY.to_string(),
1569 Property::builtin(Value::Boolean(false)),
1570 );
1571 data.properties.insert(
1572 EVENT_DEFAULT_PREVENTED_KEY.to_string(),
1573 Property::builtin(Value::Boolean(false)),
1574 );
1575 data.properties.insert(
1576 EVENT_PHASE_KEY.to_string(),
1577 Property::builtin(Value::Number(0.0)),
1578 );
1579
1580 // Event phase constants.
1581 data.properties
1582 .insert("NONE".to_string(), Property::builtin(Value::Number(0.0)));
1583 data.properties.insert(
1584 "CAPTURING_PHASE".to_string(),
1585 Property::builtin(Value::Number(1.0)),
1586 );
1587 data.properties.insert(
1588 "AT_TARGET".to_string(),
1589 Property::builtin(Value::Number(2.0)),
1590 );
1591 data.properties.insert(
1592 "BUBBLING_PHASE".to_string(),
1593 Property::builtin(Value::Number(3.0)),
1594 );
1595
1596 let event_ref = gc.alloc(HeapObject::Object(data));
1597
1598 // Register methods.
1599 let methods: &[NativeMethod] = &[
1600 ("preventDefault", event_prevent_default),
1601 ("stopPropagation", event_stop_propagation),
1602 ("stopImmediatePropagation", event_stop_immediate_propagation),
1603 ];
1604 for &(name, callback) in methods {
1605 let func = make_native(gc, name, callback);
1606 set_builtin_prop(gc, event_ref, name, Value::Function(func));
1607 }
1608
1609 event_ref
1610}
1611
1612/// `Event` constructor: `new Event(type, options)`.
1613fn event_constructor(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1614 let event_type = args
1615 .first()
1616 .map(|v| v.to_js_string(ctx.gc))
1617 .unwrap_or_default();
1618
1619 let mut bubbles = false;
1620 let mut cancelable = false;
1621
1622 // Parse options object if provided.
1623 if let Some(Value::Object(opts_ref)) = args.get(1) {
1624 if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) {
1625 if let Some(prop) = opts.properties.get("bubbles") {
1626 bubbles = prop.value.to_boolean();
1627 }
1628 if let Some(prop) = opts.properties.get("cancelable") {
1629 cancelable = prop.value.to_boolean();
1630 }
1631 }
1632 }
1633
1634 let event_ref = create_event_object(ctx.gc, &event_type, bubbles, cancelable);
1635 Ok(Value::Object(event_ref))
1636}
1637
1638fn event_prevent_default(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1639 if let Value::Object(r) = &ctx.this {
1640 // Only prevent if cancelable.
1641 let cancelable = match ctx.gc.get(*r) {
1642 Some(HeapObject::Object(data)) => data
1643 .properties
1644 .get(EVENT_CANCELABLE_KEY)
1645 .map(|p| p.value.to_boolean())
1646 .unwrap_or(false),
1647 _ => false,
1648 };
1649 if cancelable {
1650 set_builtin_prop(
1651 ctx.gc,
1652 *r,
1653 EVENT_DEFAULT_PREVENTED_KEY,
1654 Value::Boolean(true),
1655 );
1656 set_builtin_prop(ctx.gc, *r, "defaultPrevented", Value::Boolean(true));
1657 }
1658 }
1659 Ok(Value::Undefined)
1660}
1661
1662fn event_stop_propagation(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> {
1663 if let Value::Object(r) = &ctx.this {
1664 set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true));
1665 }
1666 Ok(Value::Undefined)
1667}
1668
1669fn event_stop_immediate_propagation(
1670 _args: &[Value],
1671 ctx: &mut NativeContext,
1672) -> Result<Value, RuntimeError> {
1673 if let Value::Object(r) = &ctx.this {
1674 set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true));
1675 set_builtin_prop(ctx.gc, *r, EVENT_STOP_IMMEDIATE_KEY, Value::Boolean(true));
1676 }
1677 Ok(Value::Undefined)
1678}
1679
1680// ── EventTarget methods ─────────────────────────────────────────────
1681
1682fn event_target_add_listener(
1683 args: &[Value],
1684 ctx: &mut NativeContext,
1685) -> Result<Value, RuntimeError> {
1686 let bridge = ctx
1687 .dom_bridge
1688 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
1689 let node_id = match &ctx.this {
1690 Value::Object(r) => get_node_id(ctx.gc, *r),
1691 _ => None,
1692 }
1693 .ok_or_else(|| RuntimeError::type_error("addEventListener called on non-node"))?;
1694
1695 let event_type = args
1696 .first()
1697 .map(|v| v.to_js_string(ctx.gc))
1698 .unwrap_or_default();
1699
1700 let callback_ref = match args.get(1) {
1701 Some(Value::Function(r)) => *r,
1702 _ => return Ok(Value::Undefined), // Silently ignore non-function
1703 };
1704
1705 let mut capture = false;
1706 let mut once = false;
1707
1708 // Third arg: boolean (capture) or options object.
1709 if let Some(arg) = args.get(2) {
1710 match arg {
1711 Value::Boolean(b) => capture = *b,
1712 Value::Object(opts_ref) => {
1713 if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) {
1714 if let Some(prop) = opts.properties.get("capture") {
1715 capture = prop.value.to_boolean();
1716 }
1717 if let Some(prop) = opts.properties.get("once") {
1718 once = prop.value.to_boolean();
1719 }
1720 }
1721 }
1722 _ => {}
1723 }
1724 }
1725
1726 let idx = node_id.index();
1727 let mut listeners = bridge.event_listeners.borrow_mut();
1728 let list = listeners.entry(idx).or_default();
1729
1730 // Don't add duplicate: same type, same callback ref, same capture.
1731 let already_exists = list
1732 .iter()
1733 .any(|l| l.event_type == event_type && l.callback == callback_ref && l.capture == capture);
1734 if !already_exists {
1735 list.push(EventListener {
1736 event_type,
1737 callback: callback_ref,
1738 capture,
1739 once,
1740 });
1741 }
1742
1743 Ok(Value::Undefined)
1744}
1745
1746fn event_target_remove_listener(
1747 args: &[Value],
1748 ctx: &mut NativeContext,
1749) -> Result<Value, RuntimeError> {
1750 let bridge = ctx
1751 .dom_bridge
1752 .ok_or_else(|| RuntimeError::type_error("no document attached"))?;
1753 let node_id = match &ctx.this {
1754 Value::Object(r) => get_node_id(ctx.gc, *r),
1755 _ => None,
1756 }
1757 .ok_or_else(|| RuntimeError::type_error("removeEventListener called on non-node"))?;
1758
1759 let event_type = args
1760 .first()
1761 .map(|v| v.to_js_string(ctx.gc))
1762 .unwrap_or_default();
1763
1764 let callback_ref = match args.get(1) {
1765 Some(Value::Function(r)) => *r,
1766 _ => return Ok(Value::Undefined),
1767 };
1768
1769 let mut capture = false;
1770 if let Some(arg) = args.get(2) {
1771 match arg {
1772 Value::Boolean(b) => capture = *b,
1773 Value::Object(opts_ref) => {
1774 if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) {
1775 if let Some(prop) = opts.properties.get("capture") {
1776 capture = prop.value.to_boolean();
1777 }
1778 }
1779 }
1780 _ => {}
1781 }
1782 }
1783
1784 let idx = node_id.index();
1785 let mut listeners = bridge.event_listeners.borrow_mut();
1786 if let Some(list) = listeners.get_mut(&idx) {
1787 list.retain(|l| {
1788 !(l.event_type == event_type && l.callback == callback_ref && l.capture == capture)
1789 });
1790 }
1791
1792 Ok(Value::Undefined)
1793}
1794
1795/// Native `dispatchEvent` — returns a marker for the VM to handle.
1796fn event_target_dispatch_event(
1797 args: &[Value],
1798 ctx: &mut NativeContext,
1799) -> Result<Value, RuntimeError> {
1800 let node_id = match &ctx.this {
1801 Value::Object(r) => get_node_id(ctx.gc, *r),
1802 _ => None,
1803 }
1804 .ok_or_else(|| RuntimeError::type_error("dispatchEvent called on non-node"))?;
1805
1806 let event_ref = match args.first() {
1807 Some(Value::Object(r)) => *r,
1808 _ => {
1809 return Err(RuntimeError::type_error(
1810 "dispatchEvent requires an Event argument",
1811 ))
1812 }
1813 };
1814
1815 // Build a marker object for the VM to process.
1816 let mut marker = ObjectData::new();
1817 marker.properties.insert(
1818 "__event_dispatch__".to_string(),
1819 Property::builtin(Value::Boolean(true)),
1820 );
1821 marker.properties.insert(
1822 "__target_id__".to_string(),
1823 Property::builtin(Value::Number(node_id.index() as f64)),
1824 );
1825 marker.properties.insert(
1826 "__event_ref__".to_string(),
1827 Property::builtin(Value::Object(event_ref)),
1828 );
1829
1830 Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker))))
1831}
1832
1833/// Register the `Event` constructor and event target methods on the VM.
1834pub fn init_event_system(vm: &mut Vm) {
1835 // Register `Event` as a global constructor.
1836 let ctor = make_native(&mut vm.gc, "Event", event_constructor);
1837 vm.set_global("Event", Value::Function(ctor));
1838
1839 // Add event target methods to the document object.
1840 if let Some(Value::Object(doc_ref)) = vm.get_global("document").cloned() {
1841 let methods: &[NativeMethod] = &[
1842 ("addEventListener", event_target_add_listener),
1843 ("removeEventListener", event_target_remove_listener),
1844 ("dispatchEvent", event_target_dispatch_event),
1845 ];
1846 for &(name, callback) in methods {
1847 let func = make_native(&mut vm.gc, name, callback);
1848 set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func));
1849 }
1850
1851 // Ensure the document object has __node_id__ set to the root node.
1852 if let Some(bridge) = &vm.dom_bridge {
1853 let root_idx = bridge.document.borrow().root().index();
1854 set_builtin_prop(
1855 &mut vm.gc,
1856 doc_ref,
1857 NODE_ID_KEY,
1858 Value::Number(root_idx as f64),
1859 );
1860 // Also cache the document wrapper.
1861 bridge.node_wrappers.borrow_mut().insert(root_idx, doc_ref);
1862 }
1863 }
1864}
1865
1866/// Run the event dispatch algorithm. Called from the VM when it detects an
1867/// event dispatch marker returned by the native `dispatchEvent`.
1868///
1869/// Returns the result of `!event.defaultPrevented`.
1870pub fn run_event_dispatch(vm: &mut Vm, target_idx: usize, event_ref: GcRef) -> Value {
1871 let bridge = match vm.dom_bridge.clone() {
1872 Some(b) => b,
1873 None => return Value::Boolean(true),
1874 };
1875
1876 let target_id = NodeId::from_index(target_idx);
1877
1878 // Build propagation path: target -> ... -> root.
1879 let path = {
1880 let doc = bridge.document.borrow();
1881 let mut path = vec![target_id];
1882 let mut current = target_id;
1883 while let Some(parent) = doc.parent(current) {
1884 path.push(parent);
1885 current = parent;
1886 }
1887 path.reverse(); // Now root -> ... -> target.
1888 path
1889 };
1890
1891 // Set event.target to the target wrapper.
1892 let target_wrapper = get_or_create_wrapper(target_id, &mut vm.gc, &bridge, vm.object_prototype);
1893 set_builtin_prop(
1894 &mut vm.gc,
1895 event_ref,
1896 "target",
1897 Value::Object(target_wrapper),
1898 );
1899
1900 let target_pos = path.len() - 1;
1901
1902 // --- Capture phase (root to target, excluding target) ---
1903 set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(1.0));
1904 set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(1.0));
1905
1906 for &node_id in &path[..target_pos] {
1907 if is_propagation_stopped(&vm.gc, event_ref) {
1908 break;
1909 }
1910 let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype);
1911 set_builtin_prop(
1912 &mut vm.gc,
1913 event_ref,
1914 "currentTarget",
1915 Value::Object(wrapper),
1916 );
1917 invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, true);
1918 }
1919
1920 // --- At-target phase ---
1921 if !is_propagation_stopped(&vm.gc, event_ref) {
1922 set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(2.0));
1923 set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(2.0));
1924 set_builtin_prop(
1925 &mut vm.gc,
1926 event_ref,
1927 "currentTarget",
1928 Value::Object(target_wrapper),
1929 );
1930 // At target, fire BOTH capture and bubble listeners in registration order.
1931 invoke_listeners(vm, &bridge, target_id, event_ref, target_wrapper, false);
1932 }
1933
1934 // --- Bubble phase (target to root, excluding target) ---
1935 let bubbles = match vm.gc.get(event_ref) {
1936 Some(HeapObject::Object(data)) => data
1937 .properties
1938 .get(EVENT_BUBBLES_KEY)
1939 .map(|p| p.value.to_boolean())
1940 .unwrap_or(false),
1941 _ => false,
1942 };
1943
1944 if bubbles && !is_propagation_stopped(&vm.gc, event_ref) {
1945 set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(3.0));
1946 set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(3.0));
1947
1948 for &node_id in path[..target_pos].iter().rev() {
1949 if is_propagation_stopped(&vm.gc, event_ref) {
1950 break;
1951 }
1952 let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype);
1953 set_builtin_prop(
1954 &mut vm.gc,
1955 event_ref,
1956 "currentTarget",
1957 Value::Object(wrapper),
1958 );
1959 invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, false);
1960 }
1961 }
1962
1963 // Reset event state.
1964 set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(0.0));
1965 set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(0.0));
1966 set_builtin_prop(&mut vm.gc, event_ref, "currentTarget", Value::Null);
1967
1968 // Return !defaultPrevented.
1969 let default_prevented = match vm.gc.get(event_ref) {
1970 Some(HeapObject::Object(data)) => data
1971 .properties
1972 .get(EVENT_DEFAULT_PREVENTED_KEY)
1973 .map(|p| p.value.to_boolean())
1974 .unwrap_or(false),
1975 _ => false,
1976 };
1977 Value::Boolean(!default_prevented)
1978}
1979
1980fn is_propagation_stopped(gc: &Gc<HeapObject>, event_ref: GcRef) -> bool {
1981 match gc.get(event_ref) {
1982 Some(HeapObject::Object(data)) => data
1983 .properties
1984 .get(EVENT_STOP_PROP_KEY)
1985 .map(|p| p.value.to_boolean())
1986 .unwrap_or(false),
1987 _ => false,
1988 }
1989}
1990
1991fn is_immediate_stopped(gc: &Gc<HeapObject>, event_ref: GcRef) -> bool {
1992 match gc.get(event_ref) {
1993 Some(HeapObject::Object(data)) => data
1994 .properties
1995 .get(EVENT_STOP_IMMEDIATE_KEY)
1996 .map(|p| p.value.to_boolean())
1997 .unwrap_or(false),
1998 _ => false,
1999 }
2000}
2001
2002/// Invoke matching listeners on a node for the given event.
2003///
2004/// When `capture_only` is true (capture phase), only capture listeners fire.
2005/// When false (at-target or bubble), at-target fires all listeners and
2006/// bubble fires only non-capture listeners. We distinguish by the event phase:
2007/// at-target (phase 2) fires all, bubble (phase 3) fires only non-capture.
2008fn invoke_listeners(
2009 vm: &mut Vm,
2010 bridge: &DomBridge,
2011 node_id: NodeId,
2012 event_ref: GcRef,
2013 current_target_wrapper: GcRef,
2014 capture_only: bool,
2015) {
2016 let event_type = match vm.gc.get(event_ref) {
2017 Some(HeapObject::Object(data)) => match data.properties.get(EVENT_TYPE_KEY) {
2018 Some(Property {
2019 value: Value::String(s),
2020 ..
2021 }) => s.clone(),
2022 _ => return,
2023 },
2024 _ => return,
2025 };
2026
2027 let phase = match vm.gc.get(event_ref) {
2028 Some(HeapObject::Object(data)) => match data.properties.get(EVENT_PHASE_KEY) {
2029 Some(Property {
2030 value: Value::Number(n),
2031 ..
2032 }) => *n as u8,
2033 _ => 0,
2034 },
2035 _ => 0,
2036 };
2037
2038 let idx = node_id.index();
2039
2040 // Collect matching listeners (snapshot the list to avoid borrow issues).
2041 let matching: Vec<(GcRef, bool)> = {
2042 let listeners = bridge.event_listeners.borrow();
2043 match listeners.get(&idx) {
2044 Some(list) => list
2045 .iter()
2046 .filter(|l| {
2047 if l.event_type != event_type {
2048 return false;
2049 }
2050 if capture_only {
2051 // Capture phase: only capture listeners.
2052 l.capture
2053 } else if phase == 2 {
2054 // At-target: all listeners fire.
2055 true
2056 } else {
2057 // Bubble phase: only non-capture listeners.
2058 !l.capture
2059 }
2060 })
2061 .map(|l| (l.callback, l.once))
2062 .collect(),
2063 None => return,
2064 }
2065 };
2066
2067 // Invoke each listener.
2068 for (callback_ref, once) in &matching {
2069 if is_immediate_stopped(&vm.gc, event_ref) {
2070 break;
2071 }
2072 // Set `this` to currentTarget for the callback.
2073 let old_this = vm.get_global("this").cloned();
2074 vm.set_global("this", Value::Object(current_target_wrapper));
2075
2076 let _ = vm.call_function(*callback_ref, &[Value::Object(event_ref)]);
2077
2078 // Restore `this`.
2079 match old_this {
2080 Some(v) => {
2081 vm.set_global("this", v);
2082 }
2083 None => {
2084 vm.remove_global("this");
2085 }
2086 }
2087
2088 // Remove if once.
2089 if *once {
2090 let mut listeners = bridge.event_listeners.borrow_mut();
2091 if let Some(list) = listeners.get_mut(&idx) {
2092 list.retain(|l| {
2093 !(l.event_type == event_type && l.callback == *callback_ref && l.once)
2094 });
2095 }
2096 }
2097 }
2098}
2099
2100// ── Tests ───────────────────────────────────────────────────────────
2101
2102#[cfg(test)]
2103mod tests {
2104 use super::*;
2105 use crate::compiler;
2106 use crate::parser::Parser;
2107 use we_html::parse_html;
2108
2109 /// Build a Document from HTML source by parsing it with the HTML parser.
2110 fn doc_from_html(html: &str) -> we_dom::Document {
2111 parse_html(html)
2112 }
2113
2114 /// Evaluate JS with a DOM document attached.
2115 fn eval_with_doc(html: &str, js: &str) -> Result<Value, RuntimeError> {
2116 let doc = doc_from_html(html);
2117 let program = Parser::parse(js).expect("parse failed");
2118 let func = compiler::compile(&program).expect("compile failed");
2119 let mut vm = Vm::new();
2120 vm.attach_document(doc);
2121 vm.execute(&func)
2122 }
2123
2124 #[test]
2125 fn test_document_is_global() {
2126 let result = eval_with_doc("<html><body></body></html>", "typeof document").unwrap();
2127 assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "object");
2128 }
2129
2130 #[test]
2131 fn test_document_node_type() {
2132 let result = eval_with_doc("<html><body></body></html>", "document.nodeType").unwrap();
2133 match result {
2134 Value::Number(n) => assert_eq!(n, 9.0),
2135 v => panic!("expected 9, got {v:?}"),
2136 }
2137 }
2138
2139 #[test]
2140 fn test_document_body() {
2141 let result = eval_with_doc("<html><body></body></html>", "document.body.tagName").unwrap();
2142 match result {
2143 Value::String(s) => assert_eq!(s, "BODY"),
2144 v => panic!("expected 'BODY', got {v:?}"),
2145 }
2146 }
2147
2148 #[test]
2149 fn test_document_head() {
2150 let result = eval_with_doc(
2151 "<html><head></head><body></body></html>",
2152 "document.head.tagName",
2153 )
2154 .unwrap();
2155 match result {
2156 Value::String(s) => assert_eq!(s, "HEAD"),
2157 v => panic!("expected 'HEAD', got {v:?}"),
2158 }
2159 }
2160
2161 #[test]
2162 fn test_document_document_element() {
2163 let result = eval_with_doc(
2164 "<html><body></body></html>",
2165 "document.documentElement.tagName",
2166 )
2167 .unwrap();
2168 match result {
2169 Value::String(s) => assert_eq!(s, "HTML"),
2170 v => panic!("expected 'HTML', got {v:?}"),
2171 }
2172 }
2173
2174 #[test]
2175 fn test_get_element_by_id_found() {
2176 let result = eval_with_doc(
2177 r#"<html><body><div id="foo">hello</div></body></html>"#,
2178 r#"document.getElementById("foo").tagName"#,
2179 )
2180 .unwrap();
2181 match result {
2182 Value::String(s) => assert_eq!(s, "DIV"),
2183 v => panic!("expected 'DIV', got {v:?}"),
2184 }
2185 }
2186
2187 #[test]
2188 fn test_get_element_by_id_not_found() {
2189 let result = eval_with_doc(
2190 "<html><body></body></html>",
2191 r#"document.getElementById("nope")"#,
2192 )
2193 .unwrap();
2194 assert!(matches!(result, Value::Null));
2195 }
2196
2197 #[test]
2198 fn test_get_element_by_id_returns_correct_id() {
2199 let result = eval_with_doc(
2200 r#"<html><body><div id="myid"></div></body></html>"#,
2201 r#"document.getElementById("myid").id"#,
2202 )
2203 .unwrap();
2204 match result {
2205 Value::String(s) => assert_eq!(s, "myid"),
2206 v => panic!("expected 'myid', got {v:?}"),
2207 }
2208 }
2209
2210 #[test]
2211 fn test_create_element() {
2212 let result = eval_with_doc(
2213 "<html><body></body></html>",
2214 r#"document.createElement("div").tagName"#,
2215 )
2216 .unwrap();
2217 match result {
2218 Value::String(s) => assert_eq!(s, "DIV"),
2219 v => panic!("expected 'DIV', got {v:?}"),
2220 }
2221 }
2222
2223 #[test]
2224 fn test_create_text_node() {
2225 let result = eval_with_doc(
2226 "<html><body></body></html>",
2227 r#"document.createTextNode("hello").nodeType"#,
2228 )
2229 .unwrap();
2230 match result {
2231 Value::Number(n) => assert_eq!(n, 3.0),
2232 v => panic!("expected 3, got {v:?}"),
2233 }
2234 }
2235
2236 #[test]
2237 fn test_element_node_type() {
2238 let result = eval_with_doc("<html><body></body></html>", "document.body.nodeType").unwrap();
2239 match result {
2240 Value::Number(n) => assert_eq!(n, 1.0),
2241 v => panic!("expected 1, got {v:?}"),
2242 }
2243 }
2244
2245 #[test]
2246 fn test_query_selector_by_class() {
2247 let result = eval_with_doc(
2248 r#"<html><body><p class="intro">hi</p></body></html>"#,
2249 r#"document.querySelector(".intro").tagName"#,
2250 )
2251 .unwrap();
2252 match result {
2253 Value::String(s) => assert_eq!(s, "P"),
2254 v => panic!("expected 'P', got {v:?}"),
2255 }
2256 }
2257
2258 #[test]
2259 fn test_query_selector_not_found() {
2260 let result = eval_with_doc(
2261 "<html><body></body></html>",
2262 r#"document.querySelector(".missing")"#,
2263 )
2264 .unwrap();
2265 assert!(matches!(result, Value::Null));
2266 }
2267
2268 #[test]
2269 fn test_query_selector_all() {
2270 let result = eval_with_doc(
2271 r#"<html><body><p>a</p><p>b</p><p>c</p></body></html>"#,
2272 r#"document.querySelectorAll("p").length"#,
2273 )
2274 .unwrap();
2275 match result {
2276 Value::Number(n) => assert_eq!(n, 3.0),
2277 v => panic!("expected 3, got {v:?}"),
2278 }
2279 }
2280
2281 #[test]
2282 fn test_get_elements_by_tag_name() {
2283 let result = eval_with_doc(
2284 r#"<html><body><div>a</div><div>b</div></body></html>"#,
2285 r#"document.getElementsByTagName("div").length"#,
2286 )
2287 .unwrap();
2288 match result {
2289 Value::Number(n) => assert_eq!(n, 2.0),
2290 v => panic!("expected 2, got {v:?}"),
2291 }
2292 }
2293
2294 #[test]
2295 fn test_get_elements_by_class_name() {
2296 let result = eval_with_doc(
2297 r#"<html><body><span class="x">a</span><span class="x">b</span></body></html>"#,
2298 r#"document.getElementsByClassName("x").length"#,
2299 )
2300 .unwrap();
2301 match result {
2302 Value::Number(n) => assert_eq!(n, 2.0),
2303 v => panic!("expected 2, got {v:?}"),
2304 }
2305 }
2306
2307 #[test]
2308 fn test_wrapper_identity() {
2309 let result = eval_with_doc(
2310 r#"<html><body><div id="x"></div></body></html>"#,
2311 r#"document.getElementById("x") === document.getElementById("x")"#,
2312 )
2313 .unwrap();
2314 match result {
2315 Value::Boolean(b) => assert!(b, "same node should return same wrapper object"),
2316 v => panic!("expected true, got {v:?}"),
2317 }
2318 }
2319
2320 #[test]
2321 fn test_document_title() {
2322 let result = eval_with_doc(
2323 "<html><head><title>Test Page</title></head><body></body></html>",
2324 "document.title",
2325 )
2326 .unwrap();
2327 match result {
2328 Value::String(s) => assert_eq!(s, "Test Page"),
2329 v => panic!("expected 'Test Page', got {v:?}"),
2330 }
2331 }
2332
2333 #[test]
2334 fn test_element_class_name() {
2335 let result = eval_with_doc(
2336 r#"<html><body><div id="d" class="foo bar"></div></body></html>"#,
2337 r#"document.getElementById("d").className"#,
2338 )
2339 .unwrap();
2340 match result {
2341 Value::String(s) => assert_eq!(s, "foo bar"),
2342 v => panic!("expected 'foo bar', got {v:?}"),
2343 }
2344 }
2345
2346 #[test]
2347 fn test_missing_element_returns_null() {
2348 let result = eval_with_doc(
2349 "<html><body></body></html>",
2350 r#"document.getElementById("nope") === null"#,
2351 )
2352 .unwrap();
2353 match result {
2354 Value::Boolean(b) => assert!(b),
2355 v => panic!("expected true, got {v:?}"),
2356 }
2357 }
2358
2359 // ── appendChild tests ────────────────────────────────
2360
2361 #[test]
2362 fn test_append_child_moves_node() {
2363 let result = eval_with_doc(
2364 r#"<html><body><div id="parent"></div></body></html>"#,
2365 r#"
2366 var parent = document.getElementById("parent");
2367 var child = document.createElement("span");
2368 parent.appendChild(child);
2369 parent.hasChildNodes()
2370 "#,
2371 )
2372 .unwrap();
2373 assert!(matches!(result, Value::Boolean(true)));
2374 }
2375
2376 #[test]
2377 fn test_append_child_returns_child() {
2378 let result = eval_with_doc(
2379 r#"<html><body><div id="p"></div></body></html>"#,
2380 r#"
2381 var p = document.getElementById("p");
2382 var c = document.createElement("span");
2383 var returned = p.appendChild(c);
2384 returned === c
2385 "#,
2386 )
2387 .unwrap();
2388 assert!(matches!(result, Value::Boolean(true)));
2389 }
2390
2391 // ── removeChild tests ────────────────────────────────
2392
2393 #[test]
2394 fn test_remove_child() {
2395 let result = eval_with_doc(
2396 r#"<html><body><div id="p"><span id="c"></span></div></body></html>"#,
2397 r#"
2398 var p = document.getElementById("p");
2399 var c = document.getElementById("c");
2400 p.removeChild(c);
2401 p.hasChildNodes()
2402 "#,
2403 )
2404 .unwrap();
2405 assert!(matches!(result, Value::Boolean(false)));
2406 }
2407
2408 // ── insertBefore tests ───────────────────────────────
2409
2410 #[test]
2411 fn test_insert_before() {
2412 let result = eval_with_doc(
2413 r#"<html><body><div id="p"><span id="ref"></span></div></body></html>"#,
2414 r#"
2415 var p = document.getElementById("p");
2416 var ref = document.getElementById("ref");
2417 var newNode = document.createElement("em");
2418 p.insertBefore(newNode, ref);
2419 p.firstChild.tagName
2420 "#,
2421 )
2422 .unwrap();
2423 match result {
2424 Value::String(s) => assert_eq!(s, "EM"),
2425 v => panic!("expected 'EM', got {v:?}"),
2426 }
2427 }
2428
2429 #[test]
2430 fn test_insert_before_null_appends() {
2431 let result = eval_with_doc(
2432 r#"<html><body><div id="p"></div></body></html>"#,
2433 r#"
2434 var p = document.getElementById("p");
2435 var newNode = document.createElement("em");
2436 p.insertBefore(newNode, null);
2437 p.firstChild.tagName
2438 "#,
2439 )
2440 .unwrap();
2441 match result {
2442 Value::String(s) => assert_eq!(s, "EM"),
2443 v => panic!("expected 'EM', got {v:?}"),
2444 }
2445 }
2446
2447 // ── replaceChild tests ───────────────────────────────
2448
2449 #[test]
2450 fn test_replace_child() {
2451 let result = eval_with_doc(
2452 r#"<html><body><div id="p"><span id="old"></span></div></body></html>"#,
2453 r#"
2454 var p = document.getElementById("p");
2455 var old = document.getElementById("old");
2456 var newEl = document.createElement("em");
2457 p.replaceChild(newEl, old);
2458 p.firstChild.tagName
2459 "#,
2460 )
2461 .unwrap();
2462 match result {
2463 Value::String(s) => assert_eq!(s, "EM"),
2464 v => panic!("expected 'EM', got {v:?}"),
2465 }
2466 }
2467
2468 // ── cloneNode tests ──────────────────────────────────
2469
2470 #[test]
2471 fn test_clone_node_shallow() {
2472 let result = eval_with_doc(
2473 r#"<html><body><div id="orig"><span>child</span></div></body></html>"#,
2474 r#"
2475 var orig = document.getElementById("orig");
2476 var clone = orig.cloneNode(false);
2477 clone.hasChildNodes()
2478 "#,
2479 )
2480 .unwrap();
2481 assert!(matches!(result, Value::Boolean(false)));
2482 }
2483
2484 #[test]
2485 fn test_clone_node_deep() {
2486 let result = eval_with_doc(
2487 r#"<html><body><div id="orig"><span>child</span></div></body></html>"#,
2488 r#"
2489 var orig = document.getElementById("orig");
2490 var clone = orig.cloneNode(true);
2491 clone.hasChildNodes()
2492 "#,
2493 )
2494 .unwrap();
2495 assert!(matches!(result, Value::Boolean(true)));
2496 }
2497
2498 // ── textContent tests ────────────────────────────────
2499
2500 #[test]
2501 fn test_text_content_get() {
2502 let result = eval_with_doc(
2503 r#"<html><body><div id="d">hello <span>world</span></div></body></html>"#,
2504 r#"document.getElementById("d").textContent"#,
2505 )
2506 .unwrap();
2507 match result {
2508 Value::String(s) => assert_eq!(s, "hello world"),
2509 v => panic!("expected 'hello world', got {v:?}"),
2510 }
2511 }
2512
2513 #[test]
2514 fn test_text_content_set() {
2515 let result = eval_with_doc(
2516 r#"<html><body><div id="d"><span>old</span></div></body></html>"#,
2517 r#"
2518 var d = document.getElementById("d");
2519 d.textContent = "new text";
2520 d.textContent
2521 "#,
2522 )
2523 .unwrap();
2524 match result {
2525 Value::String(s) => assert_eq!(s, "new text"),
2526 v => panic!("expected 'new text', got {v:?}"),
2527 }
2528 }
2529
2530 // ── innerHTML tests ──────────────────────────────────
2531
2532 #[test]
2533 fn test_inner_html_get() {
2534 let result = eval_with_doc(
2535 r#"<html><body><div id="d"><span>hi</span></div></body></html>"#,
2536 r#"document.getElementById("d").innerHTML"#,
2537 )
2538 .unwrap();
2539 match result {
2540 Value::String(s) => assert_eq!(s, "<span>hi</span>"),
2541 v => panic!("expected '<span>hi</span>', got {v:?}"),
2542 }
2543 }
2544
2545 #[test]
2546 fn test_inner_html_set() {
2547 let result = eval_with_doc(
2548 r#"<html><body><div id="d"></div></body></html>"#,
2549 r#"
2550 var d = document.getElementById("d");
2551 d.innerHTML = "<em>new</em>";
2552 d.firstChild.tagName
2553 "#,
2554 )
2555 .unwrap();
2556 match result {
2557 Value::String(s) => assert_eq!(s, "EM"),
2558 v => panic!("expected 'EM', got {v:?}"),
2559 }
2560 }
2561
2562 // ── outerHTML test ───────────────────────────────────
2563
2564 #[test]
2565 fn test_outer_html() {
2566 let result = eval_with_doc(
2567 r#"<html><body><div id="d"><span>hi</span></div></body></html>"#,
2568 r#"document.getElementById("d").outerHTML"#,
2569 )
2570 .unwrap();
2571 match result {
2572 Value::String(s) => assert!(
2573 s.contains("<div") && s.contains("</div>") && s.contains("<span>hi</span>"),
2574 "unexpected outerHTML: {s}"
2575 ),
2576 v => panic!("expected string, got {v:?}"),
2577 }
2578 }
2579
2580 // ── getAttribute / setAttribute tests ────────────────
2581
2582 #[test]
2583 fn test_get_attribute() {
2584 let result = eval_with_doc(
2585 r#"<html><body><a id="link" href="https://example.com">x</a></body></html>"#,
2586 r#"document.getElementById("link").getAttribute("href")"#,
2587 )
2588 .unwrap();
2589 match result {
2590 Value::String(s) => assert_eq!(s, "https://example.com"),
2591 v => panic!("expected URL, got {v:?}"),
2592 }
2593 }
2594
2595 #[test]
2596 fn test_get_attribute_missing() {
2597 let result = eval_with_doc(
2598 r#"<html><body><div id="d"></div></body></html>"#,
2599 r#"document.getElementById("d").getAttribute("nope") === null"#,
2600 )
2601 .unwrap();
2602 assert!(matches!(result, Value::Boolean(true)));
2603 }
2604
2605 #[test]
2606 fn test_set_attribute() {
2607 let result = eval_with_doc(
2608 r#"<html><body><div id="d"></div></body></html>"#,
2609 r#"
2610 var d = document.getElementById("d");
2611 d.setAttribute("data-x", "123");
2612 d.getAttribute("data-x")
2613 "#,
2614 )
2615 .unwrap();
2616 match result {
2617 Value::String(s) => assert_eq!(s, "123"),
2618 v => panic!("expected '123', got {v:?}"),
2619 }
2620 }
2621
2622 #[test]
2623 fn test_has_attribute() {
2624 let result = eval_with_doc(
2625 r#"<html><body><div id="d" class="foo"></div></body></html>"#,
2626 r#"
2627 var d = document.getElementById("d");
2628 d.hasAttribute("class") && !d.hasAttribute("nope")
2629 "#,
2630 )
2631 .unwrap();
2632 assert!(matches!(result, Value::Boolean(true)));
2633 }
2634
2635 #[test]
2636 fn test_remove_attribute() {
2637 let result = eval_with_doc(
2638 r#"<html><body><div id="d" class="foo"></div></body></html>"#,
2639 r#"
2640 var d = document.getElementById("d");
2641 d.removeAttribute("class");
2642 d.hasAttribute("class")
2643 "#,
2644 )
2645 .unwrap();
2646 assert!(matches!(result, Value::Boolean(false)));
2647 }
2648
2649 // ── Navigation property tests ────────────────────────
2650
2651 #[test]
2652 fn test_parent_node() {
2653 let result = eval_with_doc(
2654 r#"<html><body><div id="child"></div></body></html>"#,
2655 r#"document.getElementById("child").parentNode.tagName"#,
2656 )
2657 .unwrap();
2658 match result {
2659 Value::String(s) => assert_eq!(s, "BODY"),
2660 v => panic!("expected 'BODY', got {v:?}"),
2661 }
2662 }
2663
2664 #[test]
2665 fn test_child_nodes() {
2666 let result = eval_with_doc(
2667 r#"<html><body><div id="p"><span>a</span><span>b</span></div></body></html>"#,
2668 r#"document.getElementById("p").childNodes.length"#,
2669 )
2670 .unwrap();
2671 match result {
2672 Value::Number(n) => assert_eq!(n, 2.0),
2673 v => panic!("expected 2, got {v:?}"),
2674 }
2675 }
2676
2677 #[test]
2678 fn test_children_element_only() {
2679 let result = eval_with_doc(
2680 r#"<html><body><div id="p"><span>a</span>text<span>b</span></div></body></html>"#,
2681 r#"document.getElementById("p").children.length"#,
2682 )
2683 .unwrap();
2684 match result {
2685 Value::Number(n) => assert_eq!(n, 2.0),
2686 v => panic!("expected 2, got {v:?}"),
2687 }
2688 }
2689
2690 #[test]
2691 fn test_first_child() {
2692 let result = eval_with_doc(
2693 r#"<html><body><div id="p"><span>a</span><em>b</em></div></body></html>"#,
2694 r#"document.getElementById("p").firstChild.tagName"#,
2695 )
2696 .unwrap();
2697 match result {
2698 Value::String(s) => assert_eq!(s, "SPAN"),
2699 v => panic!("expected 'SPAN', got {v:?}"),
2700 }
2701 }
2702
2703 #[test]
2704 fn test_last_child() {
2705 let result = eval_with_doc(
2706 r#"<html><body><div id="p"><span>a</span><em>b</em></div></body></html>"#,
2707 r#"document.getElementById("p").lastChild.tagName"#,
2708 )
2709 .unwrap();
2710 match result {
2711 Value::String(s) => assert_eq!(s, "EM"),
2712 v => panic!("expected 'EM', got {v:?}"),
2713 }
2714 }
2715
2716 #[test]
2717 fn test_next_sibling() {
2718 let result = eval_with_doc(
2719 r#"<html><body><span id="a">1</span><em id="b">2</em></body></html>"#,
2720 r#"document.getElementById("a").nextSibling.tagName"#,
2721 )
2722 .unwrap();
2723 match result {
2724 Value::String(s) => assert_eq!(s, "EM"),
2725 v => panic!("expected 'EM', got {v:?}"),
2726 }
2727 }
2728
2729 #[test]
2730 fn test_previous_sibling() {
2731 let result = eval_with_doc(
2732 r#"<html><body><span id="a">1</span><em id="b">2</em></body></html>"#,
2733 r#"document.getElementById("b").previousSibling.tagName"#,
2734 )
2735 .unwrap();
2736 match result {
2737 Value::String(s) => assert_eq!(s, "SPAN"),
2738 v => panic!("expected 'SPAN', got {v:?}"),
2739 }
2740 }
2741
2742 #[test]
2743 fn test_null_navigation() {
2744 let result = eval_with_doc(
2745 r#"<html><body><div id="only"></div></body></html>"#,
2746 r#"document.getElementById("only").nextSibling === null"#,
2747 )
2748 .unwrap();
2749 assert!(matches!(result, Value::Boolean(true)));
2750 }
2751
2752 // ── style property tests ─────────────────────────────
2753
2754 #[test]
2755 fn test_style_set_and_get() {
2756 let result = eval_with_doc(
2757 r#"<html><body><div id="d"></div></body></html>"#,
2758 r#"
2759 var d = document.getElementById("d");
2760 d.style.color = "red";
2761 d.style.color
2762 "#,
2763 )
2764 .unwrap();
2765 match result {
2766 Value::String(s) => assert_eq!(s, "red"),
2767 v => panic!("expected 'red', got {v:?}"),
2768 }
2769 }
2770
2771 #[test]
2772 fn test_style_read_existing() {
2773 let result = eval_with_doc(
2774 r#"<html><body><div id="d" style="color: blue"></div></body></html>"#,
2775 r#"document.getElementById("d").style.color"#,
2776 )
2777 .unwrap();
2778 match result {
2779 Value::String(s) => assert_eq!(s, "blue"),
2780 v => panic!("expected 'blue', got {v:?}"),
2781 }
2782 }
2783
2784 // ── classList tests ──────────────────────────────────
2785
2786 #[test]
2787 fn test_class_list_add() {
2788 let result = eval_with_doc(
2789 r#"<html><body><div id="d"></div></body></html>"#,
2790 r#"
2791 var d = document.getElementById("d");
2792 d.classList.add("foo");
2793 d.getAttribute("class")
2794 "#,
2795 )
2796 .unwrap();
2797 match result {
2798 Value::String(s) => assert_eq!(s, "foo"),
2799 v => panic!("expected 'foo', got {v:?}"),
2800 }
2801 }
2802
2803 #[test]
2804 fn test_class_list_remove() {
2805 let result = eval_with_doc(
2806 r#"<html><body><div id="d" class="foo bar"></div></body></html>"#,
2807 r#"
2808 var d = document.getElementById("d");
2809 d.classList.remove("foo");
2810 d.getAttribute("class")
2811 "#,
2812 )
2813 .unwrap();
2814 match result {
2815 Value::String(s) => assert_eq!(s, "bar"),
2816 v => panic!("expected 'bar', got {v:?}"),
2817 }
2818 }
2819
2820 #[test]
2821 fn test_class_list_toggle() {
2822 let result = eval_with_doc(
2823 r#"<html><body><div id="d" class="foo"></div></body></html>"#,
2824 r#"
2825 var d = document.getElementById("d");
2826 var removed = d.classList.toggle("foo");
2827 var added = d.classList.toggle("bar");
2828 !removed && added
2829 "#,
2830 )
2831 .unwrap();
2832 assert!(matches!(result, Value::Boolean(true)));
2833 }
2834
2835 #[test]
2836 fn test_class_list_contains() {
2837 let result = eval_with_doc(
2838 r#"<html><body><div id="d" class="foo bar"></div></body></html>"#,
2839 r#"
2840 var d = document.getElementById("d");
2841 d.classList.contains("foo") && !d.classList.contains("baz")
2842 "#,
2843 )
2844 .unwrap();
2845 assert!(matches!(result, Value::Boolean(true)));
2846 }
2847
2848 // ── setAttribute syncs id/className ──────────────────
2849
2850 #[test]
2851 fn test_set_attribute_syncs_id() {
2852 let result = eval_with_doc(
2853 r#"<html><body><div id="d"></div></body></html>"#,
2854 r#"
2855 var d = document.getElementById("d");
2856 d.setAttribute("id", "new_id");
2857 d.id
2858 "#,
2859 )
2860 .unwrap();
2861 match result {
2862 Value::String(s) => assert_eq!(s, "new_id"),
2863 v => panic!("expected 'new_id', got {v:?}"),
2864 }
2865 }
2866
2867 // ── id and className setters ─────────────────────────
2868
2869 #[test]
2870 fn test_set_id_syncs_attribute() {
2871 let result = eval_with_doc(
2872 r#"<html><body><div id="d"></div></body></html>"#,
2873 r#"
2874 var d = document.getElementById("d");
2875 d.id = "new_id";
2876 d.getAttribute("id")
2877 "#,
2878 )
2879 .unwrap();
2880 match result {
2881 Value::String(s) => assert_eq!(s, "new_id"),
2882 v => panic!("expected 'new_id', got {v:?}"),
2883 }
2884 }
2885
2886 #[test]
2887 fn test_set_class_name_syncs_attribute() {
2888 let result = eval_with_doc(
2889 r#"<html><body><div id="d"></div></body></html>"#,
2890 r#"
2891 var d = document.getElementById("d");
2892 d.className = "a b";
2893 d.getAttribute("class")
2894 "#,
2895 )
2896 .unwrap();
2897 match result {
2898 Value::String(s) => assert_eq!(s, "a b"),
2899 v => panic!("expected 'a b', got {v:?}"),
2900 }
2901 }
2902
2903 // ── Navigation after DOM modification ────────────────
2904
2905 #[test]
2906 fn test_navigation_after_append() {
2907 let result = eval_with_doc(
2908 r#"<html><body><div id="p"></div></body></html>"#,
2909 r#"
2910 var p = document.getElementById("p");
2911 var a = document.createElement("span");
2912 var b = document.createElement("em");
2913 p.appendChild(a);
2914 p.appendChild(b);
2915 p.firstChild.tagName + "," + p.lastChild.tagName
2916 "#,
2917 )
2918 .unwrap();
2919 match result {
2920 Value::String(s) => assert_eq!(s, "SPAN,EM"),
2921 v => panic!("expected 'SPAN,EM', got {v:?}"),
2922 }
2923 }
2924
2925 // ── Removed nodes are detached ───────────────────────
2926
2927 #[test]
2928 fn test_removed_node_detached() {
2929 let result = eval_with_doc(
2930 r#"<html><body><div id="p"><span id="c"></span></div></body></html>"#,
2931 r#"
2932 var p = document.getElementById("p");
2933 var c = document.getElementById("c");
2934 p.removeChild(c);
2935 c.parentNode === null
2936 "#,
2937 )
2938 .unwrap();
2939 assert!(matches!(result, Value::Boolean(true)));
2940 }
2941
2942 // ── Event system tests ──────────────────────────────────
2943
2944 #[test]
2945 fn test_event_constructor() {
2946 let result = eval_with_doc(
2947 "<html><body></body></html>",
2948 r#"
2949 var e = new Event("click");
2950 e.type
2951 "#,
2952 )
2953 .unwrap();
2954 match result {
2955 Value::String(s) => assert_eq!(s, "click"),
2956 v => panic!("expected 'click', got {v:?}"),
2957 }
2958 }
2959
2960 #[test]
2961 fn test_event_constructor_options() {
2962 let result = eval_with_doc(
2963 "<html><body></body></html>",
2964 r#"
2965 var e = new Event("click", { bubbles: true, cancelable: true });
2966 e.bubbles + "," + e.cancelable
2967 "#,
2968 )
2969 .unwrap();
2970 match result {
2971 Value::String(s) => assert_eq!(s, "true,true"),
2972 v => panic!("expected 'true,true', got {v:?}"),
2973 }
2974 }
2975
2976 #[test]
2977 fn test_event_default_properties() {
2978 let result = eval_with_doc(
2979 "<html><body></body></html>",
2980 r#"
2981 var e = new Event("test");
2982 e.bubbles + "," + e.cancelable + "," + e.defaultPrevented + "," + e.eventPhase
2983 "#,
2984 )
2985 .unwrap();
2986 match result {
2987 Value::String(s) => assert_eq!(s, "false,false,false,0"),
2988 v => panic!("expected 'false,false,false,0', got {v:?}"),
2989 }
2990 }
2991
2992 #[test]
2993 fn test_add_event_listener_and_dispatch() {
2994 let result = eval_with_doc(
2995 r#"<html><body><div id="d"></div></body></html>"#,
2996 r#"
2997 var called = false;
2998 var d = document.getElementById("d");
2999 d.addEventListener("click", function(e) {
3000 called = true;
3001 });
3002 d.dispatchEvent(new Event("click"));
3003 called
3004 "#,
3005 )
3006 .unwrap();
3007 assert!(matches!(result, Value::Boolean(true)));
3008 }
3009
3010 #[test]
3011 fn test_event_handler_receives_event_object() {
3012 let result = eval_with_doc(
3013 r#"<html><body><div id="d"></div></body></html>"#,
3014 r#"
3015 var eventType = "";
3016 var d = document.getElementById("d");
3017 d.addEventListener("myevent", function(e) {
3018 eventType = e.type;
3019 });
3020 d.dispatchEvent(new Event("myevent"));
3021 eventType
3022 "#,
3023 )
3024 .unwrap();
3025 match result {
3026 Value::String(s) => assert_eq!(s, "myevent"),
3027 v => panic!("expected 'myevent', got {v:?}"),
3028 }
3029 }
3030
3031 #[test]
3032 fn test_event_target_set_correctly() {
3033 let result = eval_with_doc(
3034 r#"<html><body><div id="d"></div></body></html>"#,
3035 r#"
3036 var targetTag = "";
3037 var d = document.getElementById("d");
3038 d.addEventListener("click", function(e) {
3039 targetTag = e.target.tagName;
3040 });
3041 d.dispatchEvent(new Event("click"));
3042 targetTag
3043 "#,
3044 )
3045 .unwrap();
3046 match result {
3047 Value::String(s) => assert_eq!(s, "DIV"),
3048 v => panic!("expected 'DIV', got {v:?}"),
3049 }
3050 }
3051
3052 #[test]
3053 fn test_event_bubbling() {
3054 let result = eval_with_doc(
3055 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3056 r#"
3057 var order = "";
3058 var parent = document.getElementById("parent");
3059 var child = document.getElementById("child");
3060 parent.addEventListener("click", function(e) {
3061 order = order + "parent";
3062 });
3063 child.addEventListener("click", function(e) {
3064 order = order + "child,";
3065 });
3066 child.dispatchEvent(new Event("click", { bubbles: true }));
3067 order
3068 "#,
3069 )
3070 .unwrap();
3071 match result {
3072 Value::String(s) => assert_eq!(s, "child,parent"),
3073 v => panic!("expected 'child,parent', got {v:?}"),
3074 }
3075 }
3076
3077 #[test]
3078 fn test_event_no_bubbling_by_default() {
3079 let result = eval_with_doc(
3080 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3081 r#"
3082 var parentCalled = false;
3083 var parent = document.getElementById("parent");
3084 var child = document.getElementById("child");
3085 parent.addEventListener("test", function(e) {
3086 parentCalled = true;
3087 });
3088 child.dispatchEvent(new Event("test"));
3089 parentCalled
3090 "#,
3091 )
3092 .unwrap();
3093 assert!(matches!(result, Value::Boolean(false)));
3094 }
3095
3096 #[test]
3097 fn test_event_capture_phase() {
3098 let result = eval_with_doc(
3099 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3100 r#"
3101 var order = "";
3102 var parent = document.getElementById("parent");
3103 var child = document.getElementById("child");
3104 parent.addEventListener("click", function(e) {
3105 order = order + "capture,";
3106 }, true);
3107 parent.addEventListener("click", function(e) {
3108 order = order + "bubble";
3109 });
3110 child.addEventListener("click", function(e) {
3111 order = order + "target,";
3112 });
3113 child.dispatchEvent(new Event("click", { bubbles: true }));
3114 order
3115 "#,
3116 )
3117 .unwrap();
3118 match result {
3119 Value::String(s) => assert_eq!(s, "capture,target,bubble"),
3120 v => panic!("expected 'capture,target,bubble', got {v:?}"),
3121 }
3122 }
3123
3124 #[test]
3125 fn test_stop_propagation() {
3126 let result = eval_with_doc(
3127 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3128 r#"
3129 var parentCalled = false;
3130 var parent = document.getElementById("parent");
3131 var child = document.getElementById("child");
3132 parent.addEventListener("click", function(e) {
3133 parentCalled = true;
3134 });
3135 child.addEventListener("click", function(e) {
3136 e.stopPropagation();
3137 });
3138 child.dispatchEvent(new Event("click", { bubbles: true }));
3139 parentCalled
3140 "#,
3141 )
3142 .unwrap();
3143 assert!(matches!(result, Value::Boolean(false)));
3144 }
3145
3146 #[test]
3147 fn test_stop_immediate_propagation() {
3148 let result = eval_with_doc(
3149 r#"<html><body><div id="d"></div></body></html>"#,
3150 r#"
3151 var count = 0;
3152 var d = document.getElementById("d");
3153 d.addEventListener("click", function(e) {
3154 count = count + 1;
3155 e.stopImmediatePropagation();
3156 });
3157 d.addEventListener("click", function(e) {
3158 count = count + 1;
3159 });
3160 d.dispatchEvent(new Event("click"));
3161 count
3162 "#,
3163 )
3164 .unwrap();
3165 match result {
3166 Value::Number(n) => assert_eq!(n, 1.0),
3167 v => panic!("expected 1, got {v:?}"),
3168 }
3169 }
3170
3171 #[test]
3172 fn test_prevent_default() {
3173 let result = eval_with_doc(
3174 r#"<html><body><div id="d"></div></body></html>"#,
3175 r#"
3176 var d = document.getElementById("d");
3177 d.addEventListener("click", function(e) {
3178 e.preventDefault();
3179 });
3180 var result = d.dispatchEvent(new Event("click", { cancelable: true }));
3181 result
3182 "#,
3183 )
3184 .unwrap();
3185 // dispatchEvent returns false when preventDefault was called.
3186 assert!(matches!(result, Value::Boolean(false)));
3187 }
3188
3189 #[test]
3190 fn test_prevent_default_not_cancelable() {
3191 let result = eval_with_doc(
3192 r#"<html><body><div id="d"></div></body></html>"#,
3193 r#"
3194 var d = document.getElementById("d");
3195 d.addEventListener("click", function(e) {
3196 e.preventDefault();
3197 });
3198 var result = d.dispatchEvent(new Event("click"));
3199 result
3200 "#,
3201 )
3202 .unwrap();
3203 // Non-cancelable: preventDefault has no effect, returns true.
3204 assert!(matches!(result, Value::Boolean(true)));
3205 }
3206
3207 #[test]
3208 fn test_remove_event_listener() {
3209 let result = eval_with_doc(
3210 r#"<html><body><div id="d"></div></body></html>"#,
3211 r#"
3212 var count = 0;
3213 var d = document.getElementById("d");
3214 var handler = function(e) { count = count + 1; };
3215 d.addEventListener("click", handler);
3216 d.dispatchEvent(new Event("click"));
3217 d.removeEventListener("click", handler);
3218 d.dispatchEvent(new Event("click"));
3219 count
3220 "#,
3221 )
3222 .unwrap();
3223 match result {
3224 Value::Number(n) => assert_eq!(n, 1.0),
3225 v => panic!("expected 1, got {v:?}"),
3226 }
3227 }
3228
3229 #[test]
3230 fn test_once_option() {
3231 let result = eval_with_doc(
3232 r#"<html><body><div id="d"></div></body></html>"#,
3233 r#"
3234 var count = 0;
3235 var d = document.getElementById("d");
3236 d.addEventListener("click", function(e) {
3237 count = count + 1;
3238 }, { once: true });
3239 d.dispatchEvent(new Event("click"));
3240 d.dispatchEvent(new Event("click"));
3241 count
3242 "#,
3243 )
3244 .unwrap();
3245 match result {
3246 Value::Number(n) => assert_eq!(n, 1.0),
3247 v => panic!("expected 1, got {v:?}"),
3248 }
3249 }
3250
3251 #[test]
3252 fn test_dispatch_event_returns_true_normally() {
3253 let result = eval_with_doc(
3254 r#"<html><body><div id="d"></div></body></html>"#,
3255 r#"
3256 var d = document.getElementById("d");
3257 d.dispatchEvent(new Event("click"))
3258 "#,
3259 )
3260 .unwrap();
3261 assert!(matches!(result, Value::Boolean(true)));
3262 }
3263
3264 #[test]
3265 fn test_event_current_target() {
3266 let result = eval_with_doc(
3267 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3268 r#"
3269 var currentTag = "";
3270 var parent = document.getElementById("parent");
3271 var child = document.getElementById("child");
3272 parent.addEventListener("click", function(e) {
3273 currentTag = e.currentTarget.tagName;
3274 });
3275 child.dispatchEvent(new Event("click", { bubbles: true }));
3276 currentTag
3277 "#,
3278 )
3279 .unwrap();
3280 match result {
3281 Value::String(s) => assert_eq!(s, "DIV"),
3282 v => panic!("expected 'DIV', got {v:?}"),
3283 }
3284 }
3285
3286 #[test]
3287 fn test_multiple_listeners_same_type() {
3288 let result = eval_with_doc(
3289 r#"<html><body><div id="d"></div></body></html>"#,
3290 r#"
3291 var order = "";
3292 var d = document.getElementById("d");
3293 d.addEventListener("click", function(e) { order = order + "a,"; });
3294 d.addEventListener("click", function(e) { order = order + "b"; });
3295 d.dispatchEvent(new Event("click"));
3296 order
3297 "#,
3298 )
3299 .unwrap();
3300 match result {
3301 Value::String(s) => assert_eq!(s, "a,b"),
3302 v => panic!("expected 'a,b', got {v:?}"),
3303 }
3304 }
3305
3306 #[test]
3307 fn test_capture_option_object() {
3308 let result = eval_with_doc(
3309 r#"<html><body><div id="parent"><span id="child"></span></div></body></html>"#,
3310 r#"
3311 var capturePhase = false;
3312 var parent = document.getElementById("parent");
3313 var child = document.getElementById("child");
3314 parent.addEventListener("click", function(e) {
3315 capturePhase = (e.eventPhase === 1);
3316 }, { capture: true });
3317 child.dispatchEvent(new Event("click", { bubbles: true }));
3318 capturePhase
3319 "#,
3320 )
3321 .unwrap();
3322 assert!(matches!(result, Value::Boolean(true)));
3323 }
3324
3325 #[test]
3326 fn test_document_add_event_listener() {
3327 let result = eval_with_doc(
3328 r#"<html><body><div id="d"></div></body></html>"#,
3329 r#"
3330 var docCalled = false;
3331 document.addEventListener("click", function(e) {
3332 docCalled = true;
3333 });
3334 var d = document.getElementById("d");
3335 d.dispatchEvent(new Event("click", { bubbles: true }));
3336 docCalled
3337 "#,
3338 )
3339 .unwrap();
3340 assert!(matches!(result, Value::Boolean(true)));
3341 }
3342}