fix(js,dom): scope element queries, real timers, form/prepend/equal (#180)

- Element.querySelector / querySelectorAll scope to the receiver subtree
  via a new query_selector_scoped Rust op, not always document root.
  Same for DocumentFragment.
- setTimeout respects its delay argument via a new async op_sleep.
  setInterval is a real repeating timer instead of an alias to setTimeout.
- document.forms / images / links return live querySelectorAll results
  instead of hardcoded [].
- HTMLFormElement subclasses Element with .elements / .length, and form
  nodes are wrapped as HTMLFormElement so instanceof works. submit()
  stays on Element.prototype to preserve the existing submit-event
  dispatch and form-data navigation path.
- Element.prepend has a real implementation.
- Node.isEqualNode does a structural compare (nodeType, nodeName,
  nodeValue, attributes, recursive children) instead of nid identity.
- Fix long-standing insert_before arg-order bug: bootstrap.js was
  passing (parent, new, ref) but _dom only forwards two args, so ref
  was silently dropped. Pass (new, ref) directly. Same fix in
  replaceChild.

Adds 3 selector unit tests and 6 runtime regression tests covering
the JS shim fixes.

Closes #105.
This commit is contained in:
SGavrl authored and GitHub committed 2026-05-24 20:14:17 +00:00
1 parent d4d749d850
commit 71b9b4fbab
4 files changed
+264 -31

No files matched your search

+17
View File
@@ -120,6 +120,17 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
.map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"query_selector_scoped" => {
let root_nid = arg1.parse::<u32>().unwrap_or(0);
dom.query_selector_from(NodeId::new(root_nid), &arg2).ok().flatten()
.map(|id| id.index().to_string()).unwrap_or("-1".into())
}
"query_selector_all_scoped" => {
let root_nid = arg1.parse::<u32>().unwrap_or(0);
let ids: Vec<i32> = dom.query_selector_all_from(NodeId::new(root_nid), &arg2).ok()
.map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"node_type" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.get_node(NodeId::new(nid)).map(|n| match &n.data {
@@ -742,6 +753,11 @@ fn op_navigate(state: &OpState, #[string] url: &str, #[string] method: &str, #[s
gs.pending_navigation = Some((url.to_string(), method.to_string(), body.to_string()));
}
#[op2(async)]
async fn op_sleep(#[number] millis: u64) {
tokio::time::sleep(std::time::Duration::from_millis(millis)).await;
}
pub fn build_extension() -> Extension {
Extension {
name: "obscura_dom",
@@ -752,6 +768,7 @@ pub fn build_extension() -> Extension {
op_get_cookies(),
op_set_cookie(),
op_navigate(),
op_sleep(),
]),
..Default::default()
}
+111
View File
@@ -965,6 +965,117 @@ mod tests {
assert_eq!(text, serde_json::json!("BODY_TEXT"));
}
/// Regression for #105: `element.querySelector` and `querySelectorAll`
/// must scope to the receiver's subtree, not the whole document.
#[test]
fn element_query_selector_is_scoped_to_subtree() {
let mut rt = setup_runtime(
r#"<div id="a"><span class="x">in a</span></div><div id="b"><span class="x">in b</span></div>"#,
);
let text = rt
.evaluate("document.getElementById('a').querySelector('.x').textContent")
.unwrap();
assert_eq!(text, serde_json::json!("in a"));
let count_in_a = rt
.evaluate("document.getElementById('a').querySelectorAll('.x').length")
.unwrap();
assert_eq!(count_in_a.as_f64().unwrap() as i64, 1);
// Document-scoped query still sees both.
let count_doc = rt.evaluate("document.querySelectorAll('.x').length").unwrap();
assert_eq!(count_doc.as_f64().unwrap() as i64, 2);
}
/// Regression for #105: `document.forms` / `images` / `links` must be
/// live, not hardcoded `[]`. jQuery 1.x's submit-event setup iterates
/// `document.forms` and crashes when it's empty for pages that have forms.
#[test]
fn document_forms_images_links_are_live() {
let mut rt = setup_runtime(
r#"<form></form><form></form><img><a href="x">l</a><a>no-href</a>"#,
);
assert_eq!(rt.evaluate("document.forms.length").unwrap().as_f64().unwrap() as i64, 2);
assert_eq!(rt.evaluate("document.images.length").unwrap().as_f64().unwrap() as i64, 1);
assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1);
}
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
/// frameworks that probe form field collections work.
#[test]
fn html_form_element_exposes_elements_collection() {
let mut rt = setup_runtime(
r#"<form id="f"><input name=a><input name=b><textarea></textarea></form>"#,
);
let n = rt
.evaluate("document.getElementById('f').elements.length")
.unwrap();
assert_eq!(n.as_f64().unwrap() as i64, 3);
let is_form = rt
.evaluate("document.getElementById('f') instanceof HTMLFormElement")
.unwrap();
assert_eq!(is_form, serde_json::json!(true));
}
/// Regression for #105: `Element.prepend` must actually insert at the
/// start, not silently no-op.
#[test]
fn element_prepend_inserts_at_start() {
let mut rt = setup_runtime(r#"<div id="c"><span>existing</span></div>"#);
rt.evaluate(
r#"
const c = document.getElementById('c');
const n = document.createElement('span');
n.id = 'first';
c.prepend(n);
"#,
)
.unwrap();
let first_id = rt.evaluate("document.getElementById('c').firstChild.id").unwrap();
assert_eq!(first_id, serde_json::json!("first"));
let count = rt.evaluate("document.getElementById('c').childNodes.length").unwrap();
assert_eq!(count.as_f64().unwrap() as i64, 2);
}
/// Regression for #105: `isEqualNode` compares structure, not identity.
/// Framework diff algorithms rely on this.
#[test]
fn is_equal_node_does_structural_compare() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"
const a = document.createElement('div'); a.setAttribute('class', 'x'); a.innerHTML = '<span>hi</span>';
const b = document.createElement('div'); b.setAttribute('class', 'x'); b.innerHTML = '<span>hi</span>';
const c = document.createElement('div'); c.innerHTML = '<span>bye</span>';
return [a.isEqualNode(b), a.isEqualNode(c), a.isSameNode(b)];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([true, false, false]));
}
/// Regression for the long-standing insert_before arg-order bug noted
/// in CLAUDE.md: bootstrap.js was passing (parent, new, ref) but `_dom`
/// forwards only two args, silently dropping `ref`. With the fix,
/// `insertBefore` actually inserts.
#[test]
fn insert_before_inserts_node_at_correct_position() {
let mut rt = setup_runtime(r#"<div id="p"><span id="b">b</span><span id="c">c</span></div>"#);
let order = rt
.evaluate(
r#"
const p = document.getElementById('p');
const a = document.createElement('span');
a.id = 'a';
p.insertBefore(a, document.getElementById('b'));
return Array.from(p.children).map(e => e.id).join(',');
"#,
)
.unwrap();
assert_eq!(order, serde_json::json!("a,b,c"));
}
#[test]
fn test_console_log() {
let mut rt = setup_runtime("<html><body></body></html>");