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

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

No files matched your search

+17 -4
View File
@@ -744,6 +744,11 @@ impl Page {
}
if let Some(js) = &mut self.js {
let dynamic_settle_ms = std::env::var("OBSCURA_DYNAMIC_SCRIPT_SETTLE_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(3_000)
.max(500);
// Bound the post-script settle loop by wall clock, not just by the
// 10ms-tick branch. The old code only consulted `deadline` inside
// the `Err(_)` arm (when the inner tick timed out), so a steady
@@ -753,14 +758,22 @@ impl Page {
// On busy sites this could keep the V8 lock held for tens of
// seconds, wedging the entire CDP dispatcher (see triage for
// issue series around the 40-site compat sweep).
// A dynamic external script may still be in flight at 500ms. Keep
// pumping only while that queue is pending, up to a separate bounded
// budget, so normal pages and unrelated fetches retain the fast path.
// A single run_event_loop poll that pins the thread inside V8 makes
// the per-poll tokio timeouts below useless, so guard the whole loop
// with a watchdog that fires ~250ms past its 500ms deadline.
let settle_wd = js.arm_watchdog(std::time::Duration::from_millis(750));
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
// with a watchdog that fires 250ms past the longest deadline.
let settle_wd = js.arm_watchdog(std::time::Duration::from_millis(dynamic_settle_ms + 250));
let started = tokio::time::Instant::now();
let deadline = started + tokio::time::Duration::from_millis(500);
let dynamic_deadline = started + tokio::time::Duration::from_millis(dynamic_settle_ms);
let mut idle_count = 0u32;
loop {
if tokio::time::Instant::now() >= deadline {
let now = tokio::time::Instant::now();
if now >= deadline
&& (now >= dynamic_deadline || !js.has_pending_dynamic_scripts())
{
break;
}
let result = tokio::time::timeout(
@@ -0,0 +1,119 @@
// Regression for issue #474: external scripts inserted after a timer must be
// fetched and execute before the post-navigation event-loop settle completes.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let read = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..read]);
let (content_type, body) = if request.starts_with("GET /direct.js") {
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
("application/javascript", "window.__directExecuted = true;")
} else if request.starts_with("GET /nested.js") {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
("application/javascript", "window.__nestedExecuted = true;")
} else {
(
"text/html",
r#"<html><body>
<div id="r">stage1</div>
<script>
setTimeout(function () {
var direct = document.createElement("script");
direct.src = "/direct.js";
direct.onload = function () { window.__directLoaded = true; };
document.body.appendChild(direct);
var box = document.createElement("div");
var nested = document.createElement("script");
nested.src = "/nested.js";
nested.onload = function () { window.__nestedLoaded = true; };
box.appendChild(nested);
document.body.appendChild(box);
}, 100);
</script>
</body></html>"#,
)
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
format!("http://{addr}/")
}
async fn cdp(
ctx: &mut CdpContext,
id: u64,
method: &str,
params: Value,
session_id: &str,
) -> Value {
let response = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(
response.error.is_none(),
"CDP {method} failed: {:?}",
response.error
);
response.result.unwrap_or_else(|| json!({}))
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_external_scripts_execute_and_fire_load() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id);
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
let result = cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({
"expression": "JSON.stringify({directExecuted: !!window.__directExecuted, directLoaded: !!window.__directLoaded, nestedExecuted: !!window.__nestedExecuted, nestedLoaded: !!window.__nestedLoaded})",
"returnByValue": true,
}),
session_id,
)
.await;
assert_eq!(
result["result"]["value"],
r#"{"directExecuted":true,"directLoaded":true,"nestedExecuted":true,"nestedLoaded":true}"#,
"dynamic scripts must execute and fire load before navigation settles"
);
}
@@ -0,0 +1,170 @@
// Regression tests for iframe event dispatch. `_IframeDocument`'s
// addEventListener/removeEventListener/dispatchEvent were no-ops, so listeners
// registered on an iframe document never ran. Separately, iframe load invoked
// the `onload` property directly instead of dispatching through the element,
// which skipped any addEventListener('load', ...) listener. Both fail on main,
// pass after the fix.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
// Serve every request (not just the first): Page.navigate consumes one, and the
// iframe load test needs its own successful response so it exercises the real
// load path rather than the fetch-failure fallback.
async fn serve() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await;
let body = "<html><body><div id=a></div></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}/")
}
async fn cdp(
ctx: &mut CdpContext,
id: u64,
method: &str,
params: Value,
session_id: &str,
) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
resp.result.unwrap_or_else(|| json!({}))
}
async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
cdp(
ctx,
id,
"Runtime.evaluate",
json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
session_id,
)
.await
}
async fn setup() -> (CdpContext, String) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
(ctx, session_id.to_string())
}
#[tokio::test(flavor = "current_thread")]
async fn iframe_document_dispatches_registered_listeners() {
let (mut ctx, sid) = setup().await;
// Accessing contentDocument on a src-less iframe lazily creates an
// about:blank _IframeDocument synchronously, so this exercises the event
// target without waiting on an async load.
let v = eval(
&mut ctx,
2,
r#"(function () {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
const out = { hasDoc: !!doc };
let calls = 0;
const listener = () => { calls++; };
// A registered listener runs on dispatch.
doc.addEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterRegister = calls;
// Registering the same listener again is a no-op (deduped).
doc.addEventListener('probe', listener);
doc.addEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterDuplicate = calls;
// After removal the listener no longer runs.
doc.removeEventListener('probe', listener);
doc.dispatchEvent(new Event('probe'));
out.afterRemove = calls;
// dispatchEvent reports cancellation via its return value.
doc.addEventListener('cancelme', (e) => e.preventDefault());
out.cancelReturn = doc.dispatchEvent(new Event('cancelme', { cancelable: true }));
out.plainReturn = doc.dispatchEvent(new Event('nolisteners'));
return JSON.stringify(out);
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(val["hasDoc"], true);
assert_eq!(val["afterRegister"].as_u64(), Some(1), "listener runs once on dispatch");
assert_eq!(val["afterDuplicate"].as_u64(), Some(2), "duplicate registration deduped");
assert_eq!(val["afterRemove"].as_u64(), Some(2), "removed listener does not run");
assert_eq!(val["cancelReturn"], false, "preventDefault -> dispatchEvent returns false");
assert_eq!(val["plainReturn"], true, "no cancellation -> dispatchEvent returns true");
}
#[tokio::test(flavor = "current_thread")]
async fn iframe_load_reaches_onload_and_addeventlistener() {
let (mut ctx, sid) = setup().await;
// Both the onload property and an addEventListener('load', ...) listener
// must run exactly once. On main, setting onload made the direct-call path
// skip the addEventListener listener entirely.
let v = eval(
&mut ctx,
2,
r#"(function () {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
const events = [];
// Resolve from the onload property (which fires on both old and
// new code) one microtask later, so the synchronous
// addEventListener('load') handler has already run on the fixed
// build. On main the listener never fires, so this reports
// ["property"] (a clean assertion failure) instead of hanging.
iframe.onload = () => {
events.push('property');
Promise.resolve().then(() => resolve(JSON.stringify({ events })));
};
iframe.addEventListener('load', () => events.push('listener'));
document.body.appendChild(iframe);
iframe.src = location.href;
});
})()"#,
&sid,
)
.await;
let val = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(
val["events"],
json!(["property", "listener"]),
"both onload property and addEventListener('load') run exactly once, in order"
);
}
@@ -0,0 +1,165 @@
//! Assigning `scrollTop` / `scrollLeft` must fire a `scroll` event.
//!
//! `scrollTo`/`scrollBy` already dispatched one; direct assignment did not, so
//! the very common lazy-load idiom
//!
//! ```js
//! el.addEventListener('scroll', loadMore);
//! el.scrollTop = el.scrollHeight; // never reached loadMore
//! ```
//!
//! silently did nothing and scroll-driven feeds stalled after their first batch.
//!
//! Checked against a real Chrome over CDP with this same probe: assigning
//! `scrollTop` fires exactly one `scroll` event there. These tests assert the
//! same observable behaviour, not the synthetic geometry around it — the offset
//! is intentionally not clamped, since without layout any maximum is a guess.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_once() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let body = "<html><body><div id=\"box\"><p>a</p><p>b</p></div></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
});
format!("http://{addr}/")
}
async fn cdp(ctx: &mut CdpContext, id: u64, method: &str, params: Value, session_id: &str) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
resp.result.unwrap_or_else(|| json!({}))
}
/// `awaitPromise` matters here: the scroll event is dispatched from a
/// `setTimeout(..., 0)`, so the probe has to yield before reading the counter.
async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
cdp(
ctx,
id,
"Runtime.evaluate",
json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
session_id,
)
.await
}
async fn setup() -> (CdpContext, String) {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_once().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await;
(ctx, session_id.to_string())
}
async fn probe(ctx: &mut CdpContext, sid: &str, body: &str) -> Value {
let expr = format!(
r#"(() => {{
const el = document.getElementById('box');
let fired = 0;
el.addEventListener('scroll', () => {{ fired++; }});
{body}
return new Promise(r => setTimeout(() => r(JSON.stringify({{
fired, top: el.scrollTop, left: el.scrollLeft,
}})), 0));
}})()"#
);
let v = eval(ctx, 2, &expr, sid).await;
serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn assigning_scroll_top_fires_one_scroll_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTop = 100;").await;
assert_eq!(r["fired"], 1, "scrollTop assignment must fire exactly one scroll event");
assert_eq!(r["top"], 100, "scrollTop must round-trip the assigned value");
}
#[tokio::test(flavor = "current_thread")]
async fn assigning_scroll_left_fires_one_scroll_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollLeft = 40;").await;
assert_eq!(r["fired"], 1, "scrollLeft assignment must fire exactly one scroll event");
assert_eq!(r["left"], 40, "scrollLeft must round-trip the assigned value");
}
/// Re-assigning the same offset is not a scroll, so it must stay silent —
/// otherwise a loader that writes `scrollTop` on every frame re-enters forever.
#[tokio::test(flavor = "current_thread")]
async fn reassigning_the_same_offset_is_silent() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTop = 100; el.scrollTop = 100;").await;
assert_eq!(r["fired"], 1, "only the offset change fires; the repeat must not");
}
/// `scrollTo` moves both axes, and a real browser reports one scroll per
/// movement rather than one per axis. The setters suppress their own event
/// inside `scrollTo`/`scrollBy` so the operation stays a single event.
#[tokio::test(flavor = "current_thread")]
async fn scroll_to_coalesces_both_axes_into_one_event() {
let (mut ctx, sid) = setup().await;
let r = probe(&mut ctx, &sid, "el.scrollTo(30, 60);").await;
assert_eq!(r["fired"], 1, "scrollTo must fire one event, not one per axis");
assert_eq!(r["top"], 60);
assert_eq!(r["left"], 30);
}
/// The lazy-load idiom the fix exists for: a listener that appends more rows
/// when the feed is scrolled. Before the fix this counted 0 and feeds froze.
#[tokio::test(flavor = "current_thread")]
async fn scroll_driven_lazy_loader_advances() {
let (mut ctx, sid) = setup().await;
let v = eval(
&mut ctx,
2,
r#"(() => {
const el = document.getElementById('box');
let batches = 0;
el.addEventListener('scroll', () => {
if (batches >= 3) return;
batches++;
for (let i = 0; i < 5; i++) el.appendChild(document.createElement('p'));
});
el.scrollTop = 500;
return new Promise(r => setTimeout(() => {
el.scrollTop = 1000;
setTimeout(() => r(JSON.stringify({
batches, rows: el.querySelectorAll('p').length,
})), 0);
}, 0));
})()"#,
&sid,
)
.await;
let r = serde_json::from_str::<Value>(v["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(r["batches"], 2, "each forward scroll must reach the loader");
assert_eq!(r["rows"], 12, "2 starting rows + 2 batches of 5");
}
+15 -2
View File
@@ -21,10 +21,21 @@ impl DomTree {
pub fn inner_html(&self, node_id: NodeId) -> String {
let mut buf = String::new();
self.serialize_worklist(self.child_work(node_id), &mut buf);
self.serialize_worklist(self.child_work(self.content_source(node_id)), &mut buf);
buf
}
// The node whose children represent `node_id`'s markup. For a <template>
// that is its contents document, since the parser puts template children
// there rather than under the element (issue #463); for everything else it
// is the node itself.
fn content_source(&self, node_id: NodeId) -> NodeId {
match self.get_node(node_id).map(|n| n.data) {
Some(NodeData::Element { template_contents: Some(contents), .. }) => contents,
_ => node_id,
}
}
// Children as work items in document order (top of the LIFO stack first).
fn child_work(&self, node_id: NodeId) -> Vec<SerializeWork> {
self.children(node_id)
@@ -101,7 +112,9 @@ impl DomTree {
if include_self {
stack.push(SerializeWork::CloseTag(tag.to_string()));
}
for w in self.child_work(node_id) {
// A <template> serializes its contents document, not its
// own (always empty) children — issue #463.
for w in self.child_work(self.content_source(node_id)) {
stack.push(w);
}
}
+125
View File
@@ -579,6 +579,41 @@ impl DomTree {
Self::climb_to_next_sibling(&inner, root, current)
}
/// Returns the node before `current` in document order, without leaving the
/// subtree rooted at `root`. `root` has no predecessor within its own
/// subtree, but it is itself reachable as one — a NodeIterator can return
/// its root, unlike a TreeWalker.
///
/// A NodeIterator applies no subtree pruning (DOM 6.2: FILTER_REJECT
/// behaves as FILTER_SKIP), so unlike the TreeWalker's backward walk the
/// whole step fits here instead of being interleaved with filter calls.
pub fn prev_in_subtree(&self, root: NodeId, current: NodeId) -> Option<NodeId> {
let inner = self.inner.borrow();
if current == root {
return None;
}
let current_node = inner.nodes.get(current.index())?.as_ref()?;
let Some(prev) = current_node.prev_sibling else {
// No previous sibling: the parent immediately precedes `current`.
return current_node.parent;
};
// Otherwise it is the previous sibling's deepest last descendant.
let mut node_id = prev;
for _ in 0..=inner.nodes.len() {
let node = inner.nodes.get(node_id.index())?.as_ref()?;
match node.last_child {
Some(child) => node_id = child,
None => return Some(node_id),
}
}
// Same defense in depth as the forward walk: a malformed tree must not
// spin here.
None
}
/// Follow `current`'s next sibling, climbing ancestors until one has a next
/// sibling — without stepping outside `root`.
fn climb_to_next_sibling(
@@ -603,6 +638,41 @@ impl DomTree {
None
}
/// The node holding a `<template>` element's contents.
///
/// The parser puts template children in a separate contents document rather
/// than under the element (HTML spec), so this is the only way to reach
/// them. Templates built with `createElement` have no contents node yet, so
/// one is allocated on demand — `.content` must be usable either way.
///
/// Returns `None` for a non-element node.
pub fn template_contents(&self, node_id: NodeId) -> Option<NodeId> {
{
let inner = self.inner.borrow();
let node = inner.nodes.get(node_id.index())?.as_ref()?;
match &node.data {
NodeData::Element { template_contents, .. } => {
if let Some(existing) = *template_contents {
return Some(existing);
}
}
_ => return None,
}
}
// Borrow released above: `new_node` takes its own mutable borrow.
// Matches what the tree sink allocates for a parsed template.
let contents = self.new_node(NodeData::Document);
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(node_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(contents);
return Some(contents);
}
}
None
}
pub fn ancestors(&self, node_id: NodeId) -> Vec<NodeId> {
let inner = self.inner.borrow();
let mut result = Vec::new();
@@ -727,6 +797,38 @@ impl DomTree {
let new_id = self.new_node(node_data);
self.append_child(dest_parent, new_id);
// A <template>'s children hang off a separate contents document, so
// the child walk below never reaches them. Worse, the cloned data
// carries the *source* tree's contents NodeId, which here indexes
// whatever unrelated node occupies that slot. Allocate a real
// contents node and queue the source contents' children into it, so
// the reference is remapped rather than left dangling (issue #463).
let src_contents = {
let inner = self.inner.borrow();
match inner.nodes.get(new_id.index()).and_then(|n| n.as_ref()) {
Some(node) => match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
},
None => None,
}
};
if let Some(src_contents) = src_contents {
let dest_contents = self.new_node(NodeData::Document);
{
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(new_id.index()) {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(dest_contents);
}
}
}
// Onto the same stack, so nested templates stay iterative.
for child_id in source.children(src_contents).into_iter().rev() {
stack.push((dest_contents, child_id));
}
}
for child_id in source.children(src_id).into_iter().rev() {
stack.push((new_id, child_id));
}
@@ -1071,6 +1173,29 @@ mod tests {
assert_eq!(tree.next_after_subtree(root, root), None);
}
#[test]
fn test_prev_in_subtree_reverses_document_order() {
// root > [first > nested, second]; document order is root, first,
// nested, second, so the reverse walk must retrace it exactly.
let tree = DomTree::new();
let root = tree.new_node(NodeData::Text { contents: "root".into() });
let first = tree.new_node(NodeData::Text { contents: "first".into() });
let nested = tree.new_node(NodeData::Text { contents: "nested".into() });
let second = tree.new_node(NodeData::Text { contents: "second".into() });
tree.append_child(tree.document(), root);
tree.append_child(root, first);
tree.append_child(first, nested);
tree.append_child(root, second);
// Previous sibling's deepest last descendant, not the sibling itself.
assert_eq!(tree.prev_in_subtree(root, second), Some(nested));
assert_eq!(tree.prev_in_subtree(root, nested), Some(first));
// No previous sibling: the parent precedes it, and root is returnable.
assert_eq!(tree.prev_in_subtree(root, first), Some(root));
// Root has no predecessor inside its own subtree.
assert_eq!(tree.prev_in_subtree(root, root), None);
}
// Builds a chain of `depth` nested <div> elements under the document and
// returns (outermost_div, innermost_div). Depth this large overflows a
// recursive tree walk and aborts the process, so it guards the iterative
+202 -35
View File
@@ -1216,6 +1216,8 @@ class Element extends Node {
if (this.localName === "svg") return "http://www.w3.org/2000/svg";
return "http://www.w3.org/1999/xhtml";
}
// `inner_html` resolves a <template> to its contents document on the Rust
// side (issue #463), so this needs no template special case.
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
set innerHTML(v) {
if (this.localName === 'template') {
@@ -1254,6 +1256,18 @@ class Element extends Node {
// infinite retry loop (issue #210).
const tag = this.localName;
if (tag === 'template') {
// Back the fragment with the node's real template contents (issue #463).
// The parser stores template children in a separate contents document
// instead of under the element, so without this the getter handed back a
// fabricated empty fragment and the parsed markup was unreachable.
// `template_contents` allocates one on demand for created templates.
const nid = +_dom("template_contents", this._nid);
if (nid >= 0) {
// Cache by node id so `.content` keeps a stable identity across reads —
// frameworks stash the fragment and compare it later.
if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid));
return _cache.get(nid);
}
if (!this._templateContent) this._templateContent = document.createDocumentFragment();
return this._templateContent;
}
@@ -1929,17 +1943,15 @@ class Element extends Node {
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
}
if (typeof el.onload === 'function') {
try { el.onload(); } catch(e) {}
} else {
var onloadAttr = el.getAttribute('onload');
if (onloadAttr) try { (0, eval)(onloadAttr); } catch(e) {}
}
// Dispatch through the element so the onload property/attribute and any
// addEventListener('load', ...) listeners all run. Calling el.onload()
// directly bypasses listeners registered via addEventListener.
el.dispatchEvent(new Event('load'));
}).catch(() => {
el._iframeDoc = new _IframeDocument('<!DOCTYPE html><html><head></head><body></body></html>', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
if (typeof el.onload === 'function') try { el.onload(); } catch(e) {}
el.dispatchEvent(new Event('load'));
});
}
get contentDocument() {
@@ -2110,13 +2122,29 @@ class Element extends Node {
const t = this.tagName;
return t === 'HTML' || t === 'BODY';
}
// No layout engine, so there is no real overflow to scroll. We still track a
// scroll offset so scrollTop/scrollLeft round-trip and the scroll methods
// below can report a position, which is what infinite-scroll code reads back.
// No layout engine, so there is no real overflow to scroll and the offset is
// deliberately NOT clamped: without real geometry any synthetic max is a
// guess, and a max derived from a stub scroll box pins scrollTop at 0, which
// deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll).
// We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll
// event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely
// on that event, and scrollTo/scrollBy below would otherwise be its only source.
get scrollTop() { return this._scrollTop || 0; }
set scrollTop(v) { v = +v; this._scrollTop = Number.isFinite(v) && v > 0 ? v : 0; }
set scrollTop(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
const changed = nv !== (this._scrollTop || 0);
this._scrollTop = nv;
if (changed && !this._scrollSuppress) this._fireScroll();
}
get scrollLeft() { return this._scrollLeft || 0; }
set scrollLeft(v) { v = +v; this._scrollLeft = Number.isFinite(v) && v > 0 ? v : 0; }
set scrollLeft(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
const changed = nv !== (this._scrollLeft || 0);
this._scrollLeft = nv;
if (changed && !this._scrollSuppress) this._fireScroll();
}
getBoundingClientRect() {
globalThis.__obscura_click_target = this;
// documentElement and body span the full viewport. Without this every
@@ -2175,15 +2203,17 @@ class Element extends Node {
set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); }
scrollIntoView() { globalThis.__obscura_click_target = this; }
// scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object.
// Without layout the offset cannot be clamped to a real max, but updating it
// and firing a scroll event lets scroll-driven lazy loaders advance instead
// of throwing "scrollBy is not a function".
// The setters fire a scroll event of their own, so suppress the per-axis ones
// here and emit a single event for the whole movement, the way a real browser
// coalesces one scroll per scroll operation rather than one per axis.
scrollTo(x, y) {
let left, top;
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
else { left = x; top = y; }
this._scrollSuppress = true;
if (left !== undefined) this.scrollLeft = +left || 0;
if (top !== undefined) this.scrollTop = +top || 0;
this._scrollSuppress = false;
this._fireScroll();
}
scroll(x, y) { this.scrollTo(x, y); }
@@ -2191,8 +2221,10 @@ class Element extends Node {
let dl, dt;
if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; }
else { dl = x; dt = y; }
this._scrollSuppress = true;
this.scrollLeft = (this.scrollLeft || 0) + (+dl || 0);
this.scrollTop = (this.scrollTop || 0) + (+dt || 0);
this._scrollSuppress = false;
this._fireScroll();
}
_fireScroll() {
@@ -2637,11 +2669,8 @@ class Document extends Node {
const verdict = this._filter(node);
if (verdict === 1) { this.currentNode = node; return node; }
// FILTER_REJECT skips the node AND its subtree; FILTER_SKIP (and any
// other non-accept value) skips only the node. NodeIterator has no
// pruning at all, so `_rejectIsSkip` keeps it on the plain step.
const step = (verdict === 2 && !this._rejectIsSkip)
? "next_after_subtree"
: "next_in_subtree";
// other non-accept value) skips only the node.
const step = verdict === 2 ? "next_after_subtree" : "next_in_subtree";
node = _wrap(+_dom(step, this.root._nid, node._nid));
}
return null;
@@ -2734,26 +2763,86 @@ class Document extends Node {
lastChild() { return this._traverseChildren('lastChild', 'previousSibling'); },
nextSibling() { return this._traverseSiblings('firstChild', 'nextSibling'); },
previousSibling() { return this._traverseSiblings('lastChild', 'previousSibling'); },
// DOM 6.1 "parentNode" (issue #475). The old version looked only at the
// immediate parent, so it couldn't climb past a skipped ancestor; it also
// excluded `root` as a result yet stepped to root's own parent when
// currentNode was root, returning a node OUTSIDE the walker's subtree.
// The loop's `node !== this.root` guard is what keeps the walk inside
// root while still allowing root itself to be returned.
parentNode() {
let parent = this.currentNode.parentNode;
if (parent && parent !== this.root && this._accept(parent)) {
this.currentNode = parent;
return parent;
let node = this.currentNode;
while (node && node !== this.root) {
node = node.parentNode;
if (node && this._accept(node)) { this.currentNode = node; return node; }
}
return null;
},
};
return walker;
}
// A real NodeIterator (DOM 6.2), not a TreeWalker in disguise (issue #467).
// The two differ in more than naming: an iterator's pointer starts *before*
// its root, so the first nextNode() returns the root itself, and it exposes
// referenceNode/pointerBeforeReferenceNode/detach rather than a TreeWalker's
// currentNode and child/sibling movers.
createNodeIterator(root, whatToShow, filter) {
// Shares the TreeWalker implementation, but DOM 6.2 gives NodeIterator no
// subtree pruning: FILTER_REJECT behaves exactly as FILTER_SKIP there.
const iterator = this.createTreeWalker(root, whatToShow, filter);
iterator._rejectIsSkip = true;
return iterator;
// whatToShow is unsigned long; default SHOW_ALL only when the arg is
// omitted. An explicit 0 (show nothing) must stay 0, not become SHOW_ALL.
whatToShow = (whatToShow === undefined) ? 0xFFFFFFFF : (whatToShow >>> 0);
return {
root: root,
referenceNode: root,
pointerBeforeReferenceNode: true,
whatToShow: whatToShow,
filter: filter || null,
// NodeIterator prunes nothing: FILTER_REJECT behaves as FILTER_SKIP, so
// unlike the TreeWalker only "accepted or not" matters here.
_accept(node) {
if (!((whatToShow >> (node.nodeType - 1)) & 1)) return false;
if (this.filter) {
if (typeof this.filter === 'function') return this.filter(node) === 1;
if (this.filter.acceptNode) return this.filter.acceptNode(node) === 1;
}
return true;
},
// DOM 6.2 "traverse". The pointer sits either before or after
// referenceNode, which is why reversing direction re-yields the current
// node instead of stepping over it.
_traverse(forward) {
let node = this.referenceNode;
let before = this.pointerBeforeReferenceNode;
for (;;) {
if (forward === before) {
// Consume the pointer's side without moving: it flips to the other
// side of the node it already references.
before = !before;
} else {
const step = forward ? "next_in_subtree" : "prev_in_subtree";
const next = _wrap(+_dom(step, this.root._nid, node._nid));
// A failed traversal leaves referenceNode and the pointer
// untouched, so the iterator can be resumed in either direction.
if (!next) return null;
node = next;
}
if (this._accept(node)) break;
}
this.referenceNode = node;
this.pointerBeforeReferenceNode = before;
return node;
},
nextNode() { return this._traverse(true); },
previousNode() { return this._traverse(false); },
// Legacy no-op since DOM4, but older library code still calls it and
// used to hit "detach is not a function".
detach() {},
};
}
getSelection() { return this.defaultView ? _selectionFor(this) : null; }
get activeElement() { return globalThis.__obscura_focused || this.body; }
// The element that scrolls the viewport, and where the page offset lives
// (issue #468). Standards mode, so documentElement — quirks mode would be
// body, but we never parse in quirks mode.
get scrollingElement() { return this.documentElement; }
get implementation() {
const ownerDoc = this;
return {
@@ -6152,9 +6241,33 @@ class _IframeDocument {
get implementation() { return document.implementation; }
get styleSheets() { return []; }
addEventListener() {}
removeEventListener() {}
dispatchEvent() { return true; }
addEventListener(type, listener) {
if (typeof listener !== 'function') return;
if (!this._listeners) this._listeners = Object.create(null);
const list = this._listeners[type] || (this._listeners[type] = []);
if (!list.includes(listener)) list.push(listener);
}
removeEventListener(type, listener) {
const list = this._listeners && this._listeners[type];
if (!list) return;
const index = list.indexOf(listener);
if (index !== -1) list.splice(index, 1);
}
dispatchEvent(event) {
const type = event && event.type;
if (!type) return true;
const list = this._listeners && this._listeners[type];
if (list) {
for (const listener of list.slice()) {
try { listener.call(this, event); } catch (error) { console.error(error); }
}
}
const handler = this['on' + type];
if (typeof handler === 'function') {
try { handler.call(this, event); } catch (error) { console.error(error); }
}
return !event.defaultPrevented;
}
write(html) {
if (this._body) this._body.innerHTML += html;
@@ -7152,9 +7265,63 @@ URL.revokeObjectURL = function(url) {
delete globalThis.__blobStore[url];
};
globalThis.scrollTo = function(x, y) {};
globalThis.scrollBy = function(x, y) {};
globalThis.scroll = function(x, y) {};
// Window-level scrolling (issue #468). #431 gave elements functional
// scrollTop/scrollLeft plus scroll methods, but left these three as no-ops, so
// the dominant infinite-scroll idiom -- window.scrollTo(0, body.scrollHeight),
// window.scrollBy(0, 500), then a window 'scroll' listener -- did nothing at
// all: the offset never moved and no event ever fired.
//
// The page offset is stored on the scrolling element rather than in separate
// window state, so window.scrollY and document.scrollingElement.scrollTop are
// two views of one value, which is what pages assume. As with #431 there is no
// layout, so the offset still cannot be clamped to a real maximum.
function _scrollRoot() {
const doc = globalThis.document;
return (doc && doc.scrollingElement) || null;
}
function _windowScroll(x, y, relative) {
const root = _scrollRoot();
if (!root) return;
let left, top;
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
else { left = x; top = y; }
if (left !== undefined) {
root.scrollLeft = (relative ? (root.scrollLeft || 0) : 0) + (+left || 0);
}
if (top !== undefined) {
root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0);
}
// Async, matching the element path #431 added. Dispatched at the document
// AND the window: a page scroll event reaches both in Chrome, but
// Document.dispatchEvent here runs only its own listeners and does not
// propagate, so firing once would strand half the listeners.
setTimeout(() => {
try {
const doc = globalThis.document;
if (doc) doc.dispatchEvent(new Event('scroll', { bubbles: false }));
globalThis.dispatchEvent(new Event('scroll', { bubbles: false }));
} catch (e) {}
}, 0);
}
globalThis.scrollTo = function(x, y) { _windowScroll(x, y, false); };
globalThis.scrollBy = function(x, y) { _windowScroll(x, y, true); };
globalThis.scroll = function(x, y) { _windowScroll(x, y, false); };
_markNative(globalThis.scrollTo);
_markNative(globalThis.scrollBy);
_markNative(globalThis.scroll);
// Read-only accessors, as on a real Window: assigning window.scrollY does not
// scroll the page. These replace the hard-coded 0 data properties defined
// earlier, so they must stay after them.
for (const [name, offset] of [
['scrollX', 'scrollLeft'], ['pageXOffset', 'scrollLeft'],
['scrollY', 'scrollTop'], ['pageYOffset', 'scrollTop'],
]) {
Object.defineProperty(globalThis, name, {
configurable: true,
enumerable: true,
get() { const root = _scrollRoot(); return root ? (root[offset] || 0) : 0; },
});
}
globalThis.focus = function() {}; _markNative(globalThis.focus);
globalThis.blur = function() {}; _markNative(globalThis.blur);
globalThis.print = function() {}; _markNative(globalThis.print);
+18
View File
@@ -275,6 +275,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
// Reverse document order within a subtree, for NodeIterator's backward
// walk (which prunes nothing, so the whole step fits in the DOM layer).
"prev_in_subtree" => {
let root = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let current = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
dom.prev_in_subtree(root, current)
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
// Step past a whole subtree rather than into it: NodeFilter.FILTER_REJECT
// prunes the rejected node's descendants, unlike FILTER_SKIP.
"next_after_subtree" => {
@@ -392,6 +401,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
});
"true".into()
}
// A <template>'s children live in a separate contents document, so this
// is the only route to them from JS. Allocates one on demand for
// templates built via createElement.
"template_contents" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.template_contents(NodeId::new(nid))
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"create_document_fragment" => {
dom.new_node(NodeData::Document).index().to_string()
}
+380 -1
View File
@@ -859,6 +859,18 @@ impl ObscuraJsRuntime {
.map_err(|e| format!("Event loop error: {}", e))
}
/// Whether the serialized dynamic-script queue is still fetching or
/// evaluating a script. The queue variables are global lexicals rather
/// than window properties, so page code cannot overwrite this state.
pub fn has_pending_dynamic_scripts(&mut self) -> bool {
self.evaluate(
"typeof __dynScriptBusy !== 'undefined' && (__dynScriptBusy || __dynScriptQueue.length > 0)",
)
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
/// Arm a hard wall-clock backstop on synchronous V8 work. A page stuck in a
/// synchronous loop or a microtask storm pins the OS thread inside V8, so
/// `tokio::time::timeout` (which can only cancel at await points) never
@@ -1513,6 +1525,75 @@ mod tests {
/// Issue #461: NodeIterator has no subtree pruning — DOM 6.2 says
/// FILTER_REJECT behaves as FILTER_SKIP there. The shared walker must not
/// Issue #475: parentNode() must never surface a node above `root`. With
/// currentNode at root, the old guard stepped to root's own parent and
/// returned it — escaping the walker's subtree entirely.
#[test]
fn tree_walker_parent_node_does_not_escape_above_root() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const escaped = w.parentNode();
return [escaped, w.currentNode.id];
"#,
)
.unwrap();
// No parent within the subtree, and currentNode stays put at root.
assert_eq!(result, serde_json::json!([null, "root"]));
}
/// Issue #475: when the accepted ancestor is `root` itself, parentNode()
/// returns it and moves currentNode there — the old `parent !== root` guard
/// wrongly excluded it.
#[test]
fn tree_walker_parent_node_can_return_the_root() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
w.currentNode = root.querySelector('a');
const p = w.parentNode();
return [p ? p.id : null, w.currentNode === root];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["root", true]));
}
/// Issue #475: parentNode() climbs past a skipped ancestor to the first
/// accepted one, instead of stopping at the immediate parent.
#[test]
fn tree_walker_parent_node_climbs_past_skipped_ancestors() {
let mut rt = setup_runtime(
r#"<div id="root"><main id="m"><section><a></a></section></main></div>"#,
);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(n) {
return n.tagName === 'SECTION'
? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT;
}
});
w.currentNode = root.querySelector('a');
const p = w.parentNode();
return p ? p.id : null;
"#,
)
.unwrap();
// <a>'s parent <section> is skipped, so <main> is the first accepted
// ancestor — not null, and not the immediate <section>.
assert_eq!(result, serde_json::json!("m"));
}
/// leak TreeWalker's pruning into it.
#[test]
fn node_iterator_treats_filter_reject_as_skip() {
@@ -1538,7 +1619,305 @@ mod tests {
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["P", "A"]));
// The rejected <section> is skipped but not pruned, so <p> still shows.
// The leading root is #467: an iterator yields the node it is rooted at.
assert_eq!(result, serde_json::json!(["DIV", "P", "A"]));
}
/// Issue #467: a NodeIterator starts *before* its root, so the first
/// nextNode() returns the root itself. Aliasing createTreeWalker silently
/// dropped exactly the element the iterator was rooted at.
#[test]
fn node_iterator_yields_the_root_node_first() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const seen = [];
let node;
while ((node = it.nextNode())) seen.push(node.tagName);
return seen;
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["DIV", "A"]));
}
/// Issue #467: the NodeIterator interface surface, and that TreeWalker-only
/// members are not exposed on it.
#[test]
fn node_iterator_exposes_its_own_interface() {
let mut rt = setup_runtime(r#"<div id="root"><a></a></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const before = [it.referenceNode === root, it.pointerBeforeReferenceNode];
it.nextNode();
return [
before,
typeof it.detach,
it.detach() === undefined,
typeof it.previousNode,
it.root === root,
it.whatToShow,
// TreeWalker-only members must not leak onto a NodeIterator.
typeof it.currentNode,
typeof it.firstChild,
typeof it.parentNode,
// The pointer advanced past the root it just returned.
[it.referenceNode.tagName, it.pointerBeforeReferenceNode],
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
[true, true],
"function",
true,
"function",
true,
1,
"undefined",
"undefined",
"undefined",
["DIV", false]
])
);
}
/// Issue #467: previousNode() retraces the iterator, and the root is the
/// last node it yields going backwards.
#[test]
fn node_iterator_previous_node_retraces_the_walk() {
let mut rt = setup_runtime(r#"<div id="root"><a><b></b></a><c></c></div>"#);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
const forward = [];
let node;
while ((node = it.nextNode())) forward.push(node.tagName);
const backward = [];
while ((node = it.previousNode())) backward.push(node.tagName);
return [forward, backward];
"#,
)
.unwrap();
// Forward ends on <c>; going back re-yields <c> (the pointer sits after
// it), then the rest in reverse, root included.
assert_eq!(
result,
serde_json::json!([
["DIV", "A", "B", "C"],
["C", "B", "A", "DIV"]
])
);
}
/// Issue #463: `<template>` contents are parsed into the node's
/// `template_contents` document, but no op exposed it, so `.content` handed
/// back a fabricated empty fragment and the parsed markup was unreachable.
#[test]
fn template_content_exposes_parsed_markup() {
let mut rt = setup_runtime(
r#"<body><template id="t"><p class="row">a</p><p class="row">b</p></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
return [
t.content.childNodes.length,
t.content.querySelectorAll('.row').length,
t.content.firstElementChild.textContent,
t.innerHTML,
t.content.nodeType,
t.content instanceof DocumentFragment,
// Identity is stable: frameworks stash `.content` and reuse it.
t.content === t.content,
// The children stay off the element itself, per the HTML spec.
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
2,
2,
"a",
r#"<p class="row">a</p><p class="row">b</p>"#,
11,
true,
true,
0
])
);
}
/// Issue #463: the same must hold for a template that arrives via innerHTML
/// rather than the initial document parse — that is how most frameworks
/// inject templates.
#[test]
fn template_content_works_for_templates_added_via_inner_html() {
let mut rt = setup_runtime(r#"<body><div id="host"></div></body>"#);
let result = rt
.evaluate(
r#"
const host = document.getElementById('host');
host.innerHTML = '<template id="t2"><li class="item">x</li></template>';
const t = document.getElementById('t2');
const stamped = t.content.cloneNode(true);
host.appendChild(stamped);
return [
t.content.childNodes.length,
t.content.querySelector('.item').textContent,
host.querySelectorAll('li.item').length,
];
"#,
)
.unwrap();
// cloneNode(true) of the content is the canonical stamping idiom.
assert_eq!(result, serde_json::json!([1, "x", 1]));
}
/// Issue #463: a template built with createElement has no parsed contents,
/// so `.content` must allocate a backing fragment on demand and round-trip
/// through innerHTML.
#[test]
fn template_content_round_trips_for_created_templates() {
let mut rt = setup_runtime(r#"<body></body>"#);
let result = rt
.evaluate(
r#"
const t = document.createElement('template');
t.innerHTML = '<span class="s">hi</span>';
return [
t.content.childNodes.length,
t.content.querySelector('.s').textContent,
t.innerHTML,
t.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([1, "hi", r#"<span class="s">hi</span>"#, 0])
);
}
/// Issue #463: serializing a `<template>` must emit its contents, or the
/// markup silently disappears from outerHTML/innerHTML round-trips — and
/// `cloneNode(true)`, which round-trips through outer_html, yields an empty
/// template.
#[test]
fn template_contents_survive_serialization_and_clone() {
let mut rt = setup_runtime(
r#"<body><template id="t"><li class="item">x</li></template></body>"#,
);
let result = rt
.evaluate(
r#"
const t = document.getElementById('t');
const clone = t.cloneNode(true);
return [
t.outerHTML,
document.body.innerHTML,
clone.content.childNodes.length,
clone.content.querySelector('.item').textContent,
// The clone's contents are its own, not shared with the original.
(clone.content.firstElementChild === t.content.firstElementChild),
];
"#,
)
.unwrap();
let expected = r#"<template id="t"><li class="item">x</li></template>"#;
assert_eq!(
result,
serde_json::json!([expected, expected, 1, "x", false])
);
}
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
/// dominant infinite-scroll idiom never advanced the page offset.
#[test]
fn window_scroll_methods_move_the_page_offset() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate(
r#"
const scrolled = window.scrollTo(0, 500);
const afterTo = [window.scrollX, window.scrollY];
window.scrollBy(0, 200);
const afterBy = [window.pageXOffset, window.pageYOffset];
window.scrollTo({ left: 10, top: 40 });
const afterOptions = [window.scrollX, window.scrollY];
window.scroll(5, 5);
const afterScroll = [window.scrollX, window.scrollY];
// Negative offsets clamp to 0, as they do for elements.
window.scrollTo(0, -100);
return [afterTo, afterBy, afterOptions, afterScroll, window.scrollY];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([[0, 500], [0, 700], [10, 40], [5, 5], 0])
);
}
/// Issue #468: the page offset is one value, readable and writable through
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
#[test]
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate(
r#"
const isDocEl = document.scrollingElement === document.documentElement;
window.scrollTo(0, 300);
// Written through the window, read through the element...
const viaElement = document.scrollingElement.scrollTop;
// ...and the reverse.
document.scrollingElement.scrollTop = 90;
return [isDocEl, viaElement, window.scrollY, window.pageYOffset];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([true, 300, 90, 90]));
}
/// Issue #468: a scroll event must reach listeners on both the window and
/// the document — that is the signal lazy loaders wait for.
#[tokio::test(flavor = "current_thread")]
async fn window_scroll_fires_a_scroll_event() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener('scroll', () => win++);
document.addEventListener('scroll', () => doc++);
window.scrollBy(0, 400);
setTimeout(() => resolve([win, doc, window.scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
}
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so