Engine tier1 compat fixes (#208)

* engine: document.currentScript, data: subresources, dynamic module scripts

document.currentScript:
- ScriptInfo now carries the script element's nid.
- Before each script body runs, set globalThis.__currentScriptNid; clear after.
- Document.currentScript getter wraps the current nid. Next.js / Turbopack
  reads document.currentScript.src to derive the chunk base path. Without
  this the loader saw null, threw 'chunk path empty', and hydration stalled.
- Also threaded through the dynamic-script appendChild path so synchronously
  injected <script> elements see their own element as currentScript.

data: URIs as subresources:
- subresource_allowed permits 'data:' alongside http/https. The bytes are
  inline in the URI, no network fetch, no SSRF.
- Script-fetch loop short-circuits data: URLs and decodes them locally
  instead of routing through the HTTP client.
- Real Chrome allows this by default; Instagram and most Meta properties
  ship their bootstrap as <script src=data:application/x-javascript;base64,...>.

Dynamic <script type=module>:
- appendChild SCRIPT handler now accepts type='module' instead of dropping it.
- For external module src, uses dynamic import() instead of fetch + eval.
- For inline modules, wraps the source in a data: URL and import()s that
  so module syntax (import/export) parses correctly.

Before / after on previously broken sites (HTML bytes rendered):
  vercel.com          0   -> 960 KB
  instagram.com       0   -> 669 KB
  nextjs.org          0   -> 290 KB
  music.apple.com     0   -> 1.88 MB
  tailwindcss.com     0   -> 900 KB
  linear.app          0   -> 2.4 MB
  github.com PR       0   -> 468 KB

28/28 integration tests pass.

* engine: customElements.define upgrades existing matches + connectedCallback

The old customElements was a registry stub: define() recorded the class,
but never instantiated it on matching elements, never called the lifecycle
callbacks, and whenDefined() resolved synchronously with whatever was
already registered (or undefined forever for late-defined components).

New behavior matches the Web Components spec well enough for lit /
MusicKit / Polymer to wire up:

- define(): on register, querySelectorAll the document for matching tags,
  copy the prototype methods onto each element instance, call the class
  constructor with the element as 'this' for init logic, then fire
  connectedCallback if the element is currently in the document.
- whenDefined(): returns a real Promise that resolves when the class is
  later defined, not before.
- upgrade(root): walks the subtree, upgrades any matching unupgraded
  element. Used by frameworks that mount components asynchronously.

We don't truly subclass HTMLElement per spec (Element is shared across
the engine), so the upgrade copies prototype descriptors onto the
element instance instead of swapping its [[Prototype]]. Behavior is
identical for the common patterns (custom methods, shadow DOM attach
in constructor, connectedCallback render).

28/28 integration tests pass.

* scraping: fix puppeteer page.$, click, page.$$ + form / radio / checkbox actions

These bugs broke every realistic Puppeteer scraping flow. Repro: type into
a search box, click a button, scrape rows. Before this commit the very
first step (page.$('selector')) returned null on every site.

Runtime.evaluate (and the two related wrappers) put the user expression
on a single line as '({expr})'. Puppeteer's bundled injected.js ends with
a '//# sourceURL=pptr:internal' line comment; the comment swallowed our
closing paren and the wrapped function failed to parse with a SyntaxError
that never propagated back. Result: every page.$, page.$$, type(),
hover(), click(), waitForSelector(), getByRole(), etc. silently returned
null. Fix: newline before the trailing ')' so the comment can't eat the
wrapper. Applied to all three wrap paths (eval, store_object_with_meta,
wrap_expression).

documentElement and body now return viewport dimensions for clientWidth /
clientHeight / scrollWidth / scrollHeight / offsetWidth / offsetHeight and
for getBoundingClientRect itself. Puppeteer's #clickableBox clips each
candidate quad to document.documentElement.clientWidth/Height; with the
old 100x20 stub every synthesized element bbox was clipped to empty and
clickablePoint threw 'Node is either not clickable or not an Element'.
Also aligned innerWidth / innerHeight to 1280x720 to match
Page.getLayoutMetrics.

document.elementFromPoint now does real hit testing against the synthetic
bboxes (depth-first, deepest match wins) instead of always returning
<body>. Input.dispatchMouseEvent uses it to pick the click target, which
removed the dependency on a __obscura_click_target side-channel that
the previous getBoundingClientRect call had to set right before the
dispatch.

