diff --git a/build.log b/build.log new file mode 100644 index 0000000..f7a5ee5 --- /dev/null +++ b/build.log @@ -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 diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 64fc64c..2e169fd 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -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::().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( diff --git a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs new file mode 100644 index 0000000..d80a92e --- /dev/null +++ b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs @@ -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#" +
stage1
+ +"#, + ) + }; + 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" + ); +} diff --git a/crates/obscura-cdp/tests/iframe_event_dispatch.rs b/crates/obscura-cdp/tests/iframe_event_dispatch.rs new file mode 100644 index 0000000..e68d3d1 --- /dev/null +++ b/crates/obscura-cdp/tests/iframe_event_dispatch.rs @@ -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 = "
"; + 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::(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::(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" + ); +} diff --git a/crates/obscura-cdp/tests/scroll_event_on_assignment.rs b/crates/obscura-cdp/tests/scroll_event_on_assignment.rs new file mode 100644 index 0000000..9c930ef --- /dev/null +++ b/crates/obscura-cdp/tests/scroll_event_on_assignment.rs @@ -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 = "

a

b

"; + 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::(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::(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"); +} diff --git a/crates/obscura-dom/src/serialize.rs b/crates/obscura-dom/src/serialize.rs index 8f07251..3850945 100644 --- a/crates/obscura-dom/src/serialize.rs +++ b/crates/obscura-dom/src/serialize.rs @@ -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