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:
1 parent
d4d749d850
commit
71b9b4fbab
4 files changed
+263
-30
No files matched your search
@@ -500,6 +500,14 @@ pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, S
|
||||
|
||||
impl DomTree {
|
||||
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 mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
@@ -511,8 +519,7 @@ impl DomTree {
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
|
||||
let doc = self.document();
|
||||
for desc_id in self.descendants(doc) {
|
||||
for desc_id in self.descendants(root) {
|
||||
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
||||
if is_element {
|
||||
let element = DomElement::new(self, desc_id);
|
||||
@@ -528,7 +535,7 @@ impl DomTree {
|
||||
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 mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
@@ -541,8 +548,7 @@ impl DomTree {
|
||||
);
|
||||
let mut results = Vec::new();
|
||||
|
||||
let doc = self.document();
|
||||
for desc_id in self.descendants(doc) {
|
||||
for desc_id in self.descendants(root) {
|
||||
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
||||
if is_element {
|
||||
let element = DomElement::new(self, desc_id);
|
||||
@@ -638,4 +644,47 @@ mod tests {
|
||||
let results = tree.query_selector_all(".list .item.active").unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
Vendored
+81
-25
@@ -146,27 +146,42 @@ globalThis.console = {
|
||||
};
|
||||
|
||||
let _tid = 0;
|
||||
const _pendingTimers = new Map();
|
||||
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) => {
|
||||
if (typeof fn !== "function") return ++_tid;
|
||||
const id = ++_tid;
|
||||
_pendingTimers.set(id, { fn, args, delay });
|
||||
Promise.resolve().then(() => {
|
||||
if (!_clearedTimers.has(id) && _pendingTimers.has(id)) {
|
||||
_pendingTimers.delete(id);
|
||||
_scheduleAfter(delay, () => {
|
||||
if (_clearedTimers.has(id)) return;
|
||||
try { fn(...args); } catch(e) { console.error("Timer error:", e); }
|
||||
}
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); _pendingTimers.delete(id); };
|
||||
globalThis.setInterval = (fn, delay, ...args) => {
|
||||
return setTimeout(fn, delay, ...args);
|
||||
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
|
||||
|
||||
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.cancelAnimationFrame = globalThis.clearTimeout;
|
||||
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
|
||||
@@ -292,14 +307,14 @@ class Node {
|
||||
}
|
||||
replaceChild(newChild, 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);
|
||||
return oldChild;
|
||||
}
|
||||
insertBefore(n, ref) {
|
||||
if (!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;
|
||||
}
|
||||
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
|
||||
@@ -341,7 +356,28 @@ class Node {
|
||||
}
|
||||
getRootNode() { return globalThis.document; }
|
||||
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; }
|
||||
addEventListener() {} removeEventListener() {} dispatchEvent() { return true; }
|
||||
}
|
||||
@@ -464,9 +500,9 @@ class Element extends Node {
|
||||
hasAttribute(n) { return this.getAttribute(n) !== null; }
|
||||
hasAttributes() { return true; } // Simplified
|
||||
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) {
|
||||
const ids = _domParse("query_selector_all", s) || [];
|
||||
const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
|
||||
const list = ids.map(_wrapEl).filter(Boolean);
|
||||
list.item = (i) => list[i] || null;
|
||||
list.forEach = Array.prototype.forEach.bind(list);
|
||||
@@ -791,7 +827,14 @@ class Element extends Node {
|
||||
}
|
||||
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); } }
|
||||
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 {
|
||||
@@ -1005,9 +1048,9 @@ class Document extends Node {
|
||||
};
|
||||
}
|
||||
get styleSheets() { return []; }
|
||||
get forms() { return []; }
|
||||
get images() { return []; }
|
||||
get links() { return []; }
|
||||
get forms() { return this.querySelectorAll("form"); }
|
||||
get images() { return this.querySelectorAll("img"); }
|
||||
get links() { return this.querySelectorAll("a[href], area[href]"); }
|
||||
get scripts() { return this.querySelectorAll("script"); }
|
||||
get cookie() {
|
||||
return Deno.core.ops.op_get_cookies();
|
||||
@@ -1048,9 +1091,9 @@ class DocumentFragment extends Node {
|
||||
get nodeName() { return "#document-fragment"; }
|
||||
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
|
||||
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) {
|
||||
const ids = _domParse("query_selector_all", s) || [];
|
||||
const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
|
||||
const list = ids.map(_wrapEl).filter(Boolean);
|
||||
list.item = (i) => list[i] || null;
|
||||
return list;
|
||||
@@ -1085,12 +1128,17 @@ class DocumentType extends Node {
|
||||
}
|
||||
|
||||
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) {
|
||||
if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null;
|
||||
if (_cache.has(nid)) return _cache.get(nid);
|
||||
const t = +_dom("node_type", nid);
|
||||
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 === 8) n = new Comment(nid);
|
||||
else if (t === 9) n = new Document(nid);
|
||||
@@ -1101,7 +1149,8 @@ function _wrap(nid) {
|
||||
function _wrapEl(nid) {
|
||||
if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null;
|
||||
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);
|
||||
return n;
|
||||
}
|
||||
@@ -2124,7 +2173,13 @@ globalThis.HTMLAnchorElement = Element;
|
||||
globalThis.HTMLImageElement = Element;
|
||||
globalThis.HTMLInputElement = 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.HTMLTextAreaElement = Element;
|
||||
globalThis.HTMLLabelElement = Element;
|
||||
@@ -2193,8 +2248,9 @@ globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNode
|
||||
Element.prototype.focus, Element.prototype.blur,
|
||||
Element.prototype.cloneNode, Element.prototype.attachShadow,
|
||||
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,
|
||||
HTMLFormElement.prototype.reset,
|
||||
Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob,
|
||||
Node.prototype.appendChild, Node.prototype.removeChild,
|
||||
Node.prototype.replaceChild, Node.prototype.insertBefore,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
|
||||
Reference in new issue
Block a user