Merge remote-tracking branch 'origin/main' into pr471-push

# Conflicts:
#	crates/obscura-js/js/bootstrap.js
This commit is contained in:
SGavrl committed 2026-07-24 15:19:29 +00:00
commit 0df158ec9d
10 files changed
+1494 -42

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
Compiling proc-macro2 v1.0.106
Compiling quote v1.0.45
Compiling unicode-ident v1.0.24
Compiling libc v0.2.186
Compiling cfg-if v1.0.4
Compiling parking_lot_core v0.9.12
Compiling serde_core v1.0.228
Compiling serde v1.0.228
Compiling smallvec v1.15.2
Compiling icu_properties_data v2.2.0
Compiling icu_normalizer_data v2.2.0
Compiling stable_deref_trait v1.2.1
Compiling pin-project-lite v0.2.17
Compiling version_check v0.9.5
Compiling scopeguard v1.2.0
Compiling itoa v1.0.18
Compiling memchr v2.8.2
Compiling typenum v1.20.1
Compiling lock_api v0.4.14
Compiling bytes v1.11.1
Compiling crc32fast v1.5.0
Compiling zmij v1.0.21
Compiling generic-array v0.14.7
Compiling rand_core v0.6.4
Compiling thiserror v2.0.18
Compiling rand v0.8.6
Compiling serde_json v1.0.150
Compiling subtle v2.6.1
Compiling futures-core v0.3.32
Compiling syn v2.0.117
Compiling glob v0.3.3
Compiling litemap v0.8.2
Compiling writeable v0.6.3
Compiling utf8_iter v1.0.4
Compiling prettyplease v0.2.37
Compiling clang-sys v1.8.1
Compiling siphasher v1.0.3
Compiling getrandom v0.2.17
Compiling errno v0.3.14
Compiling mio v1.2.1
Compiling socket2 v0.6.4
Compiling signal-hook-registry v1.4.8
Compiling autocfg v1.5.1
Compiling parking_lot v0.12.5
Compiling futures-sink v0.3.32
Compiling phf_shared v0.11.3
Compiling crypto-common v0.1.7
Compiling rustix v0.38.44
Compiling regex-syntax v0.8.11
Compiling percent-encoding v2.3.2
Compiling num-traits v0.2.19
Compiling either v1.16.0
Compiling bitflags v2.13.0
Compiling minimal-lexical v0.2.1
Compiling phf_generator v0.11.3
Compiling futures-channel v0.3.32
Compiling nom v7.1.3
Compiling libloading v0.8.9
Compiling linux-raw-sys v0.4.15
Compiling bindgen v0.71.1
Compiling rustc-hash v2.1.2
Compiling itertools v0.13.0
Compiling log v0.4.32
Compiling futures-io v0.3.32
Compiling shlex v1.3.0
Compiling adler2 v2.0.1
Compiling home v0.5.12
Compiling once_cell v1.21.4
Compiling hashbrown v0.17.1
Compiling regex-automata v0.4.14
Compiling equivalent v1.0.2
Compiling radium v0.7.0
Compiling slab v0.4.12
Compiling futures-task v0.3.32
Compiling indexmap v2.14.0
Compiling cexpr v0.6.0
Compiling miniz_oxide v0.8.9
Compiling gzip-header v1.1.0
Compiling form_urlencoded v1.2.2
Compiling fslock v0.2.1
Compiling find-msvc-tools v0.1.9
Compiling shlex v2.0.1
Compiling cc v1.2.64
Compiling which v6.0.3
Compiling http v1.4.2
Compiling paste v1.0.15
Compiling synstructure v0.13.2
Compiling regex v1.12.4
Compiling heck v0.5.0
Compiling httparse v1.10.1
Compiling ring v0.17.14
Compiling tracing-core v0.1.36
Compiling phf_codegen v0.11.3
Compiling block-buffer v0.10.4
Compiling alloc-no-stdlib v2.0.4
Compiling zerofrom-derive v0.1.7
Compiling yoke-derive v0.8.2
Compiling zerovec-derive v0.11.3
Compiling displaydoc v0.2.6
Compiling serde_derive v1.0.228
Compiling tokio-macros v2.7.0
Compiling futures-macro v0.3.32
Compiling thiserror-impl v2.0.18
Compiling deno_error_macro v0.6.1
Compiling sys_traits_macros v0.1.0
Compiling zerofrom v0.1.8
Compiling yoke v0.8.3
Compiling tokio v1.52.3
Compiling zerotrie v0.2.4
Compiling zerovec v0.11.6
Compiling futures-util v0.3.32
Compiling tinystr v0.8.3
Compiling potential_utf v0.1.5
Compiling icu_locale_core v2.2.0
Compiling icu_collections v2.2.0
Compiling cpufeatures v0.2.17
Compiling zeroize v1.9.0
Compiling anyhow v1.0.102
Compiling icu_provider v2.2.0
Compiling getrandom v0.4.2
Compiling icu_properties v2.2.0
Compiling icu_normalizer v2.2.0
Compiling idna_adapter v1.2.2
Compiling idna v1.1.0
Compiling rustls-pki-types v1.14.1
Compiling url v2.5.8
Compiling v8 v137.3.0
Compiling alloc-stdlib v0.2.2
Compiling tracing-attributes v0.1.31
Compiling digest v0.10.7
Compiling http-body v1.0.1
Compiling new_debug_unreachable v1.0.6
Compiling simd-adler32 v0.3.9
Compiling untrusted v0.9.0
Compiling proc-macro-rules-macros v0.4.0
Compiling tracing v0.1.44
Compiling deno_error v0.6.1
Compiling brotli-decompressor v5.0.1
Compiling phf_macros v0.11.3
Compiling strum_macros v0.27.2
Compiling capacity_builder_macros v0.3.0
Compiling pin-project-internal v1.1.13
Compiling block-padding v0.3.3
Compiling tap v1.0.1
Compiling rustls v0.23.40
Compiling tower-service v0.3.3
Compiling try-lock v0.2.5
Compiling utf-8 v0.7.6
Compiling wyz v0.5.1
Compiling want v0.3.1
Compiling brotli v8.0.3
Compiling inout v0.1.4
Compiling phf v0.11.3
Compiling num-integer v0.1.46
Compiling flate2 v1.1.9
Compiling rustls-webpki v0.103.13
Compiling proc-macro-rules v0.4.0
Compiling string_cache_codegen v0.5.4
Compiling stringcase v0.4.0
Compiling funty v2.0.0
Compiling compression-core v0.4.32
Compiling uuid v1.23.3
Compiling atomic-waker v1.1.2
Compiling mac v0.1.1
Compiling outref v0.5.2
Compiling precomputed-hash v0.1.1
Compiling vsimd v0.8.0
Compiling futf v0.1.5
Compiling hyper v1.10.1
Compiling debugid v0.8.0
Compiling bitvec v1.0.1
Compiling markup5ever v0.14.1
Compiling strum v0.27.2
Compiling base64-simd v0.8.0
Compiling deno_ops v0.226.0
Compiling futures-executor v0.3.32
Compiling compression-codecs v0.4.38
Compiling num-bigint v0.4.6
Compiling cipher v0.4.4
Compiling sys_traits v0.1.28
Compiling sync_wrapper v1.0.2
Compiling tower-layer v0.3.3
Compiling ipnet v2.12.0
Compiling dtoa v1.0.11
Compiling unicode-id-start v1.4.0
Compiling data-encoding v2.11.0
Compiling bit-vec v0.8.0
Compiling base64 v0.22.1
Compiling if_chain v1.0.3
Compiling zerocopy v0.8.52
Compiling rustix v1.1.4
Compiling getrandom v0.3.4
Compiling serde_v8 v0.259.0
Compiling hyper-util v0.1.20
Compiling bit-set v0.8.0
Compiling sourcemap v9.3.2
Compiling dtoa-short v0.3.5
Compiling tower v0.5.3
Compiling deno_path_util v0.4.0
Compiling tokio-rustls v0.26.4
Compiling async-compression v0.4.42
Compiling futures v0.3.32
Compiling tendril v0.4.3
Compiling string_cache v0.8.9
Compiling deno_unsync v0.4.4
Compiling pin-project v1.1.13
Compiling capacity_builder v0.5.0
Compiling wasm_dep_analyzer v0.3.0
Compiling tokio-util v0.7.18
Compiling http-body-util v0.1.3
Compiling webpki-roots v1.0.7
Compiling bincode v1.3.3
Compiling cssparser-macros v0.6.1
Compiling selectors v0.26.0
Compiling universal-hash v0.5.1
Compiling byteorder v1.5.0
Compiling deno_core_icudata v0.74.0
Compiling ryu v1.0.23
Compiling cooked-waker v5.0.0
Compiling opaque-debug v0.3.1
Compiling az v1.3.0
Compiling static_assertions v1.1.0
Compiling linux-raw-sys v0.12.1
Compiling serde_urlencoded v0.7.1
Compiling polyval v0.6.2
Compiling fxhash v0.2.1
Compiling cssparser v0.34.0
Compiling tower-http v0.6.11
Compiling hyper-rustls v0.27.9
Compiling hmac v0.12.1
Compiling sha1 v0.10.6
Compiling deno_core v0.350.0
Compiling derive_more v0.99.20
Compiling match_token v0.1.0
Compiling servo_arc v0.4.3
Compiling fastrand v2.4.1
Compiling tempfile v3.27.0
Compiling html5ever v0.29.1
Compiling rand_core v0.9.5
Compiling reqwest v0.12.28
Compiling ghash v0.5.1
Compiling ctr v0.9.2
Compiling aes v0.8.4
Compiling async-trait v0.1.89
Compiling aead v0.5.2
Compiling encoding_rs v0.8.35
Compiling aes-gcm v0.10.3
Compiling ppv-lite86 v0.2.21
Compiling obscura-dom v0.1.0 (/root/obscura2/merge471/crates/obscura-dom)
Compiling hkdf v0.12.4
Compiling pbkdf2 v0.12.2
Compiling cbc v0.1.2
Compiling sha2 v0.10.9
Compiling rand_chacha v0.9.0
Compiling utf8parse v0.2.2
Compiling anstyle-parse v1.0.0
Compiling rand v0.9.4
Compiling obscura-net v0.1.0 (/root/obscura2/merge471/crates/obscura-net)
Compiling colorchoice v1.0.5
Compiling anstyle v1.0.14
Compiling anstyle-query v1.1.5
Compiling is_terminal_polyfill v1.70.2
Compiling anstream v1.0.0
Compiling tungstenite v0.26.2
Compiling strsim v0.11.1
Compiling lazy_static v1.5.0
Compiling clap_lex v1.1.0
Compiling obscura-js v0.1.0 (/root/obscura2/merge471/crates/obscura-js)
Compiling clap_builder v4.6.0
Compiling sharded-slab v0.1.7
Compiling tokio-tungstenite v0.26.2
Compiling tracing-log v0.2.0
Compiling clap_derive v4.6.1
Compiling thread_local v1.1.9
Compiling nu-ansi-term v0.50.3
Compiling obscura-cli v0.1.0 (/root/obscura2/merge471/crates/obscura-cli)
Compiling matchers v0.2.0
Compiling tracing-subscriber v0.3.23
Compiling clap v4.6.1
Compiling obscura-browser v0.1.0 (/root/obscura2/merge471/crates/obscura-browser)
Compiling obscura-cdp v0.1.0 (/root/obscura2/merge471/crates/obscura-cdp)
Compiling obscura-mcp v0.1.0 (/root/obscura2/merge471/crates/obscura-mcp)
Finished `release` profile [optimized] target(s) in 1m 52s
+17 -4
View File
@@ -744,6 +744,11 @@ impl Page {
}
if let Some(js) = &mut self.js {
let dynamic_settle_ms = std::env::var("OBSCURA_DYNAMIC_SCRIPT_SETTLE_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(3_000)
.max(500);
// Bound the post-script settle loop by wall clock, not just by the
// 10ms-tick branch. The old code only consulted `deadline` inside
// the `Err(_)` arm (when the inner tick timed out), so a steady
@@ -753,14 +758,22 @@ impl Page {
// On busy sites this could keep the V8 lock held for tens of
// seconds, wedging the entire CDP dispatcher (see triage for
// issue series around the 40-site compat sweep).
// A dynamic external script may still be in flight at 500ms. Keep
// pumping only while that queue is pending, up to a separate bounded
// budget, so normal pages and unrelated fetches retain the fast path.
// A single run_event_loop poll that pins the thread inside V8 makes
// the per-poll tokio timeouts below useless, so guard the whole loop
// with a watchdog that fires ~250ms past its 500ms deadline.
let settle_wd = js.arm_watchdog(std::time::Duration::from_millis(750));
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
// with a watchdog that fires 250ms past the longest deadline.
let settle_wd = js.arm_watchdog(std::time::Duration::from_millis(dynamic_settle_ms + 250));
let started = tokio::time::Instant::now();
let deadline = started + tokio::time::Duration::from_millis(500);
let dynamic_deadline = started + tokio::time::Duration::from_millis(dynamic_settle_ms);
let mut idle_count = 0u32;
loop {
if tokio::time::Instant::now() >= deadline {
let now = tokio::time::Instant::now();
if now >= deadline
&& (now >= dynamic_deadline || !js.has_pending_dynamic_scripts())
{
break;
}
let result = tokio::time::timeout(
@@ -0,0 +1,119 @@
// Regression for issue #474: external scripts inserted after a timer must be
// fetched and execute before the post-navigation event-loop settle completes.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let read = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..read]);
let (content_type, body) = if request.starts_with("GET /direct.js") {
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
("application/javascript", "window.__directExecuted = true;")
} else if request.starts_with("GET /nested.js") {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
("application/javascript", "window.__nestedExecuted = true;")
} else {
(
"text/html",
r#"<html><body>
<div id="r">stage1</div>
<script>
setTimeout(function () {
var direct = document.createElement("script");
direct.src = "/direct.js";
direct.onload = function () { window.__directLoaded = true; };
document.body.appendChild(direct);
var box = document.createElement("div");
var nested = document.createElement("script");
nested.src = "/nested.js";
nested.onload = function () { window.__nestedLoaded = true; };
box.appendChild(nested);
document.body.appendChild(box);
}, 100);
</script>
</body></html>"#,
)
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
format!("http://{addr}/")
}
async fn cdp(
ctx: &mut CdpContext,
id: u64,
method: &str,
params: Value,
session_id: &str,
) -> Value {
let response = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(
response.error.is_none(),
"CDP {method} failed: {:?}",
response.error
);
response.result.unwrap_or_else(|| json!({}))
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_external_scripts_execute_and_fire_load() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id);
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
let result = cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({
"expression": "JSON.stringify({directExecuted: !!window.__directExecuted, directLoaded: !!window.__directLoaded, nestedExecuted: !!window.__nestedExecuted, nestedLoaded: !!window.__nestedLoaded})",
"returnByValue": true,
}),
session_id,
)
.await;
assert_eq!(
result["result"]["value"],
r#"{"directExecuted":true,"directLoaded":true,"nestedExecuted":true,"nestedLoaded":true}"#,
"dynamic scripts must execute and fire load before navigation settles"
);
}
@@ -0,0 +1,170 @@
// Regression tests for iframe event dispatch. `_IframeDocument`'s
// addEventListener/removeEventListener/dispatchEvent were no-ops, so listeners
// registered on an iframe document never ran. Separately, iframe load invoked
// the `onload` property directly instead of dispatching through the element,
// which skipped any addEventListener('load', ...) listener. Both fail on main,
// pass after the fix.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
// Serve every request (not just the first): Page.navigate consumes one, and the
// iframe load test needs its own successful response so it exercises the real
// load path rather than the fetch-failure fallback.
async fn serve() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await;
let body = "<html><body><div id=a></div></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}/")
}
async fn cdp(
ctx: &mut CdpContext,
id: u64,
method: &str,
params: Value,
session_id: &str,
) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
resp.result.unwrap_or_else(|| json!({}))
}
async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
cdp(
ctx,
id,
"Runtime.evaluate",
json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
session_id,
)
.await
}
async fn setup() -> (CdpContext, String) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
(ctx, session_id.to_string())
}
#[tokio::test(flavor = "current_thread")]
async fn iframe_document_dispatches_registered_listeners() {
let (mut ctx, sid) = setup().await;
// Accessing contentDocument on a src-less iframe lazily creates an
// about:blank _IframeDocument synchronously, so this exercises the event
// target without waiting on an async load.
let v = eval(
&mut ctx,
2,
r#"(function () {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
const out = { hasDoc: !!doc };
let calls = 0;
const listener = () => { calls++; };
// A registered listener runs on dispatch.
doc.addEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterRegister = calls;
// Registering the same listener again is a no-op (deduped).
doc.addEventListener('probe', listener);
doc.addEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterDuplicate = calls;
// After removal the listener no longer runs.
doc.removeEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterRemove = calls;
// dispatchEvent reports cancellation via its return value.
doc.addEventListener('cancelme', (e) => e.preventDefault());
out.cancelReturn = doc.dispatchEvent(new Event('cancelme', { cancelable: true }));
out.plainReturn = doc.dispatchEvent(new Event('nolisteners'));
return JSON.stringify(out);
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["hasDoc"], true);
assert_eq!(val["afterRegister"].as_u64(), Some(1), "listener runs once on dispatch");
assert_eq!(val["afterDuplicate"].as_u64(), Some(2), "duplicate registration deduped");
assert_eq!(val["afterRemove"].as_u64(), Some(2), "removed listener does not run");
assert_eq!(val["cancelReturn"], false, "preventDefault -> dispatchEvent returns false");
assert_eq!(val["plainReturn"], true, "no cancellation -> dispatchEvent returns true");
}
#[tokio::test(flavor = "current_thread")]
async fn iframe_load_reaches_onload_and_addeventlistener() {
let (mut ctx, sid) = setup().await;
// Both the onload property and an addEventListener('load', ...) listener
// must run exactly once. On main, setting onload made the direct-call path
// skip the addEventListener listener entirely.
let v = eval(
&mut ctx,
2,
r#"(function () {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
const events = [];
// Resolve from the onload property (which fires on both old and
// new code) one microtask later, so the synchronous
// addEventListener('load') handler has already run on the fixed
// build. On main the listener never fires, so this reports
// ["property"] (a clean assertion failure) instead of hanging.
iframe.onload = () => {
events.push('property');
Promise.resolve().then(() => resolve(JSON.stringify({ events })));
};
iframe.addEventListener('load', () => events.push('listener'));
document.body.appendChild(iframe);
iframe.src = location.href;
});
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(
val["events"],
json!(["property", "listener"]),
"both onload property and addEventListener('load') run exactly once, in order"
);
}
@@ -0,0 +1,165 @@
//! Assigning `scrollTop` / `scrollLeft` must fire a `scroll` event.
//!
//! `scrollTo`/`scrollBy` already dispatched one; direct assignment did not, so
//! the very common lazy-load idiom
//!
//! ```js
//! el.addEventListener('scroll', loadMore);
//! el.scrollTop = el.scrollHeight; // never reached loadMore
//! ```
//!
//! silently did nothing and scroll-driven feeds stalled after their first batch.
//!
//! Checked against a real Chrome over CDP with this same probe: assigning
//! `scrollTop` fires exactly one `scroll` event there. These tests assert the
//! same observable behaviour, not the synthetic geometry around it — the offset
//! is intentionally not clamped, since without layout any maximum is a guess.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_once() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let body = "<html><body><div id=\"box\"><p>a</p><p>b</p></div></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
});
format!("http://{addr}/")
}
async fn cdp(ctx: &mut CdpContext, id: u64, method: &str, params: Value, session_id: &str) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
resp.result.unwrap_or_else(|| json!({}))
}
/// `awaitPromise` matters here: the scroll event is dispatched from a
/// `setTimeout(..., 0)`, so the probe has to yield before reading the counter.
async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
cdp(
ctx,
id,
"Runtime.evaluate",
json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
session_id,
)
.await
}
async fn setup() -> (CdpContext, String) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_once().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await;
(ctx, session_id.to_string())
}
async fn probe(ctx: &mut CdpContext, sid: &str, body: &str) -> Value {
let expr = format!(
r#"(() => {{
const el = document.getElementById('box');
let fired = 0;
el.addEventListener('scroll', () => {{ fired++; }});
{body}
return new Promise(r => setTimeout(() => r(JSON.stringify({{
fired, top: el.scrollTop, left: el.scrollLeft,
}})), 0));
}})()"#
);
let v = eval(ctx, 2, &expr, sid).await;
serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn assigning_scroll_top_fires_one_scroll_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTop = 100;").await;
assert_eq!(r["fired"], 1, "scrollTop assignment must fire exactly one scroll event");
assert_eq!(r["top"], 100, "scrollTop must round-trip the assigned value");
}
#[tokio::test(flavor = "current_thread")]
async fn assigning_scroll_left_fires_one_scroll_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollLeft = 40;").await;
assert_eq!(r["fired"], 1, "scrollLeft assignment must fire exactly one scroll event");
assert_eq!(r["left"], 40, "scrollLeft must round-trip the assigned value");
}
/// Re-assigning the same offset is not a scroll, so it must stay silent —
/// otherwise a loader that writes `scrollTop` on every frame re-enters forever.
#[tokio::test(flavor = "current_thread")]
async fn reassigning_the_same_offset_is_silent() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTop = 100; el.scrollTop = 100;").await;
assert_eq!(r["fired"], 1, "only the offset change fires; the repeat must not");
}
/// `scrollTo` moves both axes, and a real browser reports one scroll per
/// movement rather than one per axis. The setters suppress their own event
/// inside `scrollTo`/`scrollBy` so the operation stays a single event.
#[tokio::test(flavor = "current_thread")]
async fn scroll_to_coalesces_both_axes_into_one_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTo(30, 60);").await;
assert_eq!(r["fired"], 1, "scrollTo must fire one event, not one per axis");
assert_eq!(r["top"], 60);
assert_eq!(r["left"], 30);
}
/// The lazy-load idiom the fix exists for: a listener that appends more rows
/// when the feed is scrolled. Before the fix this counted 0 and feeds froze.
#[tokio::test(flavor = "current_thread")]
async fn scroll_driven_lazy_loader_advances() {
let (mut ctx, sid) = setup().await;
let v = eval(
&mut ctx,
2,
r#"(() => {
const el = document.getElementById('box');
let batches = 0;
el.addEventListener('scroll', () => {
if (batches >= 3) return;
batches++;
for (let i = 0; i < 5; i++) el.appendChild(document.createElement('p'));
});
el.scrollTop = 500;
return new Promise(r => setTimeout(() => {
el.scrollTop = 1000;
setTimeout(() => r(JSON.stringify({
batches, rows: el.querySelectorAll('p').length,
})), 0);
}, 0));
})()"#,
&sid,
)
.await;
let r = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(r["batches"], 2, "each forward scroll must reach the loader");
assert_eq!(r["rows"], 12, "2 starting rows + 2 batches of 5");
}
+15 -2
View File
@@ -21,10 +21,21 @@ impl DomTree {
pub fn inner_html(&self, node_id: NodeId) -> String {
let mut buf = String::new();
self.serialize_worklist(self.child_work(node_id), &mut buf);
self.serialize_worklist(self.child_work(self.content_source(node_id)), &mut buf);
buf
}
// The node whose children represent `node_id`'s markup. For a <template>
// that is its contents document, since the parser puts template children
// there rather than under the element (issue #463); for everything else it
// is the node itself.
fn content_source(&self, node_id: NodeId) -> NodeId {
match self.get_node(node_id).map(|n| n.data) {
Some(NodeData::Element { template_contents: Some(contents), .. }) => contents,
_ => node_id,
}
}
// Children as work items in document order (top of the LIFO stack first).
fn child_work(&self, node_id: NodeId) -> Vec<SerializeWork> {
self.children(node_id)
@@ -101,7 +112,9 @@ impl DomTree {
if include_self {
stack.push(SerializeWork::CloseTag(tag.to_string()));
}
for w in self.child_work(node_id) {
// A <template> serializes its contents document, not its
// own (always empty) children — issue #463.
for w in self.child_work(self.content_source(node_id)) {
stack.push(w);
}
}
+125
View File
@@ -579,6 +579,41 @@ impl DomTree {
Self::climb_to_next_sibling(&inner, root, current)
}
/// Returns the node before `current` in document order, without leaving the
/// subtree rooted at `root`. `root` has no predecessor within its own
/// subtree, but it is itself reachable as one — a NodeIterator can return
/// its root, unlike a TreeWalker.
///
/// A NodeIterator applies no subtree pruning (DOM 6.2: FILTER_REJECT
/// behaves as FILTER_SKIP), so unlike the TreeWalker's backward walk the
/// whole step fits here instead of being interleaved with filter calls.
pub fn prev_in_subtree(&self, root: NodeId, current: NodeId) -> Option<NodeId> {
let inner = self.inner.borrow();
if current == root {
return None;
}
let current_node = inner.nodes.get(current.index())?.as_ref()?;
let Some(prev) = current_node.prev_sibling else {
// No previous sibling: the parent immediately precedes `current`.
return current_node.parent;
};
// Otherwise it is the previous sibling's deepest last descendant.
let mut node_id = prev;
for _ in 0..=inner.nodes.len() {
let node = inner.nodes.get(node_id.index())?.as_ref()?;
match node.last_child {
Some(child) => node_id = child,
None => return Some(node_id),
}
}
// Same defense in depth as the forward walk: a malformed tree must not
// spin here.
None
}
/// Follow `current`'s next sibling, climbing ancestors until one has a next
/// sibling — without stepping outside `root`.
fn climb_to_next_sibling(
@@ -603,6 +638,41 @@ impl DomTree {
None
}
/// The node holding a `<template>` element's contents.
///
/// The parser puts template children in a separate contents document rather
/// than under the element (HTML spec), so this is the only way to reach
/// them. Templates built with `createElement` have no contents node yet, so
/// one is allocated on demand — `.content` must be usable either way.
///
/// Returns `None` for a non-element node.
pub fn template_contents(&self, node_id: NodeId) -> Option<NodeId> {
{
let inner = self.inner.borrow();
let node = inner.nodes.get(node_id.index())?.as_ref()?;
match &node.data {
NodeData::Element { template_contents, .. } => {
if let Some(existing) = *template_contents {
return Some(existing);
}
}
_ => return None,
}
}
// Borrow released above: `new_node` takes its own mutable borrow.
// Matches what the tree sink allocates for a parsed template.
let contents = self.new_node(NodeData::Document);
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(node_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(contents);
return Some(contents);
}
}
None
}
pub fn ancestors(&self, node_id: NodeId) -> Vec<NodeId> {
let inner = self.inner.borrow();
let mut result = Vec::new();
@@ -727,6 +797,38 @@ impl DomTree {
let new_id = self.new_node(node_data);
self.append_child(dest_parent, new_id);
// A <template>'s children hang off a separate contents document, so
// the child walk below never reaches them. Worse, the cloned data
// carries the *source* tree's contents NodeId, which here indexes
// whatever unrelated node occupies that slot. Allocate a real
// contents node and queue the source contents' children into it, so
// the reference is remapped rather than left dangling (issue #463).
let src_contents = {
let inner = self.inner.borrow();
match inner.nodes.get(new_id.index()).and_then(|n| n.as_ref()) {
Some(node) => match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
},
None => None,
}
};
if let Some(src_contents) = src_contents {
let dest_contents = self.new_node(NodeData::Document);
{
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(new_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(dest_contents);
}
}
}
// Onto the same stack, so nested templates stay iterative.
for child_id in source.children(src_contents).into_iter().rev() {
stack.push((dest_contents, child_id));
}
}
for child_id in source.children(src_id).into_iter().rev() {
stack.push((new_id, child_id));
}
@@ -1071,6 +1173,29 @@ mod tests {
assert_eq!(tree.next_after_subtree(root, root), None);
}
#[test]
fn test_prev_in_subtree_reverses_document_order() {
// root > [first > nested, second]; document order is root, first,
// nested, second, so the reverse walk must retrace it exactly.
let tree = DomTree::new();
let root = tree.new_node(NodeData::Text { contents: "root".into() });
let first = tree.new_node(NodeData::Text { contents: "first".into() });
let nested = tree.new_node(NodeData::Text { contents: "nested".into() });
let second = tree.new_node(NodeData::Text { contents: "second".into() });
tree.append_child(tree.document(), root);
tree.append_child(root, first);
tree.append_child(first, nested);
tree.append_child(root, second);
// Previous sibling's deepest last descendant, not the sibling itself.
assert_eq!(tree.prev_in_subtree(root, second), Some(nested));
assert_eq!(tree.prev_in_subtree(root, nested), Some(first));
// No previous sibling: the parent precedes it, and root is returnable.
assert_eq!(tree.prev_in_subtree(root, first), Some(root));
// Root has no predecessor inside its own subtree.
assert_eq!(tree.prev_in_subtree(root, root), None);
}
// Builds a chain of `depth` nested <div> elements under the document and
// returns (outermost_div, innermost_div). Depth this large overflows a
// recursive tree walk and aborts the process, so it guards the iterative
+202 -35
View File
@@ -1216,6 +1216,8 @@ class Element extends Node {
if (this.localName === "svg") return "http://www.w3.org/2000/svg";
return "http://www.w3.org/1999/xhtml";
}
// `inner_html` resolves a <template> to its contents document on the Rust
// side (issue #463), so this needs no template special case.
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
set innerHTML(v) {
if (this.localName === 'template') {
@@ -1254,6 +1256,18 @@ class Element extends Node {
// infinite retry loop (issue #210).
const tag = this.localName;
if (tag === 'template') {
// Back the fragment with the node's real template contents (issue #463).
// The parser stores template children in a separate contents document
// instead of under the element, so without this the getter handed back a
// fabricated empty fragment and the parsed markup was unreachable.
// `template_contents` allocates one on demand for created templates.
const nid = +_dom("template_contents", this._nid);
if (nid >= 0) {
// Cache by node id so `.content` keeps a stable identity across reads —
// frameworks stash the fragment and compare it later.
if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid));
return _cache.get(nid);
}
if (!this._templateContent) this._templateContent = document.createDocumentFragment();
return this._templateContent;
}
@@ -1929,17 +1943,15 @@ class Element extends Node {
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
}
if (typeof el.onload === 'function') {
try { el.onload(); } catch(e) {}
} else {
var onloadAttr = el.getAttribute('onload');
if (onloadAttr) try { (0, eval)(onloadAttr); } catch(e) {}
}
// Dispatch through the element so the onload property/attribute and any
// addEventListener('load', ...) listeners all run. Calling el.onload()
// directly bypasses listeners registered via addEventListener.
el.dispatchEvent(new Event('load'));
}).catch(() => {
el._iframeDoc = new _IframeDocument('<!DOCTYPE html><html><head></head><body></body></html>', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
if (typeof el.onload === 'function') try { el.onload(); } catch(e) {}
el.dispatchEvent(new Event('load'));
});
}
get contentDocument() {
@@ -2110,13 +2122,29 @@ class Element extends Node {
const t = this.tagName;
return t === 'HTML' || t === 'BODY';
}
// No layout engine, so there is no real overflow to scroll. We still track a
// scroll offset so scrollTop/scrollLeft round-trip and the scroll methods
// below can report a position, which is what infinite-scroll code reads back.
// No layout engine, so there is no real overflow to scroll and the offset is
// deliberately NOT clamped: without real geometry any synthetic max is a
// guess, and a max derived from a stub scroll box pins scrollTop at 0, which
// deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll).
// We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll
// event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely
// on that event, and scrollTo/scrollBy below would otherwise be its only source.
get scrollTop() { return this._scrollTop || 0; }
set scrollTop(v) { v = +v; this._scrollTop = Number.isFinite(v) && v > 0 ? v : 0; }
set scrollTop(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
const changed = nv !== (this._scrollTop || 0);
this._scrollTop = nv;
if (changed && !this._scrollSuppress) this._fireScroll();
}
get scrollLeft() { return this._scrollLeft || 0; }
set scrollLeft(v) { v = +v; this._scrollLeft = Number.isFinite(v) && v > 0 ? v : 0; }
set scrollLeft(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
const changed = nv !== (this._scrollLeft || 0);
this._scrollLeft = nv;
if (changed && !this._scrollSuppress) this._fireScroll();
}
getBoundingClientRect() {
globalThis.__obscura_click_target = this;
// documentElement and body span the full viewport. Without this every
@@ -2175,15 +2203,17 @@ class Element extends Node {
set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); }
scrollIntoView() { globalThis.__obscura_click_target = this; }
// scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object.
// Without layout the offset cannot be clamped to a real max, but updating it
// and firing a scroll event lets scroll-driven lazy loaders advance instead
// of throwing "scrollBy is not a function".
// The setters fire a scroll event of their own, so suppress the per-axis ones
// here and emit a single event for the whole movement, the way a real browser
// coalesces one scroll per scroll operation rather than one per axis.
scrollTo(x, y) {
let left, top;
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
else { left = x; top = y; }
this._scrollSuppress = true;
if (left !== undefined) this.scrollLeft = +left || 0;
if (top !== undefined) this.scrollTop = +top || 0;
this._scrollSuppress = false;
this._fireScroll();
}
scroll(x, y) { this.scrollTo(x, y); }
@@ -2191,8 +2221,10 @@ class Element extends Node {
let dl, dt;
if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; }
else { dl = x; dt = y; }
this._scrollSuppress = true;
this.scrollLeft = (this.scrollLeft || 0) + (+dl || 0);
this.scrollTop = (this.scrollTop || 0) + (+dt || 0);
this._scrollSuppress = false;
this._fireScroll();
}
_fireScroll() {
@@ -2637,11 +2669,8 @@ class Document extends Node {
const verdict = this._filter(node);
if (verdict === 1) { this.currentNode = node; return node; }
// FILTER_REJECT skips the node AND its subtree; FILTER_SKIP (and any
// other non-accept value) skips only the node. NodeIterator has no
// pruning at all, so `_rejectIsSkip` keeps it on the plain step.
const step = (verdict === 2 && !this._rejectIsSkip)
? "next_after_subtree"
: "next_in_subtree";
// other non-accept value) skips only the node.
const step = verdict === 2 ? "next_after_subtree" : "next_in_subtree";
node = _wrap(+_dom(step, this.root._nid, node._nid));
}
return null;
@@ -2734,26 +2763,86 @@ class Document extends Node {
lastChild() { return this._traverseChildren('lastChild', 'previousSibling'); },
nextSibling() { return this._traverseSiblings('firstChild', 'nextSibling'); },
previousSibling() { return this._traverseSiblings('lastChild', 'previousSibling'); },
// DOM 6.1 "parentNode" (issue #475). The old version looked only at the
// immediate parent, so it couldn't climb past a skipped ancestor; it also
// excluded `root` as a result yet stepped to root's own parent when
// currentNode was root, returning a node OUTSIDE the walker's subtree.
// The loop's `node !== this.root` guard is what keeps the walk inside
// root while still allowing root itself to be returned.
parentNode() {
let parent = this.currentNode.parentNode;
if (parent && parent !== this.root && this._accept(parent)) {
this.currentNode = parent;
return parent;
let node = this.currentNode;
while (node && node !== this.root) {
node = node.parentNode;
if (node && this._accept(node)) { this.currentNode = node; return node; }
}
return null;
},
};
return walker;
}
// A real NodeIterator (DOM 6.2), not a TreeWalker in disguise (issue #467).
// The two differ in more than naming: an iterator's pointer starts *before*
// its root, so the first nextNode() returns the root itself, and it exposes
// referenceNode/pointerBeforeReferenceNode/detach rather than a TreeWalker's
// currentNode and child/sibling movers.
createNodeIterator(root, whatToShow, filter) {
// Shares the TreeWalker implementation, but DOM 6.2 gives NodeIterator no
// subtree pruning: FILTER_REJECT behaves exactly as FILTER_SKIP there.
const iterator = this.createTreeWalker(root, whatToShow, filter);
iterator._rejectIsSkip = true;
return iterator;
// whatToShow is unsigned long; default SHOW_ALL only when the arg is
// omitted. An explicit 0 (show nothing) must stay 0, not become SHOW_ALL.
whatToShow = (whatToShow === undefined) ? 0xFFFFFFFF : (whatToShow >>> 0);
return {
root: root,
referenceNode: root,
pointerBeforeReferenceNode: true,
whatToShow: whatToShow,
filter: filter || null,
// NodeIterator prunes nothing: FILTER_REJECT behaves as FILTER_SKIP, so
// unlike the TreeWalker only "accepted or not" matters here.
_accept(node) {
if (!((whatToShow >> (node.nodeType - 1)) & 1)) return false;
if (this.filter) {
if (typeof this.filter === 'function') return this.filter(node) === 1;
if (this.filter.acceptNode) return this.filter.acceptNode(node) === 1;
}
return true;
},
// DOM 6.2 "traverse". The pointer sits either before or after
// referenceNode, which is why reversing direction re-yields the current
// node instead of stepping over it.
_traverse(forward) {
let node = this.referenceNode;
let before = this.pointerBeforeReferenceNode;
for (;;) {
if (forward === before) {
// Consume the pointer's side without moving: it flips to the other
// side of the node it already references.
before = !before;
} else {
const step = forward ? "next_in_subtree" : "prev_in_subtree";
const next = _wrap(+_dom(step, this.root._nid, node._nid));
// A failed traversal leaves referenceNode and the pointer
// untouched, so the iterator can be resumed in either direction.
if (!next) return null;
node = next;
}
if (this._accept(node)) break;
}
this.referenceNode = node;
this.pointerBeforeReferenceNode = before;
return node;
},
nextNode() { return this._traverse(true); },
previousNode() { return this._traverse(false); },
// Legacy no-op since DOM4, but older library code still calls it and
// used to hit "detach is not a function".
detach() {},
};
}
getSelection() { return this.defaultView ? _selectionFor(this) : null; }
get activeElement() { return globalThis.__obscura_focused || this.body; }
// The element that scrolls the viewport, and where the page offset lives
// (issue #468). Standards mode, so documentElement — quirks mode would be
// body, but we never parse in quirks mode.
get scrollingElement() { return this.documentElement; }
get implementation() {
const ownerDoc = this;
return {
@@ -6152,9 +6241,33 @@ class _IframeDocument {
get implementation() { return document.implementation; }
get styleSheets() { return []; }
addEventListener() {}
removeEventListener() {}
dispatchEvent() { return true; }
addEventListener(type, listener) {
if (typeof listener !== 'function') return;
if (!this._listeners) this._listeners = Object.create(null);
const list = this._listeners[type] || (this._listeners[type] = []);
if (!list.includes(listener)) list.push(listener);
}
removeEventListener(type, listener) {
const list = this._listeners && this._listeners[type];
if (!list) return;
const index = list.indexOf(listener);
if (index !== -1) list.splice(index, 1);
}
dispatchEvent(event) {
const type = event && event.type;
if (!type) return true;
const list = this._listeners && this._listeners[type];
if (list) {
for (const listener of list.slice()) {
try { listener.call(this, event); } catch (error) { console.error(error); }
}
}
const handler = this['on' + type];
if (typeof handler === 'function') {
try { handler.call(this, event); } catch (error) { console.error(error); }
}
return !event.defaultPrevented;
}
write(html) {
if (this._body) this._body.innerHTML += html;
@@ -7152,9 +7265,63 @@ URL.revokeObjectURL = function(url) {
delete globalThis.__blobStore[url];
};
globalThis.scrollTo = function(x, y) {};
globalThis.scrollBy = function(x, y) {};
globalThis.scroll = function(x, y) {};
// Window-level scrolling (issue #468). #431 gave elements functional
// scrollTop/scrollLeft plus scroll methods, but left these three as no-ops, so
// the dominant infinite-scroll idiom -- window.scrollTo(0, body.scrollHeight),
// window.scrollBy(0, 500), then a window 'scroll' listener -- did nothing at
// all: the offset never moved and no event ever fired.
//
// The page offset is stored on the scrolling element rather than in separate
// window state, so window.scrollY and document.scrollingElement.scrollTop are
// two views of one value, which is what pages assume. As with #431 there is no
// layout, so the offset still cannot be clamped to a real maximum.
function _scrollRoot() {
const doc = globalThis.document;
return (doc && doc.scrollingElement) || null;
}
function _windowScroll(x, y, relative) {
const root = _scrollRoot();
if (!root) return;
let left, top;
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
else { left = x; top = y; }
if (left !== undefined) {
root.scrollLeft = (relative ? (root.scrollLeft || 0) : 0) + (+left || 0);
}
if (top !== undefined) {
root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0);
}
// Async, matching the element path #431 added. Dispatched at the document
// AND the window: a page scroll event reaches both in Chrome, but
// Document.dispatchEvent here runs only its own listeners and does not
// propagate, so firing once would strand half the listeners.
setTimeout(() => {
try {
const doc = globalThis.document;
if (doc) doc.dispatchEvent(new Event('scroll', { bubbles: false }));
globalThis.dispatchEvent(new Event('scroll', { bubbles: false }));
} catch (e) {}
}, 0);
}
globalThis.scrollTo = function(x, y) { _windowScroll(x, y, false); };
globalThis.scrollBy = function(x, y) { _windowScroll(x, y, true); };
globalThis.scroll = function(x, y) { _windowScroll(x, y, false); };
_markNative(globalThis.scrollTo);
_markNative(globalThis.scrollBy);
_markNative(globalThis.scroll);
// Read-only accessors, as on a real Window: assigning window.scrollY does not
// scroll the page. These replace the hard-coded 0 data properties defined
// earlier, so they must stay after them.
for (const [name, offset] of [
['scrollX', 'scrollLeft'], ['pageXOffset', 'scrollLeft'],
['scrollY', 'scrollTop'], ['pageYOffset', 'scrollTop'],
]) {
Object.defineProperty(globalThis, name, {
configurable: true,
enumerable: true,
get() { const root = _scrollRoot(); return root ? (root[offset] || 0) : 0; },
});
}
globalThis.focus = function() {}; _markNative(globalThis.focus);
globalThis.blur = function() {}; _markNative(globalThis.blur);
globalThis.print = function() {}; _markNative(globalThis.print);
+18
View File
@@ -275,6 +275,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
// Reverse document order within a subtree, for NodeIterator's backward
// walk (which prunes nothing, so the whole step fits in the DOM layer).
"prev_in_subtree" => {
let root = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let current = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
dom.prev_in_subtree(root, current)
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
// Step past a whole subtree rather than into it: NodeFilter.FILTER_REJECT
// prunes the rejected node's descendants, unlike FILTER_SKIP.
"next_after_subtree" => {
@@ -392,6 +401,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
});
"true".into()
}
// A <template>'s children live in a separate contents document, so this
// is the only route to them from JS. Allocates one on demand for
// templates built via createElement.
"template_contents" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.template_contents(NodeId::new(nid))
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"create_document_fragment" => {
dom.new_node(NodeData::Document).index().to_string()
}
+380 -1
View File
@@ -859,6 +859,18 @@ impl ObscuraJsRuntime {
.map_err(|e| format!("Event loop error: {}", e))
}
/// Whether the serialized dynamic-script queue is still fetching or
/// evaluating a script. The queue variables are global lexicals rather
/// than window properties, so page code cannot overwrite this state.
pub fn has_pending_dynamic_scripts(&mut self) -> bool {
self.evaluate(
"typeof __dynScriptBusy !== 'undefined' && (__dynScriptBusy || __dynScriptQueue.length > 0)",
)
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
/// Arm a hard wall-clock backstop on synchronous V8 work. A page stuck in a
/// synchronous loop or a microtask storm pins the OS thread inside V8, so
/// `tokio::time::timeout` (which can only cancel at await points) never
@@ -1513,6 +1525,75 @@ mod tests {
/// Issue #461: NodeIterator has no subtree pruning — DOM 6.2 says
/// FILTER_REJECT behaves as FILTER_SKIP there. The shared walker must not
/// Issue #475: parentNode() must never surface a node above `root`. With
/// currentNode at root, the old guard stepped to root's own parent and
/// returned it — escaping the walker's subtree entirely.
#[test]
fn tree_walker_parent_node_does_not_escape_above_root() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const escaped = w.parentNode();
return [escaped, w.currentNode.id];
"#,
)
.unwrap();
// No parent within the subtree, and currentNode stays put at root.
assert_eq!(result, serde_json::json!([null, "root"]));
}
/// Issue #475: when the accepted ancestor is `root` itself, parentNode()
/// returns it and moves currentNode there — the old `parent !== root` guard
/// wrongly excluded it.
#[test]
fn tree_walker_parent_node_can_return_the_root() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
w.currentNode = root.querySelector('a');
const p = w.parentNode();
return [p ? p.id : null, w.currentNode === root];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["root", true]));
}
/// Issue #475: parentNode() climbs past a skipped ancestor to the first
/// accepted one, instead of stopping at the immediate parent.
#[test]
fn tree_walker_parent_node_climbs_past_skipped_ancestors() {
let mut rt = setup_runtime(
r#"<div id="root"><main id="m"><section><a></a></section></main></div>"#,
);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(n) {
return n.tagName === 'SECTION'
? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT;
}
});
w.currentNode = root.querySelector('a');
const p = w.parentNode();
return p ? p.id : null;
"#,
)
.unwrap();
// <a>'s parent <section> is skipped, so <main> is the first accepted
// ancestor — not null, and not the immediate <section>.
assert_eq!(result, serde_json::json!("m"));
}
/// leak TreeWalker's pruning into it.
#[test]
fn node_iterator_treats_filter_reject_as_skip() {
@@ -1538,7 +1619,305 @@ mod tests {
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["P", "A"]));
// The rejected <section> is skipped but not pruned, so <p> still shows.
// The leading root is #467: an iterator yields the node it is rooted at.
assert_eq!(result, serde_json::json!(["DIV", "P", "A"]));
}
/// Issue #467: a NodeIterator starts *before* its root, so the first
/// nextNode() returns the root itself. Aliasing createTreeWalker silently
/// dropped exactly the element the iterator was rooted at.
#[test]
fn node_iterator_yields_the_root_node_first() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const seen = [];
let node;
while ((node = it.nextNode())) seen.push(node.tagName);
return seen;
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["DIV", "A"]));
}
/// Issue #467: the NodeIterator interface surface, and that TreeWalker-only
/// members are not exposed on it.
#[test]
fn node_iterator_exposes_its_own_interface() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const before = [it.referenceNode === root, it.pointerBeforeReferenceNode];
it.nextNode();
return [
before,
typeof it.detach,
it.detach() === undefined,
typeof it.previousNode,
it.root === root,
it.whatToShow,
// TreeWalker-only members must not leak onto a NodeIterator.
typeof it.currentNode,
typeof it.firstChild,
typeof it.parentNode,
// The pointer advanced past the root it just returned.
[it.referenceNode.tagName, it.pointerBeforeReferenceNode],
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
[true, true],
"function",
true,
"function",
true,
1,
"undefined",
"undefined",
"undefined",
["DIV", false]
])
);
}
/// Issue #467: previousNode() retraces the iterator, and the root is the
/// last node it yields going backwards.
#[test]
fn node_iterator_previous_node_retraces_the_walk() {
let mut rt = setup_runtime(r#"<div id="root"><a><b></b></a><c></c></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const forward = [];
let node;
while ((node = it.nextNode())) forward.push(node.tagName);
const backward = [];
while ((node = it.previousNode())) backward.push(node.tagName);
return [forward, backward];
"#,
)
.unwrap();
// Forward ends on <c>; going back re-yields <c> (the pointer sits after
// it), then the rest in reverse, root included.
assert_eq!(
result,
serde_json::json!([
["DIV", "A", "B", "C"],
["C", "B", "A", "DIV"]
])
);
}
/// Issue #463: `<template>` contents are parsed into the node's
/// `template_contents` document, but no op exposed it, so `.content` handed
/// back a fabricated empty fragment and the parsed markup was unreachable.
#[test]
fn template_content_exposes_parsed_markup() {
let mut rt = setup_runtime(
r#"<body><template id="t"><p class="row">a</p><p class="row">b</p></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
return [
t.content.childNodes.length,
t.content.querySelectorAll('.row').length,
t.content.firstElementChild.textContent,
t.innerHTML,
t.content.nodeType,
t.content instanceof DocumentFragment,
// Identity is stable: frameworks stash `.content` and reuse it.
t.content === t.content,
// The children stay off the element itself, per the HTML spec.
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
2,
2,
"a",
r#"<p class="row">a</p><p class="row">b</p>"#,
11,
true,
true,
0
])
);
}
/// Issue #463: the same must hold for a template that arrives via innerHTML
/// rather than the initial document parse — that is how most frameworks
/// inject templates.
#[test]
fn template_content_works_for_templates_added_via_inner_html() {
let mut rt = setup_runtime(r#"<body><div id="host"></div></body>"#);
let result = rt
.evaluate(
r#"
const host = document.getElementById('host');
host.innerHTML = '<template id="t2"><li class="item">x</li></template>';
const t = document.getElementById('t2');
const stamped = t.content.cloneNode(true);
host.appendChild(stamped);
return [
t.content.childNodes.length,
t.content.querySelector('.item').textContent,
host.querySelectorAll('li.item').length,
];
"#,
)
.unwrap();
// cloneNode(true) of the content is the canonical stamping idiom.
assert_eq!(result, serde_json::json!([1, "x", 1]));
}
/// Issue #463: a template built with createElement has no parsed contents,
/// so `.content` must allocate a backing fragment on demand and round-trip
/// through innerHTML.
#[test]
fn template_content_round_trips_for_created_templates() {
let mut rt = setup_runtime(r#"<body></body>"#);
let result = rt
.evaluate(
r#"
const t = document.createElement('template');
t.innerHTML = '<span class="s">hi</span>';
return [
t.content.childNodes.length,
t.content.querySelector('.s').textContent,
t.innerHTML,
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([1, "hi", r#"<span class="s">hi</span>"#, 0])
);
}
/// Issue #463: serializing a `<template>` must emit its contents, or the
/// markup silently disappears from outerHTML/innerHTML round-trips — and
/// `cloneNode(true)`, which round-trips through outer_html, yields an empty
/// template.
#[test]
fn template_contents_survive_serialization_and_clone() {
let mut rt = setup_runtime(
r#"<body><template id="t"><li class="item">x</li></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
const clone = t.cloneNode(true);
return [
t.outerHTML,
document.body.innerHTML,
clone.content.childNodes.length,
clone.content.querySelector('.item').textContent,
// The clone's contents are its own, not shared with the original.
(clone.content.firstElementChild === t.content.firstElementChild),
];
"#,
)
.unwrap();
let expected = r#"<template id="t"><li class="item">x</li></template>"#;
assert_eq!(
result,
serde_json::json!([expected, expected, 1, "x", false])
);
}
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
/// dominant infinite-scroll idiom never advanced the page offset.
#[test]
fn window_scroll_methods_move_the_page_offset() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate(
r#"
const scrolled = window.scrollTo(0, 500);
const afterTo = [window.scrollX, window.scrollY];
window.scrollBy(0, 200);
const afterBy = [window.pageXOffset, window.pageYOffset];
window.scrollTo({ left: 10, top: 40 });
const afterOptions = [window.scrollX, window.scrollY];
window.scroll(5, 5);
const afterScroll = [window.scrollX, window.scrollY];
// Negative offsets clamp to 0, as they do for elements.
window.scrollTo(0, -100);
return [afterTo, afterBy, afterOptions, afterScroll, window.scrollY];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([[0, 500], [0, 700], [10, 40], [5, 5], 0])
);
}
/// Issue #468: the page offset is one value, readable and writable through
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
#[test]
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate(
r#"
const isDocEl = document.scrollingElement === document.documentElement;
window.scrollTo(0, 300);
// Written through the window, read through the element...
const viaElement = document.scrollingElement.scrollTop;
// ...and the reverse.
document.scrollingElement.scrollTop = 90;
return [isDocEl, viaElement, window.scrollY, window.pageYOffset];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([true, 300, 90, 90]));
}
/// Issue #468: a scroll event must reach listeners on both the window and
/// the document — that is the signal lazy loaders wait for.
#[tokio::test(flavor = "current_thread")]
async fn window_scroll_fires_a_scroll_event() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener('scroll', () => win++);
document.addEventListener('scroll', () => doc++);
window.scrollBy(0, 400);
setTimeout(() => resolve([win, doc, window.scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
}
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so