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 = '