mirror of
https://github.com/aphrody-labs/obscura.git
synced 2026-10-13 21:04:57 +00:00
Merge remote-tracking branch 'origin/main' into pr471-push
# Conflicts: # crates/obscura-js/js/bootstrap.js
This commit is contained in:
commit
0df158ec9d
10 files changed
+1494
-42
No files matched your search
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+202
-35
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user