From 07c6e735fa40a6f0718a67371b7a8fa7204dc089 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Wed, 3 Jun 2026 20:15:07 +0000
Subject: [PATCH] WPT conformance: WHATWG URL, DOM Range, and post-navigation
event-loop settle (#233)
* fix: restore regressions introduced by #227
#227 deleted build.rs (reverting #224's version-from-tag feature),
removed DOM cycle guards in append_child/insert_before that prevent
infinite loops on self-append/self-insert, dropped the CharacterData
textContent fast-path, removed PI/Doctype Rust ops, and cut the
env_allows_private_network() check from validate_fetch_url.
Restored:
- crates/obscura-cli/build.rs (version from tag/env)
- OBSCURA_BUILD_VERSION in version string and banner
- allow_private_network env var propagation in main.rs
- append_child/insert_before self-cycle guards in tree.rs
- textContent CharacterData fast-path in tree.rs
- create_processing_instruction, create_doctype, pi_target,
doctype_name, doctype_public_id ops in ops.rs
- ProcessingInstruction branch in set_text_content op
- env_allows_private_network() check in validate_fetch_url
* fix(js): Europe/Berlin timezone, plugins iterator recursion
Two bugs in the stealth bootstrap code from #227:
- 'Europe/Germany' is not a valid IANA timezone; Intl.DateTimeFormat
silently fell back to UTC. Changed to 'Europe/Berlin'.
- plugins[Symbol.iterator] called this[Symbol.iterator]() recursively,
blowing the stack on Array.from(navigator.plugins). Fixed to bind
Array.prototype[Symbol.iterator] directly to the array.
* url: back URL/URLSearchParams with the url crate, add / decomposition
The URL global was a regex shim: it mis-parsed userinfo (hostname came back as
the username, port as "pass@host"), had no username/password, and its setters
were no-ops. Replace it with two ops (op_url_parse, op_url_set) backed by the
WHATWG-compliant url crate, and add the HTMLHyperlinkElementUtils members
(protocol/host/hostname/port/pathname/search/hash/origin/username/password and
their setters) to and , which had none.
This was the single largest WPT failure. a.search returned undefined, so the
encoding encode-href tests threw "Cannot read properties of undefined (reading
'substr')" about 225k times. With the fix url-constructor passes 831/888,
url-setters 192/279, urlsearchparams-sort 17/17, url/a-element 415/887, and the
encode-href files run their assertions instead of crashing.
op_url_set runs the url crate inside catch_unwind: a few setters panic on the
pathological inputs the url-setters tests use, and a panicking setter must be a
no-op (the WHATWG "do nothing on invalid value" rule) rather than abort.
URLSearchParams also gained getAll/sort/size/keys/values/entries/iterator and a
back-reference so mutations write through to the bound URL.
* dom: real Range/StaticRange, createDocumentType, XML Document type
Range was a no-op stub (setStart/setEnd discarded their arguments, so
startContainer/endContainer read back undefined and every dom/ranges helper
that walked range.endContainer crashed with "reading 'nodeType' of undefined").
Replace it with a property-storing Range over the live tree: setStart/End and
the *Before/*After/selectNode/selectNodeContents/collapse positioning, plus the
boundary-point algorithms comparePoint, compareBoundaryPoints, isPointInRange,
intersectsNode, commonAncestorContainer, cloneRange and a real stringifier, all
built on a correct preorder tree-order comparison. Add StaticRange.
new Document() now reports an XML contentType (it has no backing node id), so
createCDATASection no longer throws NotSupportedError on it. Add
DOMImplementation.createDocumentType with the lenient name validation browsers
use and an owner-document backref so DocumentType.ownerDocument is correct.
StaticRange-constructor 14/17. This does not fully unblock dom/ranges yet:
dom/common.js also needs createHTMLDocument()/createDocument() to return
documents with a working body/createComment/etc., which is a separate fix.
* fix(browser,cli): settle the event loop after navigation
obscura fetch read the page at the load event and never pumped the event
loop afterward on the eval/dump path, so --wait was effectively ignored
there. Pages that schedule timers, do async work, or only report through a
completion callback that runs after load (testharness add_completion_callback,
SPAs) were observed frozen at the load event and never produced their result.
Add Page::settle, which drives run_event_loop until the loop goes idle or
--wait elapses (returns early when idle, so static pages stay fast), and run
it after navigation in the fetch path.
---
crates/obscura-browser/src/page.rs | 20 ++
crates/obscura-cli/src/main.rs | 6 +
crates/obscura-js/js/bootstrap.js | 408 +++++++++++++++++++++++++----
crates/obscura-js/src/ops.rs | 175 +++++++++++++
4 files changed, 555 insertions(+), 54 deletions(-)
diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index aa6efde..9cc8db9 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -675,6 +675,26 @@ impl Page {
result
}
+ /// Drive the JS event loop after navigation so deferred work can run:
+ /// pending timers (setTimeout / setInterval), queued microtasks, in-flight
+ /// fetches, and completion callbacks such as testharness's
+ /// `add_completion_callback`. Returns as soon as the loop goes idle, or
+ /// after `max_ms`. Without this the page is observed exactly as it stood at
+ /// the load event, before any async work settles, which silently strands
+ /// timer-driven tests and dynamic pages.
+ pub async fn settle(&mut self, max_ms: u64) {
+ if max_ms == 0 {
+ return;
+ }
+ if let Some(js) = &mut self.js {
+ let _ = tokio::time::timeout(
+ tokio::time::Duration::from_millis(max_ms),
+ js.run_event_loop(),
+ )
+ .await;
+ }
+ }
+
/// Append the current URL to the history stack, truncating any forward
/// entries past the cursor (matches real Chrome: navigating after a
/// goBack clobbers the forward history).
diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs
index 67596be..919fcec 100644
--- a/crates/obscura-cli/src/main.rs
+++ b/crates/obscura-cli/src/main.rs
@@ -526,6 +526,12 @@ async fn run_fetch(
eprintln!("Page loaded: {} - \"{}\"", page.url_string(), page.title);
}
+ // --wait is a post-load settle: drive the event loop so timers, async work,
+ // and completion callbacks (e.g. testharness's add_completion_callback) run
+ // before we read the page. Returns early once the loop is idle, so static
+ // pages stay fast.
+ page.settle(wait_secs.saturating_mul(1000)).await;
+
if let Some(ref sel) = selector {
let found = wait_for_selector(&mut page, sel, wait_secs).await;
if !found {
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index fd29331..f945668 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -631,13 +631,37 @@ class ProcessingInstruction extends CharacterData {
cloneNode() { return new ProcessingInstruction(+_dom("create_text_node", this.data), this._target); }
}
+// HTMLHyperlinkElementUtils helpers (the / URL-decomposition members).
+// The element's href attribute is parsed against the document base URL via the
+// WHATWG url op; component getters read it, setters rewrite the href attribute.
+function _anchorBase() { return _domParse("document_url") || "about:blank"; }
+function _elemHrefURL(el) {
+ const raw = el.getAttribute('href');
+ if (raw === null || raw === undefined) return null;
+ return _urlParseOp(raw, _anchorBase());
+}
+function _setElemHrefPart(el, part, value) {
+ const u = _elemHrefURL(el);
+ if (!u) return;
+ const c = _urlSetOp(u.href, part, value);
+ if (c) el.setAttribute('href', c.href);
+}
+
class Element extends Node {
constructor(nid) {
super(nid);
this._style = _styleProxy(new CSSStyleDeclaration());
}
get tagName() { return _domParse("tag_name", this._nid) || ""; }
- get localName() { return (this.tagName || "").toLowerCase(); }
+ get localName() {
+ // tagName is an op call and the tag never changes, so cache the lowercased
+ // localName. This keeps the new / href getters (which read
+ // localName) and every other localName consumer off the op path.
+ if (this._lname !== undefined) return this._lname;
+ const ln = (this.tagName || "").toLowerCase();
+ if (ln) this._lname = ln;
+ return ln;
+ }
get id() { return this.getAttribute("id") || ""; }
set id(v) { this.setAttribute("id", v); }
get className() { return this.getAttribute("class") || ""; }
@@ -1059,8 +1083,41 @@ class Element extends Node {
set name(v) { this.setAttribute("name", v); }
get placeholder() { return this.getAttribute("placeholder") || ""; }
set placeholder(v) { this.setAttribute("placeholder", v); }
- get href() { return this.getAttribute("href") || ""; }
+ // For /, href returns the resolved absolute URL (the spec behavior,
+ // and what scrapers want). It uses op_url_resolve, which returns just the
+ // resolved string, rather than the full-component op the decomposition
+ // members use. Other elements reflect the raw attribute.
+ get href() {
+ const ln = this.localName;
+ if (ln === 'a' || ln === 'area') {
+ const raw = this.getAttribute('href');
+ if (raw === null) return '';
+ const r = _urlResolveOp(raw, _anchorBase());
+ return r !== null ? r : raw;
+ }
+ return this.getAttribute("href") || "";
+ }
set href(v) { this.setAttribute("href", v); }
+ // HTMLHyperlinkElementUtils URL-decomposition members, live on /.
+ get protocol() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.protocol : ''; }
+ set protocol(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'protocol', v); }
+ get username() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.username : ''; }
+ set username(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'username', v); }
+ get password() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.password : ''; }
+ set password(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'password', v); }
+ get host() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.host : ''; }
+ set host(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'host', v); }
+ get hostname() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.hostname : ''; }
+ set hostname(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'hostname', v); }
+ get port() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.port : ''; }
+ set port(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'port', v); }
+ get pathname() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.pathname : ''; }
+ set pathname(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'pathname', v); }
+ get search() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.search : ''; }
+ set search(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'search', v); }
+ get hash() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.hash : ''; }
+ set hash(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'hash', v); }
+ get origin() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.origin : ''; }
get src() { return this.getAttribute("src") || ""; }
set src(v) {
this.setAttribute("src", v);
@@ -1356,6 +1413,12 @@ class Document extends Node {
get compatMode() { return "CSS1Compat"; }
get characterSet() { return "UTF-8"; }
get contentType() {
+ // An explicit type set by DOMParser/createDocument wins.
+ if (this._contentType) return this._contentType;
+ // `new Document()` (the WHATWG constructor, no backing node id) creates an
+ // XML document, so createCDATASection/etc. must not throw. Live documents
+ // wrapped from the tree carry a real nid and fall through to URL-derived.
+ if (this._nid === undefined || this._nid === null) return "application/xml";
const url = this.URL || "";
// data: URLs carry their MIME type explicitly.
const dm = /^data:([^,;]+)/i.exec(url);
@@ -1582,6 +1645,7 @@ class Document extends Node {
getSelection() { return globalThis.getSelection(); }
get activeElement() { return globalThis.__obscura_focused || this.body; }
get implementation() {
+ const ownerDoc = this;
return {
// Spec: createHTMLDocument returns a NEW detached Document. jQuery
// 3.x's selector feature-detect calls `body.innerHTML = '