feat(engine): DOM + AbortSignal + storage fixes surfaced by WPT conformance work (#219)

Engine-only changes pulled out of the WPT conformance branch. The
tests, runner, and harness vendoring that drove these have moved to a
separate benchmark repo (obscura-bench).

Real bug fixes:

- Tree::insert_before created a self-cycle when the inserted node was
  its own reference (the spec-conformant `child.after(x)` path that
  the testharness's ChildNode helpers exercise). The fixup wrote
  node.prev_sibling = node.next_sibling = own id; every subsequent
  childNodes / querySelectorAll walk then looped forever and obscura
  burned RAM until OOM. Per spec the op is a no-op; we now early-return.

- Tree::text_content extended to return own data for direct
  CharacterData access (Text / Comment / ProcessingInstruction).

- Element.namespaceURI now reflects the namespace passed to
  createElementNS instead of collapsing every non-svg namespace to
  xhtml. Without this, code that creates elements in MathML, custom
  XML namespaces, or the empty namespace gets misclassified as HTML.

- AbortController / AbortSignal: the previous shim was a plain object
  whose addEventListener was a no-op, so listeners for 'abort'
  silently never fired and any AbortSignal.timeout / AbortSignal.any
  chains broke. New impl tracks aborted/reason, has a real
  addEventListener / removeEventListener / dispatchEvent surface,
  honors `once`, and implements the static abort / timeout / any
  factories crawlers use for budgeted fetches.

- StorageEvent class added. localStorage / sessionStorage mutations
  now dispatch 'storage' events on the parent window and every iframe
  window so cross-frame onstorage listeners fire.

- ProcessingInstruction (nodeType 7) and DocumentType (nodeType 10)
  constructors plus document.createProcessingInstruction and
  document.implementation.createDocumentType. textContent returns
  null for Document and DocumentType per spec.

- insertAdjacentText / insertAdjacentElement on Element. Without
  these the testharness output renderer threw in a forEach without
  try/catch, breaking the completion callback chain for every test
  with subtests.

- Window + Node addEventListener filter non-callable listeners,
  accept the EventListenerObject shape (object with handleEvent),
  dedupe by (type, callback, capture), honor the `once` option, and
  snapshot the listener list before dispatch so a `once: true`
  removal mid-iteration doesn't corrupt the loop.

- Iframe HTML loader now extracts head <script> blocks and body
  on-handler attributes (`<body onstorage=...>`), evals scripts in
  the parent realm after the iframe window is built, re-attaches
  handlers as window listeners. Same iframe element reusing its src
  no longer double-registers in the iframe registry.

- ops.rs validate_fetch_url honors the OBSCURA_ALLOW_PRIVATE_NETWORK
  env var, and the CLI --allow-private-network flag mirrors itself
  into that env so the js-side fetch gate matches the http_client
  gate. Without this, iframe loads of 127.0.0.1 resources were
  blocked even with the flag set.

- ops.rs: create_processing_instruction, create_doctype, pi_target,
  doctype_name, doctype_public_id ops added; set_text_content extended
  to handle PI nodes.
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-02 08:45:50 +02:00
1 parent fd2421940b
commit ee10e8ddb5
5 files changed
+464 -21

No files matched your search

+27
View File
@@ -252,6 +252,15 @@ impl DomTree {
}
pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) {
// Per DOM spec: if the node being inserted IS the reference node,
// the operation is a no-op (the node is already in its target
// position). Without this, the linked-list fixup below sets the
// node's prev_sibling and next_sibling to itself, creating a cycle
// — every later traversal (childNodes, querySelectorAll, etc) then
// loops forever and the test page hangs while obscura burns RAM.
if existing_id == new_sibling_id {
return;
}
let (parent_id, prev_id) = {
let inner = self.inner.borrow();
let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) {
@@ -463,6 +472,20 @@ impl DomTree {
pub fn text_content(&self, node_id: NodeId) -> String {
let inner = self.inner.borrow();
// Per DOM spec, calling textContent ON a CharacterData node
// (Text, Comment, ProcessingInstruction) returns its .data.
// Calling textContent on an Element walks descendants and
// concatenates Text node content only (Comment + PI are
// skipped). Handle the direct-CharacterData case here so the
// descent helper can keep its element-centric behavior.
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
match &node.data {
NodeData::Text { contents } => return contents.clone(),
NodeData::Comment { contents } => return contents.clone(),
NodeData::ProcessingInstruction { data, .. } => return data.clone(),
_ => {}
}
}
let mut result = String::new();
collect_text_inner(&inner, node_id, &mut result);
result
@@ -570,6 +593,10 @@ fn collect_text_inner(inner: &DomTreeInner, node_id: NodeId, buf: &mut String) {
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
match &node.data {
NodeData::Text { contents } => buf.push_str(contents),
// Comment and ProcessingInstruction are intentionally NOT
// appended when traversing descendants: per spec, textContent
// on an Element only includes Text descendants. Direct
// textContent on a Comment/PI is handled by the caller.
_ => {
let mut child = node.first_child;
while let Some(child_id) = child {