fix(api/js): Browser cookie jar + base href + Node.baseURI + XHR EventTarget + Page::settle (closes #241,#242,#244,#245,#247) (#243)
* fix(api): Browser::cookies() now shares context's CookieJar Previously Browser::build() created its own empty CookieJar, while BrowserContext internally creates a separate jar that actually receives Set-Cookie headers from HTTP responses. This caused browser.cookies().get_all() to always return empty after navigation — the populated jar was unreachable. Fix: use context.cookie_jar instead of creating a separate one. Also removes redundant cookie-loading logic in Browser::build() since BrowserContext::_new_inner already handles loading from storage_dir. Closes #241 * fix(browser): add <base href> support for script URL resolution (closes #242) Previously resolve_base_url() returned the document URL directly, causing relative <script src> URLs to resolve against the page URL rather than the <base href> target. On SPAs served from sub-paths (Angular/Vite/Next.js), this caused all bundle scripts to 404. Add resolve_base_url() that queries DOM for <base href> and uses it as the base for relative URL resolution per HTML spec. Applied to both regular scripts and ES modules in execute_scripts(). * fix(js): implement Node.baseURI getter per WHATWG DOM spec (closes #244) Node.baseURI was missing from the bootstrap.js Node class, causing document.baseURI to return undefined. This broke Angular's PlatformLocation.getBaseHref which calls new URL(href, document.baseURI) -- with baseURI empty, the URL constructor throws Invalid URL. Per https://dom.spec.whatwg.org/#dom-node-baseuri, Node.baseURI must return the href of the first <base href> resolved against the document URL, falling back to the document URL when no <base> is present. * fix(js): add XMLHttpRequestEventTarget + XHR dispatchEvent (closes #245) XHR shim lacked dispatchEvent and XMLHttpRequestEventTarget, causing zone.js (Angular/zone.js) to crash with 'Cannot read properties of undefined (reading call)' when Angular's HttpClient makes XHR calls. Add spec-compliant XMLHttpRequestEventTarget base class with addEventListener/removeEventListener/dispatchEvent. Make XHR extend it so zone.js can walk the proto chain correctly. * fix(js): fix XHR super() call order in ES6 class extends * fix(js): use base href for dynamic script/module URL resolution Angular lazy-loaded chunks were resolving against document.location.href instead of the <base href>. This caused all dynamic import() chunks on the Deutsche Boerse SPA to 404 (/bond/7591.xxx.js instead of /7591.xxx.js). Fix: resolve dynamic script src against <base href> when present, falling back to location.href per HTML spec. * Revert "fix(js): use base href for dynamic script/module URL resolution" This reverts commit 88af239be38f2aa11c3c978a4db2a7482f2ed6c0. * feat(api): expose Page::settle() to pump JS event loop (closes #247) obscura_browser::Page already had settle(max_ms) but it was unreachable from the public API. Exposing it allows callers to resolve Promises, fetch callbacks, and microtasks before reading results with evaluate(). --------- Co-authored-by: zhangyang-crazy-one <[email protected]>
This commit is contained in:
1 parent
6dea35bd0c
commit
98b62e53e8
4 files changed
+112
-16
No files matched your search
@@ -282,8 +282,31 @@ impl Page {
|
|||||||
self.js = Some(rt);
|
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 <base href> exists.
|
||||||
|
fn resolve_base_url(&self) -> Option<url::Url> {
|
||||||
|
let doc_url = self.url.as_ref()?;
|
||||||
|
let base_href: Option<String> = 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) {
|
async fn execute_scripts(&mut self) {
|
||||||
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
|
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
|
||||||
|
// Compute document base URL, respecting <base href>.
|
||||||
|
let document_base = self.resolve_base_url();
|
||||||
// Soft deadline on the entire script-execution phase. Heavy SPAs
|
// Soft deadline on the entire script-execution phase. Heavy SPAs
|
||||||
// (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial
|
// (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial
|
||||||
// fetch + execute loop can blow past a 25s Puppeteer goto timeout.
|
// fetch + execute loop can blow past a 25s Puppeteer goto timeout.
|
||||||
@@ -397,7 +420,7 @@ impl Page {
|
|||||||
if let Some(src_url) = &script.src {
|
if let Some(src_url) = &script.src {
|
||||||
let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") {
|
let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") {
|
||||||
src_url.clone()
|
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())
|
base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone())
|
||||||
} else {
|
} else {
|
||||||
src_url.clone()
|
src_url.clone()
|
||||||
@@ -557,7 +580,7 @@ impl Page {
|
|||||||
if let Some(ref src) = module_script.src {
|
if let Some(ref src) = module_script.src {
|
||||||
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
|
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
|
||||||
src.clone()
|
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())
|
base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone())
|
||||||
} else {
|
} else {
|
||||||
src.clone()
|
src.clone()
|
||||||
|
|||||||
Vendored
+74
-1
@@ -283,6 +283,23 @@ class Node {
|
|||||||
get nodeType() { return +_dom("node_type", this._nid); }
|
get nodeType() { return +_dom("node_type", this._nid); }
|
||||||
get nodeName() { return _domParse("node_name", this._nid) || ""; }
|
get nodeName() { return _domParse("node_name", this._nid) || ""; }
|
||||||
get ownerDocument() { return globalThis.document; }
|
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) ?? ""; }
|
get textContent() { return _domParse("text_content", this._nid) ?? ""; }
|
||||||
set textContent(v) {
|
set textContent(v) {
|
||||||
const oldChildren = _domParse("child_nodes", this._nid) || [];
|
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 UNSENT = 0;
|
||||||
static OPENED = 1;
|
static OPENED = 1;
|
||||||
static HEADERS_RECEIVED = 2;
|
static HEADERS_RECEIVED = 2;
|
||||||
@@ -2593,6 +2645,7 @@ globalThis.XMLHttpRequest = class XMLHttpRequest {
|
|||||||
UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4;
|
UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
super();
|
||||||
this.readyState = 0;
|
this.readyState = 0;
|
||||||
this.status = 0;
|
this.status = 0;
|
||||||
this.statusText = "";
|
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) {
|
_setReadyState(state) {
|
||||||
this.readyState = state;
|
this.readyState = state;
|
||||||
this._fireEvent('readystatechange');
|
this._fireEvent('readystatechange');
|
||||||
@@ -2778,6 +2848,9 @@ _markNative(XMLHttpRequest.prototype.open);
|
|||||||
_markNative(XMLHttpRequest.prototype.send);
|
_markNative(XMLHttpRequest.prototype.send);
|
||||||
_markNative(XMLHttpRequest.prototype.abort);
|
_markNative(XMLHttpRequest.prototype.abort);
|
||||||
_markNative(XMLHttpRequest.prototype.setRequestHeader);
|
_markNative(XMLHttpRequest.prototype.setRequestHeader);
|
||||||
|
_markNative(XMLHttpRequest.prototype.addEventListener);
|
||||||
|
_markNative(XMLHttpRequest.prototype.removeEventListener);
|
||||||
|
_markNative(XMLHttpRequest.prototype.dispatchEvent);
|
||||||
_markNative(XMLHttpRequest.prototype.getResponseHeader);
|
_markNative(XMLHttpRequest.prototype.getResponseHeader);
|
||||||
_markNative(XMLHttpRequest.prototype.getAllResponseHeaders);
|
_markNative(XMLHttpRequest.prototype.getAllResponseHeaders);
|
||||||
|
|
||||||
|
|||||||
@@ -22,15 +22,6 @@ impl Browser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(config: BrowserConfig) -> Result<Self, Error> {
|
pub fn build(config: BrowserConfig) -> Result<Self, Error> {
|
||||||
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 {
|
let context = if let Some(ref dir) = config.storage_dir {
|
||||||
BrowserContext::with_storage_full(
|
BrowserContext::with_storage_full(
|
||||||
"api".to_string(),
|
"api".to_string(),
|
||||||
@@ -48,10 +39,10 @@ impl Browser {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Browser {
|
let context = Arc::new(context);
|
||||||
context: Arc::new(context),
|
let cookie_jar = context.cookie_jar.clone();
|
||||||
cookie_jar,
|
|
||||||
})
|
Ok(Browser { context, cookie_jar })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn builder() -> BrowserBuilder {
|
pub fn builder() -> BrowserBuilder {
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ impl Page {
|
|||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
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.
|
/// Handle to a DOM element.
|
||||||
|
|||||||
Reference in new issue
Block a user