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
+263 -30

No files matched your search

+54 -5
View File
@@ -500,6 +500,14 @@ pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, S
impl DomTree { impl DomTree {
pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> { pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> {
self.query_selector_from(self.document(), selector)
}
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> {
self.query_selector_all_from(self.document(), selector)
}
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
let selector_list = parse_selector(selector)?; let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default(); let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new( let mut context = MatchingContext::new(
@@ -511,8 +519,7 @@ impl DomTree {
MatchingForInvalidation::No, MatchingForInvalidation::No,
); );
let doc = self.document(); for desc_id in self.descendants(root) {
for desc_id in self.descendants(doc) {
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
if is_element { if is_element {
let element = DomElement::new(self, desc_id); let element = DomElement::new(self, desc_id);
@@ -528,7 +535,7 @@ impl DomTree {
Ok(None) Ok(None)
} }
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> { pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
let selector_list = parse_selector(selector)?; let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default(); let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new( let mut context = MatchingContext::new(
@@ -541,8 +548,7 @@ impl DomTree {
); );
let mut results = Vec::new(); let mut results = Vec::new();
let doc = self.document(); for desc_id in self.descendants(root) {
for desc_id in self.descendants(doc) {
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
if is_element { if is_element {
let element = DomElement::new(self, desc_id); let element = DomElement::new(self, desc_id);
@@ -638,4 +644,47 @@ mod tests {
let results = tree.query_selector_all(".list .item.active").unwrap(); let results = tree.query_selector_all(".list .item.active").unwrap();
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
} }
#[test]
fn test_query_selector_all_from_scopes_to_subtree() {
let tree = parse_html(
r#"<div id="a"><span class="x">in a</span></div><div id="b"><span class="x">in b</span></div>"#,
);
let a = tree.get_element_by_id("a").expect("div#a");
let b = tree.get_element_by_id("b").expect("div#b");
// Document-rooted: sees both spans.
assert_eq!(tree.query_selector_all(".x").unwrap().len(), 2);
// Scoped to #a: only its descendant span.
let in_a = tree.query_selector_all_from(a, ".x").unwrap();
assert_eq!(in_a.len(), 1);
let in_b = tree.query_selector_all_from(b, ".x").unwrap();
assert_eq!(in_b.len(), 1);
assert_ne!(in_a[0], in_b[0]);
}
#[test]
fn test_query_selector_from_returns_first_in_subtree_only() {
let tree = parse_html(
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
);
let s = tree.get_element_by_id("s").expect("section#s");
// Scoped to #s: skip the outside paragraph; return the first inside.
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside");
assert_eq!(tree.text_content(first_in_s), "first");
}
#[test]
fn test_query_selector_from_excludes_self() {
// The root element itself must not match its own scoped query, per
// the spec: querySelector matches descendants only.
let tree = parse_html(r#"<div id="root" class="x"><span>child</span></div>"#);
let root = tree.get_element_by_id("root").expect("div#root");
// Only descendants are candidates: `.x` on root finds nothing.
assert!(tree.query_selector_from(root, ".x").unwrap().is_none());
// `span` finds the child.
assert!(tree.query_selector_from(root, "span").unwrap().is_some());
}
} }
+81 -25
View File
@@ -146,27 +146,42 @@ globalThis.console = {
}; };
let _tid = 0; let _tid = 0;
const _pendingTimers = new Map();
const _clearedTimers = new Set(); const _clearedTimers = new Set();
const _intervals = new Set();
const _scheduleAfter = (delay, fn) => {
const d = Math.max(0, Number(delay) || 0);
if (d === 0) Promise.resolve().then(fn);
else Deno.core.ops.op_sleep(d).then(fn);
};
globalThis.setTimeout = (fn, delay = 0, ...args) => { globalThis.setTimeout = (fn, delay = 0, ...args) => {
if (typeof fn !== "function") return ++_tid; if (typeof fn !== "function") return ++_tid;
const id = ++_tid; const id = ++_tid;
_pendingTimers.set(id, { fn, args, delay }); _scheduleAfter(delay, () => {
Promise.resolve().then(() => { if (_clearedTimers.has(id)) return;
if (!_clearedTimers.has(id) && _pendingTimers.has(id)) {
_pendingTimers.delete(id);
try { fn(...args); } catch(e) { console.error("Timer error:", e); } try { fn(...args); } catch(e) { console.error("Timer error:", e); }
}
}); });
return id; return id;
}; };
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); _pendingTimers.delete(id); }; globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
globalThis.setInterval = (fn, delay, ...args) => {
return setTimeout(fn, delay, ...args); globalThis.setInterval = (fn, delay = 0, ...args) => {
if (typeof fn !== "function") return ++_tid;
const id = ++_tid;
_intervals.add(id);
const tick = () => {
if (!_intervals.has(id)) return;
try { fn(...args); } catch(e) { console.error("Interval error:", e); }
if (!_intervals.has(id)) return;
_scheduleAfter(delay, tick);
};
_scheduleAfter(delay, tick);
return id;
}; };
globalThis.clearInterval = globalThis.clearTimeout;
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); };
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
globalThis.cancelAnimationFrame = globalThis.clearTimeout; globalThis.cancelAnimationFrame = globalThis.clearTimeout;
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
@@ -292,14 +307,14 @@ class Node {
} }
replaceChild(newChild, oldChild) { replaceChild(newChild, oldChild) {
if (!oldChild || !newChild) return oldChild; if (!oldChild || !newChild) return oldChild;
_dom("insert_before", this._nid, newChild._nid, oldChild._nid); _dom("insert_before", newChild._nid, oldChild._nid);
_dom("remove_child", oldChild._nid); _dom("remove_child", oldChild._nid);
return oldChild; return oldChild;
} }
insertBefore(n, ref) { insertBefore(n, ref) {
if (!n) return n; if (!n) return n;
if (!ref) { this.appendChild(n); return n; } if (!ref) { this.appendChild(n); return n; }
_dom("insert_before", this._nid, n._nid, ref._nid); _dom("insert_before", n._nid, ref._nid);
return n; return n;
} }
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; } contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
@@ -341,7 +356,28 @@ class Node {
} }
getRootNode() { return globalThis.document; } getRootNode() { return globalThis.document; }
normalize() {} // no-op normalize() {} // no-op
isEqualNode(other) { return other && this._nid === other._nid; } isEqualNode(other) {
if (!other) return false;
if (this._nid === other._nid) return true;
if (this.nodeType !== other.nodeType) return false;
if (this.nodeName !== other.nodeName) return false;
if (this.nodeValue !== other.nodeValue) return false;
const a = this.attributes ? this.attributes : null;
const b = other.attributes ? other.attributes : null;
if ((a && a.length) || (b && b.length)) {
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (other.getAttribute(a[i].name) !== a[i].value) return false;
}
}
const cA = this.childNodes || [];
const cB = other.childNodes || [];
if (cA.length !== cB.length) return false;
for (let i = 0; i < cA.length; i++) {
if (!cA[i].isEqualNode(cB[i])) return false;
}
return true;
}
isSameNode(other) { return other && this._nid === other._nid; } isSameNode(other) { return other && this._nid === other._nid; }
addEventListener() {} removeEventListener() {} dispatchEvent() { return true; } addEventListener() {} removeEventListener() {} dispatchEvent() { return true; }
} }
@@ -464,9 +500,9 @@ class Element extends Node {
hasAttribute(n) { return this.getAttribute(n) !== null; } hasAttribute(n) { return this.getAttribute(n) !== null; }
hasAttributes() { return true; } // Simplified hasAttributes() { return true; } // Simplified
getAttributeNS(ns, n) { return this.getAttribute(n); } getAttributeNS(ns, n) { return this.getAttribute(n); }
querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
querySelectorAll(s) { querySelectorAll(s) {
const ids = _domParse("query_selector_all", s) || []; const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
const list = ids.map(_wrapEl).filter(Boolean); const list = ids.map(_wrapEl).filter(Boolean);
list.item = (i) => list[i] || null; list.item = (i) => list[i] || null;
list.forEach = Array.prototype.forEach.bind(list); list.forEach = Array.prototype.forEach.bind(list);
@@ -791,7 +827,14 @@ class Element extends Node {
} }
remove() { if (this.parentNode) this.parentNode.removeChild(this); } remove() { if (this.parentNode) this.parentNode.removeChild(this); }
append(...nodes) { for (const n of nodes) { if (typeof n === "string") this.appendChild(document.createTextNode(n)); else this.appendChild(n); } } append(...nodes) { for (const n of nodes) { if (typeof n === "string") this.appendChild(document.createTextNode(n)); else this.appendChild(n); } }
prepend() {} prepend(...nodes) {
const ref = this.firstChild;
for (const n of nodes) {
const node = (typeof n === "string") ? document.createTextNode(n) : n;
if (ref) this.insertBefore(node, ref);
else this.appendChild(node);
}
}
} }
class Document extends Node { class Document extends Node {
@@ -1005,9 +1048,9 @@ class Document extends Node {
}; };
} }
get styleSheets() { return []; } get styleSheets() { return []; }
get forms() { return []; } get forms() { return this.querySelectorAll("form"); }
get images() { return []; } get images() { return this.querySelectorAll("img"); }
get links() { return []; } get links() { return this.querySelectorAll("a[href], area[href]"); }
get scripts() { return this.querySelectorAll("script"); } get scripts() { return this.querySelectorAll("script"); }
get cookie() { get cookie() {
return Deno.core.ops.op_get_cookies(); return Deno.core.ops.op_get_cookies();
@@ -1048,9 +1091,9 @@ class DocumentFragment extends Node {
get nodeName() { return "#document-fragment"; } get nodeName() { return "#document-fragment"; }
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); }
querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
querySelectorAll(s) { querySelectorAll(s) {
const ids = _domParse("query_selector_all", s) || []; const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
const list = ids.map(_wrapEl).filter(Boolean); const list = ids.map(_wrapEl).filter(Boolean);
list.item = (i) => list[i] || null; list.item = (i) => list[i] || null;
return list; return list;
@@ -1085,12 +1128,17 @@ class DocumentType extends Node {
} }
const _cache = new Map(); const _cache = new Map();
function _elementClassFor(nid) {
const tag = _domParse("tag_name", nid);
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
return Element;
}
function _wrap(nid) { function _wrap(nid) {
if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null;
if (_cache.has(nid)) return _cache.get(nid); if (_cache.has(nid)) return _cache.get(nid);
const t = +_dom("node_type", nid); const t = +_dom("node_type", nid);
let n; let n;
if (t === 1) n = new Element(nid); if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); }
else if (t === 3) n = new Text(nid); else if (t === 3) n = new Text(nid);
else if (t === 8) n = new Comment(nid); else if (t === 8) n = new Comment(nid);
else if (t === 9) n = new Document(nid); else if (t === 9) n = new Document(nid);
@@ -1101,7 +1149,8 @@ function _wrap(nid) {
function _wrapEl(nid) { function _wrapEl(nid) {
if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null;
if (_cache.has(nid)) return _cache.get(nid); if (_cache.has(nid)) return _cache.get(nid);
const n = new Element(nid); const C = _elementClassFor(nid);
const n = new C(nid);
_cache.set(nid, n); _cache.set(nid, n);
return n; return n;
} }
@@ -2124,7 +2173,13 @@ globalThis.HTMLAnchorElement = Element;
globalThis.HTMLImageElement = Element; globalThis.HTMLImageElement = Element;
globalThis.HTMLInputElement = Element; globalThis.HTMLInputElement = Element;
globalThis.HTMLButtonElement = Element; globalThis.HTMLButtonElement = Element;
globalThis.HTMLFormElement = Element; globalThis.HTMLFormElement = class HTMLFormElement extends Element {
get elements() { return this.querySelectorAll("input, select, textarea, button, fieldset, output, object"); }
get length() { return this.elements.length; }
// Inherit submit() from Element.prototype: it dispatches the cancelable
// 'submit' event and (if not prevented) builds form data and navigates.
reset() { for (const f of this.elements) { if ('value' in f) f.value = ''; } }
};
globalThis.HTMLSelectElement = Element; globalThis.HTMLSelectElement = Element;
globalThis.HTMLTextAreaElement = Element; globalThis.HTMLTextAreaElement = Element;
globalThis.HTMLLabelElement = Element; globalThis.HTMLLabelElement = Element;
@@ -2193,8 +2248,9 @@ globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNode
Element.prototype.focus, Element.prototype.blur, Element.prototype.focus, Element.prototype.blur,
Element.prototype.cloneNode, Element.prototype.attachShadow, Element.prototype.cloneNode, Element.prototype.attachShadow,
Element.prototype.insertAdjacentHTML, Element.prototype.scrollIntoView, Element.prototype.insertAdjacentHTML, Element.prototype.scrollIntoView,
Element.prototype.append, Element.prototype.remove, Element.prototype.append, Element.prototype.prepend, Element.prototype.remove,
Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith, Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith,
HTMLFormElement.prototype.reset,
Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob, Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob,
Node.prototype.appendChild, Node.prototype.removeChild, Node.prototype.appendChild, Node.prototype.removeChild,
Node.prototype.replaceChild, Node.prototype.insertBefore, Node.prototype.replaceChild, Node.prototype.insertBefore,
+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(); .map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default();
serde_json::to_string(&ids).unwrap_or("[]".into()) 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" => { "node_type" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
dom.get_node(NodeId::new(nid)).map(|n| match &n.data { 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())); 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 { pub fn build_extension() -> Extension {
Extension { Extension {
name: "obscura_dom", name: "obscura_dom",
@@ -752,6 +768,7 @@ pub fn build_extension() -> Extension {
op_get_cookies(), op_get_cookies(),
op_set_cookie(), op_set_cookie(),
op_navigate(), op_navigate(),
op_sleep(),
]), ]),
..Default::default() ..Default::default()
} }
+111
View File
@@ -965,6 +965,117 @@ mod tests {
assert_eq!(text, serde_json::json!("BODY_TEXT")); 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] #[test]
fn test_console_log() { fn test_console_log() {
let mut rt = setup_runtime("<html><body></body></html>"); let mut rt = setup_runtime("<html><body></body></html>");