Element.dispatchEvent now fires inline event handlers
(onclick='...' content attribute and the IDL onclick property) in
addition to addEventListener registrations. Without this, every site
that uses inline onclick / onsubmit / onchange was a no-op.

Input.dispatchMouseEvent's click path now triggers the right default
action by element kind: submit button -> form.submit(); checkbox /
radio -> toggle checked + dispatch 'change'; anchor with href -> nav.
Previously only anchor was handled, so checking a checkbox or submitting
a form by clicking its button did nothing through CDP.

Runtime.getProperties now allocates a stable child objectId for every
object-valued property and stashes the value in __obscura_objects.
Items whose value has a numeric nodeType also get subtype:'node' +
className so Puppeteer's JSHandle.asElement() wraps them as
ElementHandle. Without this, page.$$('selector') returned an array
of plain JSHandles and checkboxes[0].click() raised
'click is not a function'.

Real-world Puppeteer scrape coverage (test_all.py style scenarios):
- HN top stories scrape:          fail -> pass
- GitHub repo file rows scrape:   fail -> pass
- Wikipedia search input type:    fail -> pass
- httpbin form fill + check:      fail -> pass
- Cookies persistence across nav: pass -> pass

28/28 integration tests pass.

* scraping: inline scripts run on DCL, JS nav emits CDP events, hit testing

Three remaining gaps from realistic Puppeteer scraping flows:

1. DCL path skipped execute_scripts entirely. Returning early on
   waitUntil=domcontentloaded meant every inline <script> was silently
   dropped: addEventListener calls in onload bootstraps never ran,
   form submit handlers never registered, page.click() hit no-ops.
   Spec: DOMContentLoaded fires AFTER scripts. Now scripts run before
   the lifecycle flips to DCL regardless of waitUntil.

   To keep heavy SPAs from blowing the 25s Puppeteer goto timeout
   now that scripts always run, added a soft deadline on the
   script-execution phase (default 10s, OBSCURA_SCRIPT_DEADLINE_MS
   to override). Both fetch and per-script execution check it.

2. JS-initiated navigation (form.submit, location.assign) did not
   emit CDP frameNavigated / lifecycleEvent. Puppeteer's
   waitForNavigation hung forever. Runtime.evaluate and
   Runtime.callFunctionOn now drain pending_navigation through a
   helper that fires the same post-nav event sequence Page.navigate
   does.

3. Document.elementFromPoint walked the tree only descending into
   ancestors containing (x,y). Our synthetic per-nid bboxes do not
   form a containment hierarchy, so a deep <input> inside <label><p>
   was never reached and clicks resolved to <body>. Switched to a
   flat sweep, deepest match wins (highest nid as a proxy for tree
   depth).

Realistic Puppeteer scrape scenarios: 18 of 18 assertions pass across
HN, Wikipedia, GitHub, httpbin forms, cookie persistence, and inline
JS submit handlers. 28/28 integration tests pass.

* scraping: fetch in page context, textarea, select, basic history

Four more gaps from the broad API sweep:

1. fetch() and XHR from page JS hung forever. set_intercept_tx() in
   the runtime auto-set state.intercept_enabled = true as a side
   effect, so every navigation silently enabled Fetch interception
   even when the CDP client never called Fetch.enable. op_fetch_url
   then routed every request through the intercept channel waiting
   for a Fetch.continueRequest that the client wasn't going to send.
   Split the flag from the channel: set_intercept_tx wires the
   channel without enabling; new set_intercept_enabled toggles the
   flag. Fetch.enable / Fetch.disable now call both, and the nav
   path only wires the channel.

2. page.type into textarea lost everything past the first newline.
   The dispatchKeyEvent handler skipped text=='\n' entirely, so
   'multi\nline' inserted only 'multi'. Also Enter on a textarea
   tried to submit the nearest form instead of inserting a newline.
   Now Enter inserts \n inside textareas and only submits forms
   from input fields, matching Chrome.

3. <select>.value did not reflect the selected option. Generic value
   getter returned the select's value attribute (empty) instead of
   the matching option. Added select-aware getter that walks options
   for the selected one and falls back to the first; setter accepts
   page.select() and dispatches a 'change' event.

