we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement DOM-JS bridge: document object and element access

Add the bridge between the JS engine and the DOM crate (Phase 11, issue 2 of 8).

- DomBridge struct holds shared Document and wrapper identity cache
- Vm::attach_document() registers document global with structural properties
- document.documentElement, .head, .body, .title properties
- document.getElementById, getElementsByTagName, getElementsByClassName
- document.querySelector/querySelectorAll (reuses css/style selector matching)
- document.createElement, createTextNode factory methods
- Element wrappers with tagName, nodeName, nodeType, id, className
- Wrapper identity: same NodeId always returns same JS object
- NodeId::from_index added to dom crate
- Parser::parse_selectors added to css crate
- 20 tests covering all acceptance criteria

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+823 -2
+3
Cargo.lock
··· 53 53 name = "we-js" 54 54 version = "0.1.0" 55 55 dependencies = [ 56 + "we-css", 56 57 "we-dom", 58 + "we-html", 59 + "we-style", 57 60 ] 58 61 59 62 [[package]]
+7
crates/css/src/parser.rs
··· 165 165 parser.parse_declaration_list() 166 166 } 167 167 168 + /// Parse a selector list from a string (for `querySelector`/`querySelectorAll`). 169 + pub fn parse_selectors(input: &str) -> SelectorList { 170 + let tokens = Tokenizer::tokenize(input); 171 + let mut parser = Self { tokens, pos: 0 }; 172 + parser.parse_selector_list() 173 + } 174 + 168 175 // -- Token access ------------------------------------------------------- 169 176 170 177 fn peek(&self) -> &Token {
+5
crates/dom/src/lib.rs
··· 14 14 pub fn index(self) -> usize { 15 15 self.0 16 16 } 17 + 18 + /// Create a `NodeId` from a raw index. 19 + pub fn from_index(index: usize) -> Self { 20 + NodeId(index) 21 + } 17 22 } 18 23 19 24 /// An HTML/XML attribute (name-value pair).
+5
crates/js/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 we-dom = { path = "../dom" } 12 + we-css = { path = "../css" } 13 + we-style = { path = "../style" } 14 + 15 + [dev-dependencies] 16 + we-html = { path = "../html" }
+7 -2
crates/js/src/builtins.rs
··· 20 20 // ── Helpers ────────────────────────────────────────────────── 21 21 22 22 /// Create a native function GcRef. 23 - fn make_native( 23 + pub fn make_native( 24 24 gc: &mut Gc<HeapObject>, 25 25 name: &str, 26 26 callback: fn(&[Value], &mut NativeContext) -> Result<Value, RuntimeError>, ··· 35 35 } 36 36 37 37 /// Set a non-enumerable property on an object. 38 - fn set_builtin_prop(gc: &mut Gc<HeapObject>, obj: GcRef, key: &str, val: Value) { 38 + pub fn set_builtin_prop(gc: &mut Gc<HeapObject>, obj: GcRef, key: &str, val: Value) { 39 39 if let Some(HeapObject::Object(data)) = gc.get_mut(obj) { 40 40 data.properties 41 41 .insert(key.to_string(), Property::builtin(val)); ··· 1286 1286 gc: ctx.gc, 1287 1287 this: ctx.this.clone(), 1288 1288 console_output: ctx.console_output, 1289 + dom_bridge: ctx.dom_bridge, 1289 1290 }, 1290 1291 ) 1291 1292 .or_else(|_| Ok(Value::String(String::new()))) ··· 4505 4506 callback, 4506 4507 &[v, k, this_val.clone()], 4507 4508 ctx.console_output, 4509 + ctx.dom_bridge, 4508 4510 )?; 4509 4511 } 4510 4512 Ok(Value::Undefined) ··· 4610 4612 func_ref: GcRef, 4611 4613 args: &[Value], 4612 4614 console_output: &dyn ConsoleOutput, 4615 + dom_bridge: Option<&DomBridge>, 4613 4616 ) -> Result<Value, RuntimeError> { 4614 4617 match gc.get(func_ref) { 4615 4618 Some(HeapObject::Function(fdata)) => match &fdata.kind { ··· 4619 4622 gc, 4620 4623 this: Value::Undefined, 4621 4624 console_output, 4625 + dom_bridge, 4622 4626 }; 4623 4627 cb(args, &mut ctx) 4624 4628 } ··· 4760 4764 callback, 4761 4765 &[v.clone(), v, this_val.clone()], 4762 4766 ctx.console_output, 4767 + ctx.dom_bridge, 4763 4768 )?; 4764 4769 } 4765 4770 Ok(Value::Undefined)
+765
crates/js/src/dom_bridge.rs
··· 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 + 7 + use crate::builtins::{make_native, set_builtin_prop}; 8 + use crate::gc::{Gc, GcRef}; 9 + use crate::vm::*; 10 + use we_css::parser::Parser as CssParser; 11 + use we_dom::{Document, NodeData, NodeId}; 12 + use we_style::matching::matches_selector_list; 13 + 14 + /// Native callback type alias. 15 + type NativeMethod = ( 16 + &'static str, 17 + fn(&[Value], &mut NativeContext) -> Result<Value, RuntimeError>, 18 + ); 19 + 20 + // ── Internal property key for storing NodeId on wrapper objects ────── 21 + 22 + const NODE_ID_KEY: &str = "__node_id__"; 23 + 24 + // ── Node wrapper creation ─────────────────────────────────────────── 25 + 26 + /// Get or create a JS wrapper object for a DOM node. Returns the same 27 + /// `GcRef` for the same `NodeId` (wrapper identity). 28 + fn get_or_create_wrapper( 29 + node_id: NodeId, 30 + gc: &mut Gc<HeapObject>, 31 + bridge: &DomBridge, 32 + object_proto: Option<GcRef>, 33 + ) -> GcRef { 34 + let idx = node_id.index(); 35 + if let Some(&existing) = bridge.node_wrappers.borrow().get(&idx) { 36 + // Verify the GcRef is still alive. 37 + if gc.get(existing).is_some() { 38 + return existing; 39 + } 40 + } 41 + 42 + let doc = bridge.document.borrow(); 43 + let mut data = ObjectData::new(); 44 + if let Some(proto) = object_proto { 45 + data.prototype = Some(proto); 46 + } 47 + 48 + // Store the NodeId index as an internal property. 49 + data.properties.insert( 50 + NODE_ID_KEY.to_string(), 51 + Property::builtin(Value::Number(idx as f64)), 52 + ); 53 + 54 + // Populate properties based on node type. 55 + match doc.node_data(node_id) { 56 + NodeData::Element { 57 + tag_name, 58 + attributes, 59 + .. 60 + } => { 61 + let upper_tag = tag_name.to_ascii_uppercase(); 62 + data.properties.insert( 63 + "tagName".to_string(), 64 + Property::builtin(Value::String(upper_tag.clone())), 65 + ); 66 + data.properties.insert( 67 + "nodeName".to_string(), 68 + Property::builtin(Value::String(upper_tag)), 69 + ); 70 + data.properties.insert( 71 + "nodeType".to_string(), 72 + Property::builtin(Value::Number(1.0)), 73 + ); 74 + 75 + // id attribute 76 + let id_val = attributes 77 + .iter() 78 + .find(|a| a.name == "id") 79 + .map(|a| Value::String(a.value.clone())) 80 + .unwrap_or(Value::String(String::new())); 81 + data.properties 82 + .insert("id".to_string(), Property::builtin(id_val)); 83 + 84 + // className attribute 85 + let class_val = attributes 86 + .iter() 87 + .find(|a| a.name == "class") 88 + .map(|a| Value::String(a.value.clone())) 89 + .unwrap_or(Value::String(String::new())); 90 + data.properties 91 + .insert("className".to_string(), Property::builtin(class_val)); 92 + } 93 + NodeData::Text { .. } => { 94 + data.properties.insert( 95 + "nodeName".to_string(), 96 + Property::builtin(Value::String("#text".to_string())), 97 + ); 98 + data.properties.insert( 99 + "nodeType".to_string(), 100 + Property::builtin(Value::Number(3.0)), 101 + ); 102 + } 103 + NodeData::Comment { .. } => { 104 + data.properties.insert( 105 + "nodeName".to_string(), 106 + Property::builtin(Value::String("#comment".to_string())), 107 + ); 108 + data.properties.insert( 109 + "nodeType".to_string(), 110 + Property::builtin(Value::Number(8.0)), 111 + ); 112 + } 113 + NodeData::Document => { 114 + data.properties.insert( 115 + "nodeName".to_string(), 116 + Property::builtin(Value::String("#document".to_string())), 117 + ); 118 + data.properties.insert( 119 + "nodeType".to_string(), 120 + Property::builtin(Value::Number(9.0)), 121 + ); 122 + } 123 + } 124 + 125 + let gc_ref = gc.alloc(HeapObject::Object(data)); 126 + bridge.node_wrappers.borrow_mut().insert(idx, gc_ref); 127 + gc_ref 128 + } 129 + 130 + // ── Helper: walk DOM tree ─────────────────────────────────────────── 131 + 132 + fn walk_tree(doc: &Document, root: NodeId, visitor: &mut dyn FnMut(NodeId) -> bool) { 133 + let mut stack = vec![root]; 134 + while let Some(node) = stack.pop() { 135 + if visitor(node) { 136 + return; 137 + } 138 + // Push children in reverse order so first child is visited first. 139 + let mut children: Vec<NodeId> = doc.children(node).collect(); 140 + children.reverse(); 141 + stack.extend(children); 142 + } 143 + } 144 + 145 + // ── Helper: make an array of wrapper Values ───────────────────────── 146 + 147 + fn make_wrapper_array( 148 + nodes: &[NodeId], 149 + gc: &mut Gc<HeapObject>, 150 + bridge: &DomBridge, 151 + object_proto: Option<GcRef>, 152 + ) -> Value { 153 + let mut obj = ObjectData::new(); 154 + for (i, &nid) in nodes.iter().enumerate() { 155 + let wrapper = get_or_create_wrapper(nid, gc, bridge, object_proto); 156 + obj.properties 157 + .insert(i.to_string(), Property::data(Value::Object(wrapper))); 158 + } 159 + obj.properties.insert( 160 + "length".to_string(), 161 + Property { 162 + value: Value::Number(nodes.len() as f64), 163 + writable: true, 164 + enumerable: false, 165 + configurable: false, 166 + }, 167 + ); 168 + Value::Object(gc.alloc(HeapObject::Object(obj))) 169 + } 170 + 171 + // ── Document global setup ─────────────────────────────────────────── 172 + 173 + /// Register the `document` global object on the VM. Called from 174 + /// `Vm::attach_document`. 175 + pub fn init_document_object(vm: &mut Vm) { 176 + let mut data = ObjectData::new(); 177 + if let Some(proto) = vm.object_prototype { 178 + data.prototype = Some(proto); 179 + } 180 + 181 + // nodeType 9 = Document 182 + data.properties.insert( 183 + "nodeType".to_string(), 184 + Property::builtin(Value::Number(9.0)), 185 + ); 186 + data.properties.insert( 187 + "nodeName".to_string(), 188 + Property::builtin(Value::String("#document".to_string())), 189 + ); 190 + 191 + // Set document.title from the DOM. 192 + if let Some(bridge) = &vm.dom_bridge { 193 + let doc = bridge.document.borrow(); 194 + let title = find_title_text(&doc); 195 + data.properties 196 + .insert("title".to_string(), Property::builtin(Value::String(title))); 197 + } 198 + 199 + let doc_ref = vm.gc.alloc(HeapObject::Object(data)); 200 + 201 + // Set documentElement, head, body as wrapper properties. 202 + if let Some(bridge) = &vm.dom_bridge { 203 + let (html_id, head_id, body_id) = { 204 + let doc = bridge.document.borrow(); 205 + find_structural_elements(&doc) 206 + }; 207 + if let Some(html) = html_id { 208 + let wrapper = get_or_create_wrapper(html, &mut vm.gc, bridge, vm.object_prototype); 209 + set_builtin_prop( 210 + &mut vm.gc, 211 + doc_ref, 212 + "documentElement", 213 + Value::Object(wrapper), 214 + ); 215 + } 216 + if let Some(head) = head_id { 217 + let wrapper = get_or_create_wrapper(head, &mut vm.gc, bridge, vm.object_prototype); 218 + set_builtin_prop(&mut vm.gc, doc_ref, "head", Value::Object(wrapper)); 219 + } 220 + if let Some(body) = body_id { 221 + let wrapper = get_or_create_wrapper(body, &mut vm.gc, bridge, vm.object_prototype); 222 + set_builtin_prop(&mut vm.gc, doc_ref, "body", Value::Object(wrapper)); 223 + } 224 + } 225 + 226 + // Register methods on the document object. 227 + let methods: &[NativeMethod] = &[ 228 + ("getElementById", doc_get_element_by_id), 229 + ("getElementsByTagName", doc_get_elements_by_tag_name), 230 + ("getElementsByClassName", doc_get_elements_by_class_name), 231 + ("querySelector", doc_query_selector), 232 + ("querySelectorAll", doc_query_selector_all), 233 + ("createElement", doc_create_element), 234 + ("createTextNode", doc_create_text_node), 235 + ]; 236 + for &(name, callback) in methods { 237 + let func = make_native(&mut vm.gc, name, callback); 238 + set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func)); 239 + } 240 + 241 + vm.set_global("document", Value::Object(doc_ref)); 242 + } 243 + 244 + /// Find `<html>`, `<head>`, and `<body>` elements in the document. 245 + fn find_structural_elements(doc: &Document) -> (Option<NodeId>, Option<NodeId>, Option<NodeId>) { 246 + let mut html = None; 247 + let mut head = None; 248 + let mut body = None; 249 + 250 + for child in doc.children(doc.root()) { 251 + if let NodeData::Element { tag_name, .. } = doc.node_data(child) { 252 + if tag_name.eq_ignore_ascii_case("html") { 253 + html = Some(child); 254 + for inner in doc.children(child) { 255 + if let NodeData::Element { tag_name, .. } = doc.node_data(inner) { 256 + if tag_name.eq_ignore_ascii_case("head") { 257 + head = Some(inner); 258 + } else if tag_name.eq_ignore_ascii_case("body") { 259 + body = Some(inner); 260 + } 261 + } 262 + } 263 + break; 264 + } 265 + } 266 + } 267 + 268 + (html, head, body) 269 + } 270 + 271 + /// Extract the text content of `<title>` from the document. 272 + fn find_title_text(doc: &Document) -> String { 273 + let mut result = String::new(); 274 + walk_tree(doc, doc.root(), &mut |node| { 275 + if let NodeData::Element { tag_name, .. } = doc.node_data(node) { 276 + if tag_name.eq_ignore_ascii_case("title") { 277 + // Collect text children. 278 + for child in doc.children(node) { 279 + if let Some(text) = doc.text_content(child) { 280 + result.push_str(text); 281 + } 282 + } 283 + return true; // stop walking 284 + } 285 + } 286 + false 287 + }); 288 + result 289 + } 290 + 291 + // ── Document methods ──────────────────────────────────────────────── 292 + 293 + fn doc_get_element_by_id(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 294 + let id = args 295 + .first() 296 + .map(|v| v.to_js_string(ctx.gc)) 297 + .unwrap_or_default(); 298 + 299 + let bridge = match ctx.dom_bridge { 300 + Some(b) => b, 301 + None => return Ok(Value::Null), 302 + }; 303 + 304 + let found = { 305 + let doc = bridge.document.borrow(); 306 + let mut found = None; 307 + walk_tree(&doc, doc.root(), &mut |node| { 308 + if let Some(attr_val) = doc.get_attribute(node, "id") { 309 + if attr_val == id { 310 + found = Some(node); 311 + return true; 312 + } 313 + } 314 + false 315 + }); 316 + found 317 + }; 318 + 319 + match found { 320 + Some(node_id) => { 321 + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); 322 + Ok(Value::Object(wrapper)) 323 + } 324 + None => Ok(Value::Null), 325 + } 326 + } 327 + 328 + fn doc_get_elements_by_tag_name( 329 + args: &[Value], 330 + ctx: &mut NativeContext, 331 + ) -> Result<Value, RuntimeError> { 332 + let tag = args 333 + .first() 334 + .map(|v| v.to_js_string(ctx.gc)) 335 + .unwrap_or_default(); 336 + 337 + let bridge = match ctx.dom_bridge { 338 + Some(b) => b, 339 + None => return Ok(make_empty_array(ctx.gc)), 340 + }; 341 + 342 + let matches = { 343 + let doc = bridge.document.borrow(); 344 + let mut matches = Vec::new(); 345 + walk_tree(&doc, doc.root(), &mut |node| { 346 + if let NodeData::Element { tag_name: tn, .. } = doc.node_data(node) { 347 + if tn.eq_ignore_ascii_case(&tag) || tag == "*" { 348 + matches.push(node); 349 + } 350 + } 351 + false 352 + }); 353 + matches 354 + }; 355 + 356 + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) 357 + } 358 + 359 + fn doc_get_elements_by_class_name( 360 + args: &[Value], 361 + ctx: &mut NativeContext, 362 + ) -> Result<Value, RuntimeError> { 363 + let class_name = args 364 + .first() 365 + .map(|v| v.to_js_string(ctx.gc)) 366 + .unwrap_or_default(); 367 + 368 + let bridge = match ctx.dom_bridge { 369 + Some(b) => b, 370 + None => return Ok(make_empty_array(ctx.gc)), 371 + }; 372 + 373 + let matches = { 374 + let doc = bridge.document.borrow(); 375 + let mut matches = Vec::new(); 376 + walk_tree(&doc, doc.root(), &mut |node| { 377 + if let Some(cls) = doc.get_attribute(node, "class") { 378 + if cls.split_whitespace().any(|c| c == class_name) { 379 + matches.push(node); 380 + } 381 + } 382 + false 383 + }); 384 + matches 385 + }; 386 + 387 + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) 388 + } 389 + 390 + fn doc_query_selector(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 391 + let selector_str = args 392 + .first() 393 + .map(|v| v.to_js_string(ctx.gc)) 394 + .unwrap_or_default(); 395 + 396 + let bridge = match ctx.dom_bridge { 397 + Some(b) => b, 398 + None => return Ok(Value::Null), 399 + }; 400 + 401 + let selector_list = CssParser::parse_selectors(&selector_str); 402 + if selector_list.selectors.is_empty() { 403 + return Ok(Value::Null); 404 + } 405 + 406 + let found = { 407 + let doc = bridge.document.borrow(); 408 + let mut found = None; 409 + walk_tree(&doc, doc.root(), &mut |node| { 410 + if matches!(doc.node_data(node), NodeData::Element { .. }) 411 + && matches_selector_list(&doc, node, &selector_list) 412 + { 413 + found = Some(node); 414 + return true; 415 + } 416 + false 417 + }); 418 + found 419 + }; 420 + 421 + match found { 422 + Some(node_id) => { 423 + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); 424 + Ok(Value::Object(wrapper)) 425 + } 426 + None => Ok(Value::Null), 427 + } 428 + } 429 + 430 + fn doc_query_selector_all(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 431 + let selector_str = args 432 + .first() 433 + .map(|v| v.to_js_string(ctx.gc)) 434 + .unwrap_or_default(); 435 + 436 + let bridge = match ctx.dom_bridge { 437 + Some(b) => b, 438 + None => return Ok(make_empty_array(ctx.gc)), 439 + }; 440 + 441 + let selector_list = CssParser::parse_selectors(&selector_str); 442 + 443 + let matches = { 444 + let doc = bridge.document.borrow(); 445 + let mut matches = Vec::new(); 446 + walk_tree(&doc, doc.root(), &mut |node| { 447 + if matches!(doc.node_data(node), NodeData::Element { .. }) 448 + && matches_selector_list(&doc, node, &selector_list) 449 + { 450 + matches.push(node); 451 + } 452 + false 453 + }); 454 + matches 455 + }; 456 + 457 + Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) 458 + } 459 + 460 + fn doc_create_element(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 461 + let tag = args 462 + .first() 463 + .map(|v| v.to_js_string(ctx.gc)) 464 + .unwrap_or_default(); 465 + 466 + let bridge = match ctx.dom_bridge { 467 + Some(b) => b, 468 + None => return Err(RuntimeError::type_error("no document attached")), 469 + }; 470 + 471 + let node_id = bridge.document.borrow_mut().create_element(&tag); 472 + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); 473 + Ok(Value::Object(wrapper)) 474 + } 475 + 476 + fn doc_create_text_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 477 + let text = args 478 + .first() 479 + .map(|v| v.to_js_string(ctx.gc)) 480 + .unwrap_or_default(); 481 + 482 + let bridge = match ctx.dom_bridge { 483 + Some(b) => b, 484 + None => return Err(RuntimeError::type_error("no document attached")), 485 + }; 486 + 487 + let node_id = bridge.document.borrow_mut().create_text(&text); 488 + let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); 489 + Ok(Value::Object(wrapper)) 490 + } 491 + 492 + /// Create an empty JS array. 493 + fn make_empty_array(gc: &mut Gc<HeapObject>) -> Value { 494 + let mut obj = ObjectData::new(); 495 + obj.properties.insert( 496 + "length".to_string(), 497 + Property { 498 + value: Value::Number(0.0), 499 + writable: true, 500 + enumerable: false, 501 + configurable: false, 502 + }, 503 + ); 504 + Value::Object(gc.alloc(HeapObject::Object(obj))) 505 + } 506 + 507 + // ── Tests ─────────────────────────────────────────────────────────── 508 + 509 + #[cfg(test)] 510 + mod tests { 511 + use super::*; 512 + use crate::compiler; 513 + use crate::parser::Parser; 514 + use we_html::parse_html; 515 + 516 + /// Build a Document from HTML source by parsing it with the HTML parser. 517 + fn doc_from_html(html: &str) -> we_dom::Document { 518 + parse_html(html) 519 + } 520 + 521 + /// Evaluate JS with a DOM document attached. 522 + fn eval_with_doc(html: &str, js: &str) -> Result<Value, RuntimeError> { 523 + let doc = doc_from_html(html); 524 + let program = Parser::parse(js).expect("parse failed"); 525 + let func = compiler::compile(&program).expect("compile failed"); 526 + let mut vm = Vm::new(); 527 + vm.attach_document(doc); 528 + vm.execute(&func) 529 + } 530 + 531 + #[test] 532 + fn test_document_is_global() { 533 + let result = eval_with_doc("<html><body></body></html>", "typeof document").unwrap(); 534 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "object"); 535 + } 536 + 537 + #[test] 538 + fn test_document_node_type() { 539 + let result = eval_with_doc("<html><body></body></html>", "document.nodeType").unwrap(); 540 + match result { 541 + Value::Number(n) => assert_eq!(n, 9.0), 542 + v => panic!("expected 9, got {v:?}"), 543 + } 544 + } 545 + 546 + #[test] 547 + fn test_document_body() { 548 + let result = eval_with_doc("<html><body></body></html>", "document.body.tagName").unwrap(); 549 + match result { 550 + Value::String(s) => assert_eq!(s, "BODY"), 551 + v => panic!("expected 'BODY', got {v:?}"), 552 + } 553 + } 554 + 555 + #[test] 556 + fn test_document_head() { 557 + let result = eval_with_doc( 558 + "<html><head></head><body></body></html>", 559 + "document.head.tagName", 560 + ) 561 + .unwrap(); 562 + match result { 563 + Value::String(s) => assert_eq!(s, "HEAD"), 564 + v => panic!("expected 'HEAD', got {v:?}"), 565 + } 566 + } 567 + 568 + #[test] 569 + fn test_document_document_element() { 570 + let result = eval_with_doc( 571 + "<html><body></body></html>", 572 + "document.documentElement.tagName", 573 + ) 574 + .unwrap(); 575 + match result { 576 + Value::String(s) => assert_eq!(s, "HTML"), 577 + v => panic!("expected 'HTML', got {v:?}"), 578 + } 579 + } 580 + 581 + #[test] 582 + fn test_get_element_by_id_found() { 583 + let result = eval_with_doc( 584 + r#"<html><body><div id="foo">hello</div></body></html>"#, 585 + r#"document.getElementById("foo").tagName"#, 586 + ) 587 + .unwrap(); 588 + match result { 589 + Value::String(s) => assert_eq!(s, "DIV"), 590 + v => panic!("expected 'DIV', got {v:?}"), 591 + } 592 + } 593 + 594 + #[test] 595 + fn test_get_element_by_id_not_found() { 596 + let result = eval_with_doc( 597 + "<html><body></body></html>", 598 + r#"document.getElementById("nope")"#, 599 + ) 600 + .unwrap(); 601 + assert!(matches!(result, Value::Null)); 602 + } 603 + 604 + #[test] 605 + fn test_get_element_by_id_returns_correct_id() { 606 + let result = eval_with_doc( 607 + r#"<html><body><div id="myid"></div></body></html>"#, 608 + r#"document.getElementById("myid").id"#, 609 + ) 610 + .unwrap(); 611 + match result { 612 + Value::String(s) => assert_eq!(s, "myid"), 613 + v => panic!("expected 'myid', got {v:?}"), 614 + } 615 + } 616 + 617 + #[test] 618 + fn test_create_element() { 619 + let result = eval_with_doc( 620 + "<html><body></body></html>", 621 + r#"document.createElement("div").tagName"#, 622 + ) 623 + .unwrap(); 624 + match result { 625 + Value::String(s) => assert_eq!(s, "DIV"), 626 + v => panic!("expected 'DIV', got {v:?}"), 627 + } 628 + } 629 + 630 + #[test] 631 + fn test_create_text_node() { 632 + let result = eval_with_doc( 633 + "<html><body></body></html>", 634 + r#"document.createTextNode("hello").nodeType"#, 635 + ) 636 + .unwrap(); 637 + match result { 638 + Value::Number(n) => assert_eq!(n, 3.0), 639 + v => panic!("expected 3, got {v:?}"), 640 + } 641 + } 642 + 643 + #[test] 644 + fn test_element_node_type() { 645 + let result = eval_with_doc("<html><body></body></html>", "document.body.nodeType").unwrap(); 646 + match result { 647 + Value::Number(n) => assert_eq!(n, 1.0), 648 + v => panic!("expected 1, got {v:?}"), 649 + } 650 + } 651 + 652 + #[test] 653 + fn test_query_selector_by_class() { 654 + let result = eval_with_doc( 655 + r#"<html><body><p class="intro">hi</p></body></html>"#, 656 + r#"document.querySelector(".intro").tagName"#, 657 + ) 658 + .unwrap(); 659 + match result { 660 + Value::String(s) => assert_eq!(s, "P"), 661 + v => panic!("expected 'P', got {v:?}"), 662 + } 663 + } 664 + 665 + #[test] 666 + fn test_query_selector_not_found() { 667 + let result = eval_with_doc( 668 + "<html><body></body></html>", 669 + r#"document.querySelector(".missing")"#, 670 + ) 671 + .unwrap(); 672 + assert!(matches!(result, Value::Null)); 673 + } 674 + 675 + #[test] 676 + fn test_query_selector_all() { 677 + let result = eval_with_doc( 678 + r#"<html><body><p>a</p><p>b</p><p>c</p></body></html>"#, 679 + r#"document.querySelectorAll("p").length"#, 680 + ) 681 + .unwrap(); 682 + match result { 683 + Value::Number(n) => assert_eq!(n, 3.0), 684 + v => panic!("expected 3, got {v:?}"), 685 + } 686 + } 687 + 688 + #[test] 689 + fn test_get_elements_by_tag_name() { 690 + let result = eval_with_doc( 691 + r#"<html><body><div>a</div><div>b</div></body></html>"#, 692 + r#"document.getElementsByTagName("div").length"#, 693 + ) 694 + .unwrap(); 695 + match result { 696 + Value::Number(n) => assert_eq!(n, 2.0), 697 + v => panic!("expected 2, got {v:?}"), 698 + } 699 + } 700 + 701 + #[test] 702 + fn test_get_elements_by_class_name() { 703 + let result = eval_with_doc( 704 + r#"<html><body><span class="x">a</span><span class="x">b</span></body></html>"#, 705 + r#"document.getElementsByClassName("x").length"#, 706 + ) 707 + .unwrap(); 708 + match result { 709 + Value::Number(n) => assert_eq!(n, 2.0), 710 + v => panic!("expected 2, got {v:?}"), 711 + } 712 + } 713 + 714 + #[test] 715 + fn test_wrapper_identity() { 716 + let result = eval_with_doc( 717 + r#"<html><body><div id="x"></div></body></html>"#, 718 + r#"document.getElementById("x") === document.getElementById("x")"#, 719 + ) 720 + .unwrap(); 721 + match result { 722 + Value::Boolean(b) => assert!(b, "same node should return same wrapper object"), 723 + v => panic!("expected true, got {v:?}"), 724 + } 725 + } 726 + 727 + #[test] 728 + fn test_document_title() { 729 + let result = eval_with_doc( 730 + "<html><head><title>Test Page</title></head><body></body></html>", 731 + "document.title", 732 + ) 733 + .unwrap(); 734 + match result { 735 + Value::String(s) => assert_eq!(s, "Test Page"), 736 + v => panic!("expected 'Test Page', got {v:?}"), 737 + } 738 + } 739 + 740 + #[test] 741 + fn test_element_class_name() { 742 + let result = eval_with_doc( 743 + r#"<html><body><div id="d" class="foo bar"></div></body></html>"#, 744 + r#"document.getElementById("d").className"#, 745 + ) 746 + .unwrap(); 747 + match result { 748 + Value::String(s) => assert_eq!(s, "foo bar"), 749 + v => panic!("expected 'foo bar', got {v:?}"), 750 + } 751 + } 752 + 753 + #[test] 754 + fn test_missing_element_returns_null() { 755 + let result = eval_with_doc( 756 + "<html><body></body></html>", 757 + r#"document.getElementById("nope") === null"#, 758 + ) 759 + .unwrap(); 760 + match result { 761 + Value::Boolean(b) => assert!(b), 762 + v => panic!("expected true, got {v:?}"), 763 + } 764 + } 765 + }
+1
crates/js/src/lib.rs
··· 4 4 pub mod builtins; 5 5 pub mod bytecode; 6 6 pub mod compiler; 7 + pub mod dom_bridge; 7 8 pub mod gc; 8 9 pub mod lexer; 9 10 pub mod parser;
+30
crates/js/src/vm.rs
··· 7 7 8 8 use crate::bytecode::{Constant, Function, Op, Reg}; 9 9 use crate::gc::{Gc, GcRef, Traceable}; 10 + use std::cell::RefCell; 10 11 use std::collections::HashMap; 11 12 use std::fmt; 13 + use std::rc::Rc; 14 + use we_dom::Document; 12 15 13 16 // ── Heap objects (GC-managed) ──────────────────────────────── 14 17 ··· 218 221 } 219 222 } 220 223 224 + /// Bridge between JS and the DOM. Holds a shared document and a cache 225 + /// mapping `NodeId` indices to their JS wrapper `GcRef` so that the same 226 + /// DOM node always returns the same JS object (identity). 227 + pub struct DomBridge { 228 + pub document: RefCell<Document>, 229 + pub node_wrappers: RefCell<HashMap<usize, GcRef>>, 230 + } 231 + 221 232 /// Context passed to native functions, providing GC access and `this` binding. 222 233 pub struct NativeContext<'a> { 223 234 pub gc: &'a mut Gc<HeapObject>, 224 235 pub this: Value, 225 236 pub console_output: &'a dyn ConsoleOutput, 237 + pub dom_bridge: Option<&'a DomBridge>, 226 238 } 227 239 228 240 // ── JS Value ────────────────────────────────────────────────── ··· 773 785 pub promise_prototype: Option<GcRef>, 774 786 /// Console output sink (configurable for dev tools or testing). 775 787 console_output: Box<dyn ConsoleOutput>, 788 + /// DOM bridge for JS-DOM interop (set via `attach_document`). 789 + pub(crate) dom_bridge: Option<Rc<DomBridge>>, 776 790 } 777 791 778 792 /// Maximum register file size. ··· 798 812 regexp_prototype: None, 799 813 promise_prototype: None, 800 814 console_output: Box::new(StdConsoleOutput), 815 + dom_bridge: None, 801 816 }; 802 817 crate::builtins::init_builtins(&mut vm); 803 818 vm ··· 806 821 /// Replace the console output sink (e.g. for dev tools or testing). 807 822 pub fn set_console_output(&mut self, output: Box<dyn ConsoleOutput>) { 808 823 self.console_output = output; 824 + } 825 + 826 + /// Attach a DOM document to this VM, registering the `document` global 827 + /// and enabling DOM-JS interop. 828 + pub fn attach_document(&mut self, doc: Document) { 829 + let bridge = Rc::new(DomBridge { 830 + document: RefCell::new(doc), 831 + node_wrappers: RefCell::new(HashMap::new()), 832 + }); 833 + self.dom_bridge = Some(bridge); 834 + crate::dom_bridge::init_document_object(self); 809 835 } 810 836 811 837 /// Set an instruction limit. The VM will return a RuntimeError after ··· 861 887 .get("this") 862 888 .cloned() 863 889 .unwrap_or(Value::Undefined); 890 + let dom_ref = self.dom_bridge.as_deref(); 864 891 let mut ctx = NativeContext { 865 892 gc: &mut self.gc, 866 893 this, 867 894 console_output: &*self.console_output, 895 + dom_bridge: dom_ref, 868 896 }; 869 897 let result = (native.callback)(args, &mut ctx)?; 870 898 ··· 2386 2414 .get("this") 2387 2415 .cloned() 2388 2416 .unwrap_or(Value::Undefined); 2417 + let dom_ref = self.dom_bridge.as_deref(); 2389 2418 let mut ctx = NativeContext { 2390 2419 gc: &mut self.gc, 2391 2420 this, 2392 2421 console_output: &*self.console_output, 2422 + dom_bridge: dom_ref, 2393 2423 }; 2394 2424 match callback(&args, &mut ctx) { 2395 2425 Ok(val) => {