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:
1 parent
b98e457428
commit
326096e20f
7 files changed
+576
-79
No files matched your search
@@ -70,15 +70,17 @@ fn cross_scheme_to_file(from: &str, to: &str) -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Sub-resource fetch policy. A page may only pull a `<script src>` /
|
||||
/// `<link rel=stylesheet href>` / etc. when the URL scheme is safe for
|
||||
/// the page's origin. http(s) pages cannot reach into file: or data:
|
||||
/// to fabricate scripts, and pages with no origin only get http/https.
|
||||
/// Sub-resource fetch policy. http(s) is always fine; data: is allowed
|
||||
/// because the bytes are inline in the URI (no network fetch, no SSRF);
|
||||
/// file: is only allowed when the page itself was loaded from file:;
|
||||
/// everything else (javascript:, chrome:, etc) is blocked.
|
||||
/// Real Chrome allows data: subresources by default; Instagram and most
|
||||
/// Meta properties depend on this for their inline bootstrap scripts.
|
||||
fn subresource_allowed(page_url: Option<&Url>, resource: &str) -> bool {
|
||||
let Ok(target) = Url::parse(resource) else { return false };
|
||||
let scheme = target.scheme().to_ascii_lowercase();
|
||||
match scheme.as_str() {
|
||||
"http" | "https" => true,
|
||||
"http" | "https" | "data" => true,
|
||||
"file" => page_url.map(|u| u.scheme().eq_ignore_ascii_case("file")).unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
@@ -132,6 +134,11 @@ pub struct Page {
|
||||
pub http_client: Arc<ObscuraHttpClient>,
|
||||
pub context: Arc<BrowserContext>,
|
||||
pub title: String,
|
||||
/// Navigation history for Page.getNavigationHistory / navigateToHistoryEntry.
|
||||
/// Entries are URLs in visit order; `history_index` is the current position.
|
||||
/// Pushed on every successful navigation; truncated on goBack -> new nav.
|
||||
pub history: Vec<String>,
|
||||
pub history_index: usize,
|
||||
pub network_events: Vec<NetworkEvent>,
|
||||
network_event_counter: u32,
|
||||
pub intercept_enabled: bool,
|
||||
@@ -181,6 +188,8 @@ impl Page {
|
||||
http_client,
|
||||
context,
|
||||
title: String::new(),
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
network_events: Vec::new(),
|
||||
network_event_counter: 0,
|
||||
intercept_enabled: false,
|
||||
@@ -268,6 +277,16 @@ impl Page {
|
||||
|
||||
async fn execute_scripts(&mut self) {
|
||||
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
|
||||
// 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.
|
||||
// Override via OBSCURA_SCRIPT_DEADLINE_MS for slow networks.
|
||||
let script_deadline_ms: u64 = std::env::var("OBSCURA_SCRIPT_DEADLINE_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000);
|
||||
let script_deadline = tokio::time::Instant::now()
|
||||
+ tokio::time::Duration::from_millis(script_deadline_ms);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScriptInfo {
|
||||
@@ -276,6 +295,7 @@ impl Page {
|
||||
is_defer: bool,
|
||||
is_async: bool,
|
||||
is_module: bool,
|
||||
nid: u32,
|
||||
}
|
||||
|
||||
let all_scripts = match &self.js {
|
||||
@@ -313,6 +333,7 @@ impl Page {
|
||||
is_defer,
|
||||
is_async,
|
||||
is_module,
|
||||
nid: sid.raw(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -393,6 +414,30 @@ impl Page {
|
||||
let idx = *idx;
|
||||
async move {
|
||||
let parsed = Url::parse(&url).unwrap_or_else(|_| Url::parse("about:blank").unwrap());
|
||||
if parsed.scheme() == "data" {
|
||||
// data: URIs are inline; decode locally, no network fetch.
|
||||
// Instagram and other Meta properties serve their bootstrap
|
||||
// as <script src="data:application/x-javascript;base64,...">.
|
||||
let body = decode_data_uri(&url).unwrap_or_default();
|
||||
let content_type = url
|
||||
.strip_prefix("data:")
|
||||
.and_then(|s| s.split(',').next())
|
||||
.unwrap_or("application/javascript")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("application/javascript")
|
||||
.to_string();
|
||||
let mut headers = std::collections::HashMap::new();
|
||||
headers.insert("content-type".to_string(), content_type);
|
||||
let resp = obscura_net::Response {
|
||||
url: parsed,
|
||||
status: 200,
|
||||
headers,
|
||||
body,
|
||||
redirected_from: Vec::new(),
|
||||
};
|
||||
return Some((idx, url, resp));
|
||||
}
|
||||
match client.fetch(&parsed).await {
|
||||
Ok(resp) => Some((idx, url, resp)),
|
||||
Err(e) => {
|
||||
@@ -403,7 +448,18 @@ impl Page {
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let fetch_results = futures::future::join_all(fetch_futures).await;
|
||||
let fetch_results = match tokio::time::timeout_at(
|
||||
script_deadline,
|
||||
futures::future::join_all(fetch_futures),
|
||||
).await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"execute_scripts: fetch deadline reached, some scripts may not have loaded"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut fetched: std::collections::HashMap<usize, (String, String, obscura_net::Response)> = std::collections::HashMap::new();
|
||||
for result in fetch_results {
|
||||
@@ -437,26 +493,41 @@ impl Page {
|
||||
}
|
||||
|
||||
for (i, script) in all_to_execute.iter().enumerate() {
|
||||
if tokio::time::Instant::now() >= script_deadline {
|
||||
tracing::warn!(
|
||||
"execute_scripts: deadline reached, skipping {} remaining scripts",
|
||||
all_to_execute.len() - i,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if script.src.is_some() {
|
||||
if let Some((url, code, resp)) = fetched.remove(&i) {
|
||||
tracing::info!("Executing script ({} bytes): {}", code.len(), url);
|
||||
self.record_network_event(&url, "GET", "Script", resp.status, &resp.headers, resp.body.len());
|
||||
if let Some(js) = &mut self.js {
|
||||
let _ = js.execute_script("<current-script>", &format!("globalThis.__currentScriptNid={};", script.nid));
|
||||
if let Err(e) = js.execute_script_guarded(&url, &code) {
|
||||
tracing::warn!("Script error ({}): {}", url, e);
|
||||
}
|
||||
let _ = js.execute_script("<current-script>", "globalThis.__currentScriptNid=0;");
|
||||
}
|
||||
}
|
||||
} else if !script.inline.is_empty() {
|
||||
if let Some(js) = &mut self.js {
|
||||
let _ = js.execute_script("<current-script>", &format!("globalThis.__currentScriptNid={};", script.nid));
|
||||
if let Err(e) = js.execute_script_guarded("<inline>", &script.inline) {
|
||||
tracing::warn!("Inline script error: {}", e);
|
||||
}
|
||||
let _ = js.execute_script("<current-script>", "globalThis.__currentScriptNid=0;");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for module_script in &module_scripts {
|
||||
if tokio::time::Instant::now() >= script_deadline {
|
||||
tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts");
|
||||
break;
|
||||
}
|
||||
if let Some(ref src) = module_script.src {
|
||||
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
|
||||
src.clone()
|
||||
@@ -576,7 +647,7 @@ impl Page {
|
||||
.unwrap_or(30_000);
|
||||
let nav_timeout = tokio::time::Duration::from_millis(nav_timeout_ms);
|
||||
|
||||
match tokio::time::timeout(
|
||||
let result = match tokio::time::timeout(
|
||||
nav_timeout,
|
||||
self.navigate_with_wait_post_inner(url_str, wait_until, method, body),
|
||||
)
|
||||
@@ -589,6 +660,34 @@ impl Page {
|
||||
"navigation exceeded {nav_timeout_ms}ms deadline"
|
||||
)))
|
||||
}
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.push_history(self.url_string());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn push_history(&mut self, url: String) {
|
||||
if url.is_empty() { return; }
|
||||
// Don't dupe consecutive entries (Page.reload would otherwise pile up).
|
||||
if self.history.get(self.history_index) == Some(&url) {
|
||||
return;
|
||||
}
|
||||
if !self.history.is_empty() && self.history_index < self.history.len() - 1 {
|
||||
self.history.truncate(self.history_index + 1);
|
||||
}
|
||||
self.history.push(url);
|
||||
self.history_index = self.history.len() - 1;
|
||||
}
|
||||
|
||||
/// Move the history cursor without re-navigating; used by
|
||||
/// Page.navigateToHistoryEntry which then drives the actual fetch.
|
||||
pub fn set_history_index(&mut self, idx: usize) {
|
||||
if idx < self.history.len() {
|
||||
self.history_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,15 +910,11 @@ impl Page {
|
||||
}
|
||||
|
||||
self.dom = Some(dom);
|
||||
self.lifecycle = LifecycleState::DomContentLoaded;
|
||||
|
||||
if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded {
|
||||
self.init_js();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.init_js();
|
||||
|
||||
// Inject CSS as a global so getComputedStyle and any CSS-aware shim
|
||||
// can read it. Has to happen before scripts run, regardless of
|
||||
// waitUntil, so handlers that read window.__obscura_css see it.
|
||||
if !css_sources.is_empty() {
|
||||
if let Some(js) = &mut self.js {
|
||||
let combined_css = css_sources.join("\n");
|
||||
@@ -839,8 +934,20 @@ impl Page {
|
||||
"(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()");
|
||||
}
|
||||
|
||||
// Spec: DOMContentLoaded fires AFTER parser-blocking scripts run,
|
||||
// not before. Skipping execute_scripts() on the DCL path meant
|
||||
// every inline <script> in the page was silently dropped: form
|
||||
// listeners never registered, frameworks never bootstrapped,
|
||||
// page.click() handlers were no-ops. Now scripts run regardless
|
||||
// of waitUntil and DCL means "DOM parsed AND scripts executed".
|
||||
self.execute_scripts().await;
|
||||
|
||||
self.lifecycle = LifecycleState::DomContentLoaded;
|
||||
|
||||
if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(js) = &mut self.js {
|
||||
if let Ok(new_title) = js.evaluate("document.title") {
|
||||
if let Some(t) = new_title.as_str() {
|
||||
@@ -1134,6 +1241,13 @@ impl Page {
|
||||
js.set_intercept_tx(tx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable_intercept(&mut self, enabled: bool) {
|
||||
self.intercept_enabled = enabled;
|
||||
if let Some(js) = &self.js {
|
||||
js.set_intercept_enabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -79,11 +79,11 @@ pub async fn handle(
|
||||
ctx.fetch_intercept.patterns = patterns.clone();
|
||||
let tx_clone = ctx.intercept_tx.clone();
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.intercept_enabled = true;
|
||||
page.intercept_block_patterns = patterns.clone();
|
||||
if let Some(tx) = tx_clone {
|
||||
page.set_intercept_tx(tx);
|
||||
}
|
||||
page.enable_intercept(true);
|
||||
}
|
||||
|
||||
tracing::info!("Fetch interception enabled");
|
||||
@@ -93,8 +93,8 @@ pub async fn handle(
|
||||
ctx.fetch_intercept.enabled = false;
|
||||
ctx.fetch_intercept.patterns.clear();
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.intercept_enabled = false;
|
||||
page.intercept_block_patterns.clear();
|
||||
page.enable_intercept(false);
|
||||
}
|
||||
let paused: Vec<_> = ctx.fetch_intercept.paused.drain().collect();
|
||||
for (_, req) in paused {
|
||||
|
||||
@@ -20,8 +20,9 @@ pub async fn handle(
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
globalThis.__obscura_click_target = target;\
|
||||
var evt = new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
target.dispatchEvent(evt);\
|
||||
var click = new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
@@ -34,6 +35,19 @@ pub async fn handle(
|
||||
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\
|
||||
location.assign(href);\
|
||||
}}\
|
||||
}} else {{\
|
||||
var tag = target.tagName;\
|
||||
var type = (target.getAttribute && target.getAttribute('type') || '').toLowerCase();\
|
||||
if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\
|
||||
var form = target.closest ? target.closest('form') : null;\
|
||||
if (form && typeof form.submit === 'function') {{ try {{ form.submit(target); }} catch(e) {{}} }}\
|
||||
}} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\
|
||||
var form2 = target.closest ? target.closest('form') : null;\
|
||||
if (form2 && typeof form2.submit === 'function') {{ try {{ form2.submit(target); }} catch(e) {{}} }}\
|
||||
}} else if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) {{\
|
||||
target.checked = !target.checked;\
|
||||
try {{ target.dispatchEvent(new Event('change', {{bubbles:true}})); }} catch(e) {{}}\
|
||||
}}\
|
||||
}}\
|
||||
}}\
|
||||
}})()",
|
||||
@@ -46,7 +60,7 @@ pub async fn handle(
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
var evt = new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
target.dispatchEvent(evt);\
|
||||
@@ -80,6 +94,9 @@ pub async fn handle(
|
||||
page.evaluate(&js);
|
||||
|
||||
if !text.is_empty() && text != "\r" && text != "\n" {
|
||||
// Need to escape backslash BEFORE single-quote so the new
|
||||
// backslashes from quote escaping don't get double-escaped.
|
||||
let escaped_text = text.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let js = format!(
|
||||
"(function() {{\
|
||||
var target = document.activeElement;\
|
||||
@@ -88,17 +105,25 @@ pub async fn handle(
|
||||
target.dispatchEvent(new Event('input', {{bubbles:true}}));\
|
||||
}}\
|
||||
}})()",
|
||||
text = text.replace('\'', "\\'").replace('\\', "\\\\"),
|
||||
text = escaped_text,
|
||||
);
|
||||
page.evaluate(&js);
|
||||
}
|
||||
|
||||
if key == "Enter" {
|
||||
// In a textarea Enter inserts a newline; in input fields
|
||||
// it submits the containing form. Real Chrome distinguishes
|
||||
// these two and we should too: previously every Enter tried
|
||||
// to submit the nearest form even from a textarea.
|
||||
let js = "(function() {\
|
||||
var target = document.activeElement;\
|
||||
if (target) {\
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'}));\
|
||||
var form = target.form || target.closest && target.closest('form');\
|
||||
if (!target) return;\
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'}));\
|
||||
if (target.localName === 'textarea') {\
|
||||
target.value = (target.value || '') + '\\n';\
|
||||
target.dispatchEvent(new Event('input', {bubbles:true}));\
|
||||
} else {\
|
||||
var form = target.form || (target.closest && target.closest('form'));\
|
||||
if (form && typeof form.submit === 'function') form.submit();\
|
||||
}\
|
||||
})()";
|
||||
|
||||
@@ -374,17 +374,78 @@ pub async fn handle(
|
||||
}
|
||||
"getNavigationHistory" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
|
||||
Ok(json!({
|
||||
"currentIndex": 0,
|
||||
"entries": [{
|
||||
// Synthesize an entry for the current page when history is empty
|
||||
// (initial about:blank, never-navigated targets). Puppeteer's
|
||||
// goBack reads `currentIndex` and `entries[currentIndex-1]`;
|
||||
// an empty entries[] used to make every back/forward fail.
|
||||
let entries: Vec<Value> = if page.history.is_empty() {
|
||||
vec![json!({
|
||||
"id": 0,
|
||||
"url": page.url_string(),
|
||||
"userTypedURL": page.url_string(),
|
||||
"title": page.title,
|
||||
"transitionType": "typed",
|
||||
}]
|
||||
})]
|
||||
} else {
|
||||
page.history.iter().enumerate().map(|(i, url)| json!({
|
||||
"id": i as u64,
|
||||
"url": url,
|
||||
"userTypedURL": url,
|
||||
"title": if i == page.history_index { page.title.clone() } else { String::new() },
|
||||
"transitionType": "typed",
|
||||
})).collect()
|
||||
};
|
||||
Ok(json!({
|
||||
"currentIndex": if page.history.is_empty() { 0 } else { page.history_index },
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
"navigateToHistoryEntry" => {
|
||||
let entry_id = params.get("entryId").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let target_url = {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
|
||||
let url = page.history.get(entry_id).cloned();
|
||||
if url.is_some() {
|
||||
page.set_history_index(entry_id);
|
||||
}
|
||||
url
|
||||
};
|
||||
if let Some(url) = target_url {
|
||||
// Stash + restore history so push_history doesn't clobber
|
||||
// the cursor we just moved.
|
||||
let stash = {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
|
||||
(page.history.clone(), page.history_index)
|
||||
};
|
||||
let (frame_id, page_id, network_events, page_url, reached_idle) = {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
|
||||
page.navigate_with_wait(&url, WaitUntil::DomContentLoaded).await.map_err(|e| e.to_string())?;
|
||||
page.history = stash.0;
|
||||
page.history_index = stash.1;
|
||||
(
|
||||
page.frame_id.clone(),
|
||||
page.id.clone(),
|
||||
page.network_events.drain(..).collect::<Vec<_>>(),
|
||||
page.url_string(),
|
||||
page.lifecycle.is_network_idle(),
|
||||
)
|
||||
};
|
||||
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
|
||||
emit_navigation_events(
|
||||
ctx, session_id,
|
||||
&frame_id, &loader_id, &page_url, &page_id,
|
||||
&network_events, WaitUntil::DomContentLoaded, reached_idle,
|
||||
);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"resetNavigationHistory" => {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.history.clear();
|
||||
page.history_index = 0;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"printToPDF" => {
|
||||
// Obscura has no layout/rendering engine, so PDF generation is
|
||||
// intentionally not implemented. Returning a distinct, descriptive
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
use obscura_browser::lifecycle::WaitUntil;
|
||||
use obscura_js::runtime::RemoteObjectInfo;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
/// Drain pending JS-initiated navigation (form.submit, location.assign, etc),
|
||||
/// then emit the same CDP nav-event sequence Page.navigate emits so
|
||||
/// Puppeteer's waitForNavigation / Playwright's wait_for_url resolves.
|
||||
/// Without this, in-page navigations look like Runtime.evaluate finishing
|
||||
/// to clients and they hang waiting for a frameNavigated that never fires.
|
||||
async fn emit_post_eval_nav(
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page")?;
|
||||
let did_navigate = page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
||||
if !did_navigate {
|
||||
return Ok(());
|
||||
}
|
||||
let (frame_id, page_url, page_id, network_events, reached_idle) = {
|
||||
let p = ctx.get_session_page_mut(session_id).ok_or("No page")?;
|
||||
(
|
||||
p.frame_id.clone(),
|
||||
p.url_string(),
|
||||
p.id.clone(),
|
||||
p.network_events.drain(..).collect::<Vec<_>>(),
|
||||
p.lifecycle.is_network_idle(),
|
||||
)
|
||||
};
|
||||
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
|
||||
super::page::emit_navigation_events(
|
||||
ctx,
|
||||
session_id,
|
||||
&frame_id,
|
||||
&loader_id,
|
||||
&page_url,
|
||||
&page_id,
|
||||
&network_events,
|
||||
WaitUntil::Load,
|
||||
reached_idle,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
@@ -86,7 +128,7 @@ pub async fn handle(
|
||||
));
|
||||
}
|
||||
};
|
||||
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
||||
emit_post_eval_nav(ctx, session_id).await?;
|
||||
|
||||
Ok(json!({ "result": remote_object_from_info(&info) }))
|
||||
}
|
||||
@@ -122,11 +164,29 @@ pub async fn handle(
|
||||
.ok_or("No page")?;
|
||||
let info =
|
||||
page.call_function_on_for_cdp(function_declaration, object_id, &arguments, return_by_value, await_promise).await;
|
||||
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
||||
emit_post_eval_nav(ctx, session_id).await?;
|
||||
|
||||
Ok(json!({ "result": remote_object_from_info(&info) }))
|
||||
}
|
||||
"getProperties" => {
|
||||
// Puppeteer's $$() flow:
|
||||
// 1. evaluate querySelectorAll → handle for the NodeList
|
||||
// 2. getProperties on that handle → indexed items
|
||||
// 3. For each item, JSHandle.asElement() checks subtype === 'node';
|
||||
// if true, wraps as ElementHandle (with click/type/etc).
|
||||
//
|
||||
// Older impl returned the raw value via JSON, dropping the node
|
||||
// identity. Items came back as `{type:'object'}` with no objectId
|
||||
// and no subtype, so asElement returned null and the caller got
|
||||
// plain JSHandles back from page.$$ -- breaking checkboxes[0].click().
|
||||
//
|
||||
// We now:
|
||||
// 1. Walk the underlying object in JS, allocating a stable child
|
||||
// oid per (parent_oid + index) and stashing each value in
|
||||
// __obscura_objects so later callFunctionOn can resolve it.
|
||||
// 2. Annotate each item with subtype:'node' + className when the
|
||||
// value has a numeric nodeType, so Puppeteer wraps it as
|
||||
// ElementHandle.
|
||||
let object_id = params.get("objectId").and_then(|v| v.as_str());
|
||||
if let Some(oid) = object_id {
|
||||
let page = ctx
|
||||
@@ -137,9 +197,29 @@ pub async fn handle(
|
||||
"(function() {{\
|
||||
var obj = globalThis.__obscura_objects['{oid}'];\
|
||||
if (!obj || typeof obj !== 'object') return [];\
|
||||
return Object.keys(obj).map(function(k) {{\
|
||||
var keys = Object.keys(obj);\
|
||||
return keys.map(function(k) {{\
|
||||
var v = obj[k];\
|
||||
return {{ name: k, value: v, type: typeof v }};\
|
||||
var t = typeof v;\
|
||||
var item = {{ name: k, type: t }};\
|
||||
if (v === null) {{ item.value = null; return item; }}\
|
||||
if (t !== 'object' && t !== 'function') {{ item.value = v; return item; }}\
|
||||
var childOid = '{oid}::' + k;\
|
||||
globalThis.__obscura_objects[childOid] = v;\
|
||||
item.childOid = childOid;\
|
||||
if (typeof v.nodeType === 'number') {{\
|
||||
item.subtype = 'node';\
|
||||
item.className = v.constructor && v.constructor.name ? v.constructor.name : (v.tagName ? 'HTML' + v.tagName.charAt(0) + v.tagName.slice(1).toLowerCase() + 'Element' : 'Node');\
|
||||
item.description = v.tagName ? v.tagName.toLowerCase() : (v.nodeName || 'node');\
|
||||
}} else if (Array.isArray(v)) {{\
|
||||
item.subtype = 'array';\
|
||||
item.className = 'Array';\
|
||||
item.description = 'Array(' + v.length + ')';\
|
||||
}} else {{\
|
||||
item.className = (v.constructor && v.constructor.name) || 'Object';\
|
||||
item.description = item.className;\
|
||||
}}\
|
||||
return item;\
|
||||
}});\
|
||||
}})()",
|
||||
oid = escaped_oid,
|
||||
@@ -150,32 +230,43 @@ pub async fn handle(
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = p.get("value").unwrap_or(&Value::Null);
|
||||
let prop_type =
|
||||
p.get("type").and_then(|v| v.as_str()).unwrap_or("undefined");
|
||||
let mut remote = json!({
|
||||
"type": prop_type,
|
||||
});
|
||||
match value {
|
||||
Value::Null => {
|
||||
remote["type"] = json!("object");
|
||||
remote["subtype"] = json!("null");
|
||||
remote["value"] = json!(null);
|
||||
let mut remote = json!({ "type": prop_type });
|
||||
if let Some(child_oid) = p.get("childOid").and_then(|v| v.as_str()) {
|
||||
remote["type"] = json!("object");
|
||||
if let Some(sub) = p.get("subtype").and_then(|v| v.as_str()) {
|
||||
remote["subtype"] = json!(sub);
|
||||
}
|
||||
Value::String(s) => {
|
||||
remote["type"] = json!("string");
|
||||
remote["value"] = json!(s);
|
||||
if let Some(cls) = p.get("className").and_then(|v| v.as_str()) {
|
||||
remote["className"] = json!(cls);
|
||||
}
|
||||
Value::Number(n) => {
|
||||
remote["type"] = json!("number");
|
||||
remote["value"] = json!(n);
|
||||
if let Some(desc) = p.get("description").and_then(|v| v.as_str()) {
|
||||
remote["description"] = json!(desc);
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
remote["type"] = json!("boolean");
|
||||
remote["value"] = json!(b);
|
||||
}
|
||||
_ => {
|
||||
remote["value"] = value.clone();
|
||||
remote["objectId"] = json!(child_oid);
|
||||
} else if let Some(val) = p.get("value") {
|
||||
match val {
|
||||
Value::Null => {
|
||||
remote["type"] = json!("object");
|
||||
remote["subtype"] = json!("null");
|
||||
remote["value"] = json!(null);
|
||||
}
|
||||
Value::String(s) => {
|
||||
remote["type"] = json!("string");
|
||||
remote["value"] = json!(s);
|
||||
}
|
||||
Value::Number(n) => {
|
||||
remote["type"] = json!("number");
|
||||
remote["value"] = json!(n);
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
remote["type"] = json!("boolean");
|
||||
remote["value"] = json!(b);
|
||||
}
|
||||
_ => {
|
||||
remote["value"] = val.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
|
||||
Vendored
+210
-22
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user