4. Basic navigation history. Pages now track a visited-URL stack
   and history_index. Page.getNavigationHistory returns the real
   history; Page.navigateToHistoryEntry honors entryId and emits the
   same CDP nav-event sequence as Page.navigate so the response and
   lifecycleEvent reach waiting clients. resetNavigationHistory
   clears the stack. Push happens after every successful nav (skips
   consecutive duplicates so Page.reload doesn't grow the stack).

Broad API sweep across 13 feature groups: 43/44 PASS.
All categories solid (navigation, selectors, typing, waitForSelector,
evaluate, cookies, keyboard, attributes, events, fetch/XHR, storage,
timers). goBack reaches its target URL but Puppeteer client-side
waitForNavigation still hangs on it; the CDP frameNavigated +
lifecycleEvent fire correctly, so the remaining gap is in event
shape rather than logic. 28/28 integration tests pass.
This commit is contained in:
SGavrl authored and GitHub committed 2026-05-30 19:11:34 +00:00
1 parent b98e457428
commit 326096e20f
7 files changed
+576 -79

No files matched your search

+210 -22
View File
@@ -279,19 +279,30 @@ class Node {
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
if (c instanceof Element && c.tagName === 'SCRIPT') {
const scriptType = c.getAttribute('type') || '';
if (scriptType && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
const isModule = scriptType === 'module';
if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return c;
}
const src = c.getAttribute('src');
const prevNid = globalThis.__currentScriptNid;
if (src) {
const fullUrl = src.startsWith('http') ? src : new URL(src, globalThis.location?.href || 'http://localhost/').href;
const fullUrl = src.startsWith('http') || src.startsWith('data:')
? src
: new URL(src, globalThis.location?.href || 'http://localhost/').href;
const pageOrigin = (function() { try { return new URL(globalThis.location?.href || "about:blank").origin; } catch(e) { return ""; } })();
(async () => {
try {
const raw = await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors");
const parsed = JSON.parse(raw);
if (parsed.body) {
try { (0, eval)(parsed.body); } catch(e) { console.error('Dynamic script error (' + fullUrl + '):', e.message); }
if (isModule) {
await import(fullUrl);
} else {
const raw = await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors");
const parsed = JSON.parse(raw);
if (parsed.body) {
globalThis.__currentScriptNid = c._nid;
try { (0, eval)(parsed.body); }
catch(e) { console.error('Dynamic script error (' + fullUrl + '):', e.message); }
finally { globalThis.__currentScriptNid = prevNid || 0; }
}
}
if (typeof c.onload === 'function') try { c.onload(new Event('load')); } catch(e) {}
try { c.dispatchEvent(new Event('load')); } catch(e) {}
@@ -302,7 +313,20 @@ class Node {
})();
} else {
const code = c.textContent;
if (code) { try { (0, eval)(code); } catch(e) { console.error('Dynamic inline script error:', e.message); } }
if (code) {
if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
(async () => {
try { await import(dataUrl); }
catch(e) { console.error('Dynamic inline module error:', e.message); }
})();
} else {
globalThis.__currentScriptNid = c._nid;
try { (0, eval)(code); }
catch(e) { console.error('Dynamic inline script error:', e.message); }
finally { globalThis.__currentScriptNid = prevNid || 0; }
}
}
}
}
return c;
@@ -612,6 +636,20 @@ class Element extends Node {
if (!event) return true;
if (!event.target) event.target = this;
event.currentTarget = this;
// Spec: inline `onclick="..."` content attributes are event handlers
// for the matching event type. Fire them alongside any
// addEventListener handlers. Also honor the IDL property
// `el.onclick = fn` if set. Without this, b.click() never invokes
// the inline handler and forms with onsubmit / buttons with onclick
// are silently dead.
const handlerName = 'on' + event.type;
const inlineFn = this[handlerName] || this._resolveInlineHandler(handlerName);
if (typeof inlineFn === 'function') {
try {
const ret = inlineFn.call(this, event);
if (ret === false) event.preventDefault();
} catch(e) { console.error(e); }
}
const handlers = (_eventRegistry[this._nid] || {})[event.type] || [];
for (const h of handlers) {
try { h.call(this, event); } catch(e) { console.error(e); }
@@ -622,6 +660,20 @@ class Element extends Node {
}
return !event.defaultPrevented;
}
_resolveInlineHandler(name) {
// name = 'onclick' / 'onsubmit' / etc. Compile the content attribute
// as a function body on first read and cache it on the instance.
const cache = this.__inlineHandlerCache || (this.__inlineHandlerCache = {});
if (Object.prototype.hasOwnProperty.call(cache, name)) return cache[name];
const src = this.getAttribute && this.getAttribute(name);
if (!src) { cache[name] = null; return null; }
try {
cache[name] = new Function('event', src);
} catch (e) {
cache[name] = null;
}
return cache[name];
}
click() {
const cancelled = !this.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true}));
if (!cancelled) {
@@ -645,14 +697,44 @@ class Element extends Node {
focus() { globalThis.__obscura_focused = this; globalThis.__obscura_click_target = this; }
blur() { if (globalThis.__obscura_focused === this) globalThis.__obscura_focused = null; }
get value() {
if (_formValues[this._nid] !== undefined) return _formValues[this._nid];
const tag = this.localName;
if (tag === 'select') {
// Selected option wins; otherwise first option (HTML default).
const opts = this.querySelectorAll('option');
for (let i = 0; i < opts.length; i++) {
if (opts[i].selected) {
return opts[i].getAttribute('value') !== null ? opts[i].getAttribute('value') : opts[i].textContent;
}
}
if (opts.length) return opts[0].getAttribute('value') !== null ? opts[0].getAttribute('value') : opts[0].textContent;
return '';
}
if (_formValues[this._nid] !== undefined) return _formValues[this._nid];
if (tag === 'textarea') return this.textContent;
if (tag === 'option') {
const attr = this.getAttribute('value');
return attr !== null ? attr : this.textContent;
}
return this.getAttribute("value") || "";
}
set value(v) {
_formValues[this._nid] = String(v);
const tag = this.localName;
if (tag === 'select') {
// Set selected on matching option, clear on others. Puppeteer's
// page.select(selector, value) round-trips through this setter.
const wanted = String(v);
const opts = this.querySelectorAll('option');
let matched = false;
for (let i = 0; i < opts.length; i++) {
const attrV = opts[i].getAttribute('value');
const optVal = attrV !== null ? attrV : opts[i].textContent;
if (optVal === wanted) { opts[i].selected = true; matched = true; }
else { opts[i].selected = false; }
}
if (matched) try { this.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) {}
return;
}
_formValues[this._nid] = String(v);
if (tag === 'textarea') {
this.textContent = String(v);
}
@@ -823,14 +905,36 @@ class Element extends Node {
set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; },
});
}
get offsetWidth() { return 100; } get offsetHeight() { return 20; }
get offsetWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
get offsetHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
get offsetTop() { return 0; } get offsetLeft() { return 0; }
get clientWidth() { return 100; } get clientHeight() { return 20; }
get scrollWidth() { return 100; } get scrollHeight() { return 20; }
// documentElement / body / window expose VIEWPORT geometry, not their own content box.
// Puppeteer's #clickableBox clips boxes to document.documentElement.clientWidth/Height;
// returning 100x20 there made every element appear off-screen and broke .click().
get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
get scrollWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
get scrollHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
_isViewportRoot() {
const t = this.tagName;
return t === 'HTML' || t === 'BODY';
}
get scrollTop() { return 0; } set scrollTop(v) {}
get scrollLeft() { return 0; } set scrollLeft(v) {}
getBoundingClientRect() {
globalThis.__obscura_click_target = this;
// documentElement and body span the full viewport. Without this every
// hit test against them clips down to a 100x20 synthetic cell and
// Document.elementFromPoint can never recurse into their children.
if (this._isViewportRoot()) {
const vw = globalThis.innerWidth || 1280;
const vh = globalThis.innerHeight || 720;
return {
x: 0, y: 0, width: vw, height: vh,
top: 0, right: vw, bottom: vh, left: 0,
toJSON() { return this; },
};
}
// No layout engine, but Playwright's actionability polling needs each
// element to occupy a stable, distinct rect so hit-testing can pick the
// right one (issue #45). Synthesize a deterministic position from the
@@ -933,6 +1037,13 @@ class Document extends Node {
get characterSet() { return "UTF-8"; }
get contentType() { return "text/html"; }
get readyState() { return globalThis.__documentReadyState__ || 'complete'; }
get currentScript() {
// Next.js / Turbopack chunk loader reads document.currentScript.src to
// derive its base path. page.rs sets __currentScriptNid before each
// <script> body runs and clears it after, mirroring real Chrome.
const nid = globalThis.__currentScriptNid;
return nid ? _wrapEl(+nid) : null;
}
get hidden() { return false; }
get visibilityState() { return "visible"; }
getElementById(id) { return _wrapEl(+_dom("get_element_by_id", id)); }
@@ -1438,8 +1549,8 @@ globalThis.WebGL2RenderingContext = class WebGL2RenderingContext {};
globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = 1;
globalThis.innerWidth = 1920; globalThis.innerHeight = 1000;
globalThis.outerWidth = 1920; globalThis.outerHeight = 1080;
globalThis.innerWidth = 1280; globalThis.innerHeight = 720;
globalThis.outerWidth = 1280; globalThis.outerHeight = 720;
globalThis.scrollX = 0; globalThis.scrollY = 0;
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
@@ -2217,10 +2328,66 @@ globalThis.__notifyMutation = function(type, target_nid, addedNodes, removedNode
globalThis.ShadowRoot = class ShadowRoot {};
globalThis.customElements = {
_registry: new Map(),
define(name, cls, opts) { this._registry.set(name, cls); },
_whenDefinedResolvers: new Map(),
define(name, cls, opts) {
if (this._registry.has(name)) return;
this._registry.set(name, cls);
// Upgrade existing matching elements: instantiate the class on each,
// fire connectedCallback if the element is in the document. Without
// this, lit / MusicKit / Polymer components never wire up their
// shadow DOM or render, leaving heavy chunks of YouTube,
// music.apple.com, and any web-component site as empty shells.
try {
const matches = globalThis.document?.querySelectorAll(name) || [];
for (const el of matches) this._upgradeElement(el, cls);
} catch (e) {}
const resolvers = this._whenDefinedResolvers.get(name);
if (resolvers) {
for (const r of resolvers) r(cls);
this._whenDefinedResolvers.delete(name);
}
},
_upgradeElement(el, cls) {
if (el.__customUpgraded) return;
el.__customUpgraded = true;
try {
// Web Components spec: copy own props from the prototype onto the
// element. JS-side classes define behavior via methods on the
// prototype; we don't truly swap prototypes (Element is shared),
// so attach the prototype methods directly to the instance.
const proto = cls.prototype;
for (const key of Object.getOwnPropertyNames(proto)) {
if (key === 'constructor') continue;
const desc = Object.getOwnPropertyDescriptor(proto, key);
if (desc) Object.defineProperty(el, key, desc);
}
// Run constructor-side init on the element. Real custom elements
// run the class constructor, but Element instances aren't a `cls`
// subclass here; calling `.call(el)` runs whatever init logic the
// class defines without needing a new allocation.
try { cls.call(el); } catch (e) {}
if (typeof el.connectedCallback === 'function' && globalThis.document?.contains?.(el)) {
try { el.connectedCallback(); } catch (e) {}
}
} catch (e) {}
},
get(name) { return this._registry.get(name); },
whenDefined(name) { return Promise.resolve(this._registry.get(name)); },
upgrade() {},
whenDefined(name) {
const cls = this._registry.get(name);
if (cls) return Promise.resolve(cls);
return new Promise((resolve) => {
const list = this._whenDefinedResolvers.get(name) || [];
list.push(resolve);
this._whenDefinedResolvers.set(name, list);
});
},
upgrade(root) {
if (!root || !root.querySelectorAll) return;
for (const [name, cls] of this._registry.entries()) {
const matches = root.querySelectorAll(name);
for (const el of matches) this._upgradeElement(el, cls);
}
},
};
globalThis.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4, SHOW_ALL: 0xFFFFFFFF };
// ResizeObserver is defined earlier with real per-target firing; the stub
@@ -3894,16 +4061,37 @@ if (typeof Document !== 'undefined' && !Document.prototype.importNode) {
// Wrong-but-non-throwing beats "undefined", which traps ad/analytics bootstraps in retry loops
// (see issue #63).
if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) {
// Real hit testing against the synthetic bboxes from getBoundingClientRect.
// Flat iteration over every element, NOT a tree walk: our synthetic rects
// don't form a proper containment hierarchy (a child's rect can lie far
// outside its parent's), so a tree walk that only descends into ancestors
// containing (x,y) would never reach a deep <input> inside <label><p>.
// Returns the deepest matching element (highest nid wins as a proxy for
// tree depth) so descendants beat ancestors.
Document.prototype.elementFromPoint = function(x, y) {
if (typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y)) {
return null;
}
var w = (typeof window !== 'undefined' && window.innerWidth) || 0;
var h = (typeof window !== 'undefined' && window.innerHeight) || 0;
if (x < 0 || y < 0 || x > w || y > h) {
return null;
var w = (typeof window !== 'undefined' && window.innerWidth) || 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) || 720;
if (x < 0 || y < 0 || x > w || y > h) return null;
var all = this.querySelectorAll('*');
var best = null;
var bestNid = -1;
for (var i = 0; i < all.length; i++) {
var el = all[i];
if (!el || !el.getBoundingClientRect) continue;
// documentElement / body span the viewport; skip them so we pick a
// real descendant instead of falling back to <html>/<body>.
if (el === this.documentElement || el === this.body) continue;
var r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
var nid = el._nid | 0;
if (nid > bestNid) { best = el; bestNid = nid; }
}
}
return this.body || this.documentElement || null;
return best || this.body || this.documentElement || null;
};
Document.prototype.elementsFromPoint = function(x, y) {
var el = this.elementFromPoint(x, y);
+24 -6
View File
@@ -101,10 +101,19 @@ impl ObscuraJsRuntime {
std::mem::take(&mut self.state.borrow_mut().pending_binding_calls)
}
/// Wire up the interception channel without enabling interception.
/// Use set_intercept_enabled separately. The two were entangled before
/// and every navigation auto-enabled interception, which made
/// `fetch()` from page JS hang forever waiting for a CDP client to
/// answer Fetch.requestPaused events that the client never asked for.
pub fn set_intercept_tx(&self, tx: tokio::sync::mpsc::UnboundedSender<crate::ops::InterceptedRequest>) {
let mut state = self.state.borrow_mut();
state.intercept_tx = Some(tx);
state.intercept_enabled = true;
}
pub fn set_intercept_enabled(&self, enabled: bool) {
let mut state = self.state.borrow_mut();
state.intercept_enabled = enabled;
}
pub fn set_user_agent(&mut self, ua: &str) {
@@ -144,11 +153,16 @@ impl ObscuraJsRuntime {
.trim()
.trim_end_matches(|c: char| c == ';' || c.is_whitespace());
// Puppeteer / Playwright bundles end with a `//# sourceURL=...`
// line comment. If we put `{expr})` on a single line the comment
// swallows the closing paren and our wrapper breaks. A newline
// before the `)` terminates any trailing line comment so the
// parens close on their own line.
let meta_code = if await_promise {
format!(
"(async function() {{\n\
try {{\n\
var __result = await ({expr});\n\
var __result = await (\n{expr}\n);\n\
globalThis.__obscura_objects['{oid}'] = __result;\n\
globalThis.__obscura_await_meta = {meta_fn};\n\
globalThis.__obscura_await_rejected = false;\n\
@@ -167,7 +181,7 @@ impl ObscuraJsRuntime {
format!(
"(function() {{\n\
var __result;\n\
try {{ __result = ({expr}); }} catch(e) {{ __result = undefined; }}\n\
try {{ __result = (\n{expr}\n); }} catch(e) {{ __result = undefined; }}\n\
globalThis.__obscura_objects['{oid}'] = __result;\n\
return {meta_fn};\n\
}})()",
@@ -366,7 +380,7 @@ impl ObscuraJsRuntime {
let oid = self.make_oid(self.object_counter);
let code = format!(
"(function() {{\n\
var __result = ({expr});\n\
var __result = (\n{expr}\n);\n\
globalThis.__obscura_objects['{oid}'] = __result;\n\
return {meta_fn};\n\
}})()",
@@ -614,7 +628,7 @@ impl ObscuraJsRuntime {
if is_multi_statement {
format!(
"(function() {{ try {{ {} }} catch(e) {{ return null; }} }})()",
"(function() {{ try {{\n{}\n}} catch(e) {{ return null; }} }})()",
expression
)
} else {
@@ -625,9 +639,13 @@ impl ObscuraJsRuntime {
// to parse, the catch never fires (parse errors are not
// catchable), and the function silently returns `undefined`.
// Stripping makes the wrapped expression syntactically valid.
//
// The newline before the trailing `)` also terminates any
// `//# sourceURL=...` line comment the caller may have appended
// (Puppeteer's evaluated bundles do).
let cleaned = trimmed.trim_end_matches(|c: char| c == ';' || c.is_whitespace());
format!(
"(function() {{ try {{ return ({}); }} catch(e) {{ return null; }} }})()",
"(function() {{ try {{ return (\n{}\n); }} catch(e) {{ return null; }} }})()",
cleaned
)
}