diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index ccbedd1..d281ecf 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -282,8 +282,31 @@ impl Page { self.js = Some(rt); } + /// Resolve the document base URL per HTML spec: + /// https://html.spec.whatwg.org/multipage/urls-and-fetching.html#document-base-url + /// Falls back to self.url when no exists. + fn resolve_base_url(&self) -> Option { + let doc_url = self.url.as_ref()?; + let base_href: Option = self.js.as_ref().and_then(|js| { + js.with_dom(|dom| { + match dom.query_selector("base[href]") { + Ok(Some(nid)) => { + dom.get_node(nid).and_then(|n| n.get_attribute("href").map(|s| s.to_string())) + } + _ => None, + } + }).flatten() + }); + match base_href { + Some(href) => doc_url.join(&href).ok(), + None => Some(doc_url.clone()), + } + } + async fn execute_scripts(&mut self) { tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); + // Compute document base URL, respecting . + let document_base = self.resolve_base_url(); // Soft deadline on the entire script-execution phase. Heavy SPAs // (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial // fetch + execute loop can blow past a 25s Puppeteer goto timeout. @@ -397,7 +420,7 @@ impl Page { if let Some(src_url) = &script.src { let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") { src_url.clone() - } else if let Some(base) = &self.url { + } else if let Some(base) = &document_base { base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone()) } else { src_url.clone() @@ -557,7 +580,7 @@ impl Page { if let Some(ref src) = module_script.src { let full_url = if src.starts_with("http://") || src.starts_with("https://") { src.clone() - } else if let Some(base) = &self.url { + } else if let Some(base) = &document_base { base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone()) } else { src.clone() diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index a2083d0..11c2713 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -283,6 +283,23 @@ class Node { get nodeType() { return +_dom("node_type", this._nid); } get nodeName() { return _domParse("node_name", this._nid) || ""; } get ownerDocument() { return globalThis.document; } + // https://dom.spec.whatwg.org/#dom-node-baseuri + get baseURI() { + try { + const doc = globalThis.document; + const docUrl = (doc && doc.URL) || ""; + const baseEl = (doc && doc.querySelector) ? doc.querySelector("base[href]") : null; + if (baseEl) { + const href = baseEl.getAttribute("href"); + if (href) { + return docUrl ? new URL(href, docUrl).href : href; + } + } + return docUrl; + } catch (e) { + return ""; + } + } get textContent() { return _domParse("text_content", this._nid) ?? ""; } set textContent(v) { const oldChildren = _domParse("child_nodes", this._nid) || []; @@ -2584,7 +2601,42 @@ if (typeof Headers === "undefined") { }; } -globalThis.XMLHttpRequest = class XMLHttpRequest { +// XMLHttpRequestEventTarget — spec-required ancestor for XHR EventTarget methods. +// zone.js prefers to walk XMLHttpRequestEventTarget.prototype for addEventListener/ +// removeEventListener/dispatchEvent descriptors before falling back to XHR.prototype. +class XMLHttpRequestEventTarget { + addEventListener(type, handler) { + if (!this._listeners) this._listeners = {}; + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(handler); + } + removeEventListener(type, handler) { + if (this._listeners && this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter(h => h !== handler); + } + } + dispatchEvent(event) { + if (!event || !event.type) return false; + const ev = (typeof event === 'object') ? event : { type: event }; + ev.target = ev.target || this; + ev.currentTarget = ev.currentTarget || this; + const type = ev.type; + const handlers = (this._listeners && this._listeners[type]) || []; + for (const h of handlers) { try { h.call(this, ev); } catch (e) {} } + const prop = 'on' + type; + if (typeof this[prop] === 'function') { + try { this[prop](ev); } catch (e) {} + } + return true; + } +} +globalThis.XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; +_markNative(XMLHttpRequestEventTarget); +_markNative(XMLHttpRequestEventTarget.prototype.addEventListener); +_markNative(XMLHttpRequestEventTarget.prototype.removeEventListener); +_markNative(XMLHttpRequestEventTarget.prototype.dispatchEvent); + +globalThis.XMLHttpRequest = class XMLHttpRequest extends XMLHttpRequestEventTarget { static UNSENT = 0; static OPENED = 1; static HEADERS_RECEIVED = 2; @@ -2593,6 +2645,7 @@ globalThis.XMLHttpRequest = class XMLHttpRequest { UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4; constructor() { + super(); this.readyState = 0; this.status = 0; this.statusText = ""; @@ -2755,6 +2808,23 @@ globalThis.XMLHttpRequest = class XMLHttpRequest { } } + // Per WHATWG DOM spec — required by zone.js which patches XHR via + // Object.getOwnPropertyDescriptor on XMLHttpRequestEventTarget.prototype. + dispatchEvent(event) { + if (!event || !event.type) return false; + const ev = (typeof event === 'object') ? event : { type: event }; + ev.target = ev.target || this; + ev.currentTarget = ev.currentTarget || this; + const type = ev.type; + const handlers = (this._listeners && this._listeners[type]) || []; + for (const h of handlers) { try { h.call(this, ev); } catch (e) {} } + const prop = 'on' + type; + if (typeof this[prop] === 'function') { + try { this[prop](ev); } catch (e) {} + } + return true; + } + _setReadyState(state) { this.readyState = state; this._fireEvent('readystatechange'); @@ -2778,6 +2848,9 @@ _markNative(XMLHttpRequest.prototype.open); _markNative(XMLHttpRequest.prototype.send); _markNative(XMLHttpRequest.prototype.abort); _markNative(XMLHttpRequest.prototype.setRequestHeader); +_markNative(XMLHttpRequest.prototype.addEventListener); +_markNative(XMLHttpRequest.prototype.removeEventListener); +_markNative(XMLHttpRequest.prototype.dispatchEvent); _markNative(XMLHttpRequest.prototype.getResponseHeader); _markNative(XMLHttpRequest.prototype.getAllResponseHeaders); diff --git a/crates/obscura/src/browser.rs b/crates/obscura/src/browser.rs index d7193a1..2c30d95 100644 --- a/crates/obscura/src/browser.rs +++ b/crates/obscura/src/browser.rs @@ -22,15 +22,6 @@ impl Browser { } pub fn build(config: BrowserConfig) -> Result { - let cookie_jar = Arc::new(CookieJar::new()); - - if let Some(ref dir) = config.storage_dir { - let cookie_path = dir.join("cookies.json"); - if cookie_path.exists() { - let _ = cookie_jar.load_from_file(&cookie_path); - } - } - let context = if let Some(ref dir) = config.storage_dir { BrowserContext::with_storage_full( "api".to_string(), @@ -48,10 +39,10 @@ impl Browser { ) }; - Ok(Browser { - context: Arc::new(context), - cookie_jar, - }) + let context = Arc::new(context); + let cookie_jar = context.cookie_jar.clone(); + + Ok(Browser { context, cookie_jar }) } pub fn builder() -> BrowserBuilder { diff --git a/crates/obscura/src/page.rs b/crates/obscura/src/page.rs index 0bdad3a..f8f1b33 100644 --- a/crates/obscura/src/page.rs +++ b/crates/obscura/src/page.rs @@ -84,6 +84,15 @@ impl Page { tokio::time::sleep(Duration::from_millis(100)).await; } } + + /// Drive the page's JS event loop for up to `max_ms` milliseconds. + /// + /// Call this after `evaluate()` kicks off async work (Promises, fetch, + /// setTimeout, RxJS subscribers) to let the V8 event loop pump and + /// resolve scheduled microtasks/macrotasks before the next `evaluate()`. + pub async fn settle(&mut self, max_ms: u64) { + self.inner.settle(max_ms).await + } } /// Handle to a DOM element.