Improve modern layout and viewport rendering
This commit is contained in:
1 parent
aff0172987
commit
10b8492116
22 files changed
+5978
-450
No files matched your search
@@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags;
|
||||
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
|
||||
// render feature (which enables obscura-render/paint) is compiled in.
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_render::screenshot_png;
|
||||
pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
|
||||
+155
-27
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
|
||||
pub timestamp: f64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub struct LayoutCache {
|
||||
rects: std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>,
|
||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||
sticky: obscura_render::StickyLayout,
|
||||
content_size: (f32, f32),
|
||||
}
|
||||
|
||||
pub struct ObscuraState {
|
||||
pub dom: Option<DomTree>,
|
||||
pub url: String,
|
||||
@@ -104,14 +112,17 @@ pub struct ObscuraState {
|
||||
// Network.requestWillBeSent / responseReceived for them (issue #406).
|
||||
pub js_network_events: Vec<JsNetworkEvent>,
|
||||
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
|
||||
/// cache is computed on first geometry read and cleared on navigation. It is
|
||||
/// not invalidated on every DOM mutation yet (a follow-up), so geometry read
|
||||
/// mid-script may lag a frame; reading after settle is reliable.
|
||||
/// cache is computed on first geometry read and cleared on navigation,
|
||||
/// viewport changes, and DOM/style mutations that can affect layout.
|
||||
#[cfg(feature = "render")]
|
||||
pub layout_cache:
|
||||
Option<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
|
||||
pub layout_cache: Option<LayoutCache>,
|
||||
#[cfg(feature = "render")]
|
||||
pub viewport: (f32, f32),
|
||||
/// Root scrolling offset in CSS pixels. With render enabled this is
|
||||
/// clamped against the cached document overflow and is the single source
|
||||
/// read by CSSOM geometry and screenshot paint.
|
||||
#[cfg(feature = "render")]
|
||||
pub scroll_offset: (f32, f32),
|
||||
}
|
||||
|
||||
impl ObscuraState {
|
||||
@@ -141,6 +152,8 @@ impl ObscuraState {
|
||||
layout_cache: None,
|
||||
#[cfg(feature = "render")]
|
||||
viewport: (1280.0, 720.0),
|
||||
#[cfg(feature = "render")]
|
||||
scroll_offset: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,8 +193,26 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
|
||||
}
|
||||
|
||||
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
|
||||
let gs = state.borrow::<SharedState>().clone();
|
||||
let gs = gs.borrow();
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
#[cfg(feature = "render")]
|
||||
if matches!(
|
||||
cmd.as_str(),
|
||||
"set_attribute"
|
||||
| "remove_attribute"
|
||||
| "append_child"
|
||||
| "remove_child"
|
||||
| "insert_before"
|
||||
| "set_inner_html"
|
||||
| "set_inner_html_context"
|
||||
| "set_text_content"
|
||||
) {
|
||||
// Any attribute can participate in an author selector, so even a
|
||||
// seemingly non-geometric attribute may change computed layout.
|
||||
// Detached-node creation and every read-only op deliberately skip this
|
||||
// invalidation; the next geometry/scroll read rebuilds at most once.
|
||||
shared.borrow_mut().layout_cache = None;
|
||||
}
|
||||
let gs = shared.borrow();
|
||||
let dom = match &gs.dom {
|
||||
Some(d) => d,
|
||||
None => return "null".to_string(),
|
||||
@@ -2010,7 +2041,12 @@ pub fn build_extension() -> Extension {
|
||||
// Only registered when the render feature is compiled in. bootstrap.js
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
ops.push(op_layout_geometry());
|
||||
{
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_layout_metrics());
|
||||
ops.push(op_scroll_offset());
|
||||
ops.push(op_scroll_to());
|
||||
}
|
||||
Extension {
|
||||
name: "obscura_dom",
|
||||
ops: std::borrow::Cow::Owned(ops),
|
||||
@@ -2018,35 +2054,127 @@ pub fn build_extension() -> Extension {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> {
|
||||
if state.layout_cache.is_none() {
|
||||
let dom = state.dom.as_ref()?;
|
||||
let laid = obscura_render::layout_dom(dom, state.viewport);
|
||||
let viewport_fixed = laid.viewport_fixed_nodes(dom);
|
||||
let sticky = laid.root_sticky_layout(dom, state.viewport);
|
||||
let content_size = laid.scrolling_content_size(dom, state.viewport);
|
||||
let mut rects = laid.rects;
|
||||
for (&id, rect) in &mut rects {
|
||||
if let Some((tx, ty)) = laid.translates.get(&id) {
|
||||
rect.x += tx;
|
||||
rect.y += ty;
|
||||
}
|
||||
}
|
||||
state.layout_cache = Some(LayoutCache {
|
||||
rects,
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
content_size,
|
||||
});
|
||||
}
|
||||
state.layout_cache.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
||||
let viewport = state.viewport;
|
||||
let content_size = ensure_layout_cache(state)
|
||||
.map(|cache| cache.content_size)
|
||||
.unwrap_or(viewport);
|
||||
let max_x = (content_size.0 - viewport.0).max(0.0);
|
||||
let max_y = (content_size.1 - viewport.1).max(0.0);
|
||||
let x = if requested.0.is_finite() {
|
||||
requested.0.clamp(0.0, max_x)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let y = if requested.1.is_finite() {
|
||||
requested.1.clamp(0.0, max_y)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
state.scroll_offset = (x, y);
|
||||
state.scroll_offset
|
||||
}
|
||||
|
||||
/// Real border-box geometry for an element from the obscura-render layout
|
||||
/// cache. The cache is computed lazily on first read and cleared on navigation
|
||||
/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or
|
||||
/// an empty string when the node has no box. Feature-gated.
|
||||
/// (see `set_dom`). Coordinates are viewport-relative after the shared root
|
||||
/// scroll offset, except for viewport-fixed subtrees. Returns JSON
|
||||
/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the
|
||||
/// node has no box. Feature-gated.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
{
|
||||
let mut gs = shared.borrow_mut();
|
||||
if gs.layout_cache.is_none() {
|
||||
if let Some(dom) = &gs.dom {
|
||||
let viewport = gs.viewport;
|
||||
gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects);
|
||||
}
|
||||
}
|
||||
}
|
||||
let gs = shared.borrow();
|
||||
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||
if let Some(rect) = gs
|
||||
.layout_cache
|
||||
.as_ref()
|
||||
.and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
|
||||
{
|
||||
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||
let mut gs = shared.borrow_mut();
|
||||
let scroll = gs.scroll_offset;
|
||||
let viewport = gs.viewport;
|
||||
if let Some(cache) = ensure_layout_cache(&mut gs) {
|
||||
let Some(rect) = cache.rects.get(&nid) else {
|
||||
return String::new();
|
||||
};
|
||||
let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll);
|
||||
let (x, y) = if cache.viewport_fixed.contains(&nid) {
|
||||
(rect.x, rect.y)
|
||||
} else {
|
||||
(
|
||||
rect.x + sticky_offset.0 - scroll.0,
|
||||
rect.y + sticky_offset.1 - scroll.1,
|
||||
)
|
||||
};
|
||||
let viewport_fixed = cache.viewport_fixed.contains(&nid);
|
||||
return format!(
|
||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
|
||||
rect.x, rect.y, rect.width, rect.height
|
||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}",
|
||||
x, y, rect.width, rect.height, viewport_fixed
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in
|
||||
/// render builds; default scraping builds retain their deliberately unbounded
|
||||
/// synthetic scrolling behavior.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_layout_metrics(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let viewport = gs.viewport;
|
||||
let content = ensure_layout_cache(&mut gs)
|
||||
.map(|cache| cache.content_size)
|
||||
.unwrap_or(viewport);
|
||||
format!(
|
||||
"{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
|
||||
content.0, content.1, viewport.0, viewport.1
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_scroll_offset(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let requested = gs.scroll_offset;
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, requested);
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use obscura_dom::DomTree;
|
||||
pub use deno_core::v8::IsolateHandle;
|
||||
|
||||
use crate::module_loader::ObscuraModuleLoader;
|
||||
#[cfg(feature = "render")]
|
||||
use crate::ops::clamp_scroll_offset;
|
||||
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
|
||||
|
||||
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
||||
@@ -195,6 +197,7 @@ impl ObscuraJsRuntime {
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
gs.layout_cache = None;
|
||||
gs.scroll_offset = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +288,12 @@ impl ObscuraJsRuntime {
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.viewport = (width as f32, height as f32);
|
||||
state.layout_cache = None;
|
||||
}
|
||||
let _ = self.runtime.execute_script(
|
||||
"<set-viewport>",
|
||||
format!(
|
||||
@@ -294,11 +303,22 @@ impl ObscuraJsRuntime {
|
||||
if(globalThis.visualViewport){{\
|
||||
globalThis.visualViewport.width={width};\
|
||||
globalThis.visualViewport.height={height};\
|
||||
}}\
|
||||
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
|
||||
globalThis.__obscura_recompute_intersections();\
|
||||
}}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Current clamped root scroll offset shared by CSSOM geometry and paint.
|
||||
#[cfg(feature = "render")]
|
||||
pub fn scroll_offset(&self) -> (f32, f32) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let requested = state.scroll_offset;
|
||||
clamp_scroll_offset(&mut state, requested)
|
||||
}
|
||||
|
||||
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
|
||||
/// have been set. Must be called once per page setup, after all set_* methods.
|
||||
pub fn run_page_init(&mut self) {
|
||||
@@ -1871,6 +1891,146 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_frame_requires_a_callable_callback() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
try {
|
||||
requestAnimationFrame(null);
|
||||
return [false, ""];
|
||||
} catch (error) {
|
||||
return [error instanceof TypeError, error.name];
|
||||
}
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::json!([true, "TypeError"]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn animation_frames_are_ordered_batches_with_rendering_timestamps() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-order",
|
||||
r#"
|
||||
globalThis.__rafEvents = [];
|
||||
globalThis.__rafStamps = [];
|
||||
Promise.resolve().then(() => __rafEvents.push("microtask-before"));
|
||||
setTimeout(() => __rafEvents.push("timer"), 1);
|
||||
requestAnimationFrame((timestamp) => {
|
||||
__rafEvents.push("raf-a");
|
||||
__rafStamps.push(timestamp);
|
||||
Promise.resolve().then(() => __rafEvents.push("microtask-in-raf"));
|
||||
requestAnimationFrame((nextTimestamp) => {
|
||||
__rafEvents.push("raf-next");
|
||||
__rafStamps.push(nextTimestamp);
|
||||
});
|
||||
});
|
||||
requestAnimationFrame((timestamp) => {
|
||||
__rafEvents.push("raf-b");
|
||||
__rafStamps.push(timestamp);
|
||||
});
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(150).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"[
|
||||
__rafEvents,
|
||||
__rafStamps.length,
|
||||
__rafStamps[0] === __rafStamps[1],
|
||||
__rafStamps[2] > __rafStamps[1]
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
[
|
||||
"microtask-before",
|
||||
"timer",
|
||||
"raf-a",
|
||||
"raf-b",
|
||||
"microtask-in-raf",
|
||||
"raf-next"
|
||||
],
|
||||
3,
|
||||
true,
|
||||
true
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancel_animation_frame_removes_pending_and_current_batch_callbacks() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-cancel",
|
||||
r#"
|
||||
globalThis.__rafEvents = [];
|
||||
const pending = requestAnimationFrame(() => __rafEvents.push("pending"));
|
||||
cancelAnimationFrame(pending);
|
||||
let sameBatch;
|
||||
requestAnimationFrame(() => {
|
||||
__rafEvents.push("first");
|
||||
cancelAnimationFrame(sameBatch);
|
||||
});
|
||||
sameBatch = requestAnimationFrame(() => __rafEvents.push("same-batch"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__rafEvents").unwrap(),
|
||||
serde_json::json!(["first"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn self_requeueing_animation_frame_yields_to_timer_tasks() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-yield",
|
||||
r#"
|
||||
globalThis.__rafCount = 0;
|
||||
globalThis.__rafStopped = false;
|
||||
globalThis.__timerAfterAnimation = false;
|
||||
let frameId = 0;
|
||||
function frame() {
|
||||
__rafCount++;
|
||||
frameId = requestAnimationFrame(frame);
|
||||
}
|
||||
frameId = requestAnimationFrame(frame);
|
||||
setTimeout(() => {
|
||||
cancelAnimationFrame(frameId);
|
||||
__rafStopped = true;
|
||||
}, 55);
|
||||
setTimeout(() => {
|
||||
__timerAfterAnimation = true;
|
||||
}, 65);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(200).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate("[__rafCount, __rafStopped, __timerAfterAnimation]")
|
||||
.unwrap();
|
||||
let values = result.as_array().unwrap();
|
||||
let frame_count = values[0].as_u64().unwrap();
|
||||
assert!(
|
||||
(2..=5).contains(&frame_count),
|
||||
"expected a few paced animation frames before cancellation, got {frame_count}"
|
||||
);
|
||||
assert_eq!(values[1], serde_json::json!(true));
|
||||
assert_eq!(values[2], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_url() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
@@ -1892,6 +2052,38 @@ mod tests {
|
||||
assert_eq!(count.as_f64().unwrap() as i64, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_supports_matches_capabilities_and_boolean_conditions() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"JSON.stringify([
|
||||
CSS.supports("-webkit-hyphens", "none"),
|
||||
CSS.supports("margin-trim", "inline"),
|
||||
CSS.supports("-moz-orient", "inline"),
|
||||
CSS.supports("color", "rgb(from red r g b)"),
|
||||
CSS.supports("(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b))))"),
|
||||
CSS.supports("display", "grid"),
|
||||
CSS.supports("(display:grid) and (selector(.card > *))"),
|
||||
CSS.supports("not (unknown-engine-prop:value)"),
|
||||
CSS.supports("selector(.card >)"),
|
||||
CSS.supports("selector(:obscura-unknown)"),
|
||||
CSS.supports("selector(.card,)"),
|
||||
CSS.supports("scrollbar-gutter", "stable"),
|
||||
CSS.supports("scrollbar-gutter", "floating"),
|
||||
CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"),
|
||||
CSS.supports("(color:light-dark(red, light-dark(white, black)))"),
|
||||
CSS.supports("color", "light-dark(red)"),
|
||||
CSS.supports("color", "light-dark(red, rgb(1, 2, 3)")
|
||||
])"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_element_by_id() {
|
||||
let mut rt = setup_runtime(r#"<div id="test">Content</div>"#);
|
||||
@@ -2500,6 +2692,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
|
||||
/// dominant infinite-scroll idiom never advanced the page offset.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[test]
|
||||
fn window_scroll_methods_move_the_page_offset() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
||||
@@ -2528,6 +2721,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: the page offset is one value, readable and writable through
|
||||
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[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>"#);
|
||||
@@ -2549,6 +2743,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: a scroll event must reach listeners on both the window and
|
||||
/// the document — that is the signal lazy loaders wait for.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[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>"#);
|
||||
@@ -2571,6 +2766,587 @@ mod tests {
|
||||
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_window_scroll_clamps_and_geometry_is_viewport_relative() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="wide" style="width:600px;height:700px"></div>
|
||||
<div id="target" style="width:20px;height:300px"></div>
|
||||
<div id="fixed" style="position:fixed;left:12px;top:14px;width:30px;height:25px">
|
||||
<span id="fixed-child">fixed</span>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(320.0, 200.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const target = document.getElementById("target");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const fixedChild = document.getElementById("fixed-child");
|
||||
const before = {
|
||||
target: target.getBoundingClientRect(),
|
||||
fixed: fixed.getBoundingClientRect(),
|
||||
fixedChild: fixedChild.getBoundingClientRect(),
|
||||
};
|
||||
window.scrollTo(99999, 99999);
|
||||
const maxX = document.documentElement.scrollWidth - innerWidth;
|
||||
const maxY = document.documentElement.scrollHeight - innerHeight;
|
||||
const after = {
|
||||
target: target.getBoundingClientRect(),
|
||||
fixed: fixed.getBoundingClientRect(),
|
||||
fixedChild: fixedChild.getBoundingClientRect(),
|
||||
};
|
||||
return [
|
||||
innerWidth, innerHeight,
|
||||
document.documentElement.clientWidth,
|
||||
document.documentElement.clientHeight,
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.scrollHeight,
|
||||
window.scrollX, window.scrollY,
|
||||
document.scrollingElement.scrollLeft,
|
||||
document.scrollingElement.scrollTop,
|
||||
maxX, maxY,
|
||||
Math.abs(after.target.left - (before.target.left - maxX)) < 0.01,
|
||||
Math.abs(after.target.top - (before.target.top - maxY)) < 0.01,
|
||||
Math.abs(after.fixed.left - before.fixed.left) < 0.01,
|
||||
Math.abs(after.fixed.top - before.fixed.top) < 0.01,
|
||||
Math.abs(after.fixedChild.left - before.fixedChild.left) < 0.01,
|
||||
Math.abs(after.fixedChild.top - before.fixedChild.top) < 0.01,
|
||||
];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let values = result.as_array().expect("array");
|
||||
assert_eq!(&values[0..4], &serde_json::json!([320, 200, 320, 200]).as_array().unwrap()[..]);
|
||||
let scroll_width = values[4].as_f64().expect("scrollWidth");
|
||||
let scroll_height = values[5].as_f64().expect("scrollHeight");
|
||||
assert!(scroll_width >= 600.0, "scrollWidth was {scroll_width}");
|
||||
assert!(scroll_height >= 1000.0, "scrollHeight was {scroll_height}");
|
||||
assert_eq!(values[6], values[10]);
|
||||
assert_eq!(values[7], values[11]);
|
||||
assert_eq!(values[8], values[10]);
|
||||
assert_eq!(values[9], values[11]);
|
||||
assert!(values[12..].iter().all(|value| value == &serde_json::json!(true)));
|
||||
}
|
||||
|
||||
/// Chromium 150 reference (800x513 CSS-pixel viewport):
|
||||
/// top=[60,20,20,-267], bottom=[448,448,231,31,-496] at the sampled
|
||||
/// root scroll offsets. This keeps sticky distinct from fixed positioning,
|
||||
/// verifies subtree movement, bottom-only sticking, and the containing
|
||||
/// block's lower boundary without depending on a live site.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn root_scroll_sticky_geometry_matches_chromium_constraints() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:40px"></div>
|
||||
<div id="cb" style="box-sizing:border-box;height:900px;padding:10px 12px;border:4px solid #333">
|
||||
<div id="top" style="box-sizing:border-box;position:sticky;top:20px;height:60px;margin:6px">
|
||||
<div id="top-child" style="height:12px"></div>
|
||||
</div>
|
||||
<div style="height:500px"></div>
|
||||
<div id="bottom" style="box-sizing:border-box;position:sticky;bottom:15px;height:50px;margin:5px"></div>
|
||||
</div>
|
||||
<div style="height:700px"></div>
|
||||
<div id="fixed" style="position:fixed;left:600px;top:20px;width:60px;height:60px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(800.0, 513.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const top = document.getElementById("top");
|
||||
const child = document.getElementById("top-child");
|
||||
const bottom = document.getElementById("bottom");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const sample = y => {
|
||||
window.scrollTo(0, y);
|
||||
return [
|
||||
window.scrollY,
|
||||
top.getBoundingClientRect().top,
|
||||
child.getBoundingClientRect().top,
|
||||
bottom.getBoundingClientRect().top,
|
||||
fixed.getBoundingClientRect().top,
|
||||
];
|
||||
};
|
||||
return [sample(0), sample(100), sample(400), sample(600), sample(9999)];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rows = result.as_array().expect("rows");
|
||||
let number = |row: usize, column: usize| {
|
||||
rows[row].as_array().unwrap()[column].as_f64().unwrap()
|
||||
};
|
||||
let close = |actual: f64, expected: f64| {
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.05,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
};
|
||||
|
||||
close(number(0, 1), 60.0);
|
||||
close(number(0, 3), 448.0);
|
||||
close(number(1, 1), 20.0);
|
||||
close(number(1, 2), 20.0);
|
||||
close(number(1, 3), 448.0);
|
||||
close(number(2, 1), 20.0);
|
||||
close(number(2, 3), 231.0);
|
||||
close(number(3, 1), 20.0);
|
||||
close(number(3, 3), 31.0);
|
||||
close(number(4, 0), 1127.0);
|
||||
close(number(4, 1), -267.0);
|
||||
close(number(4, 2), -267.0);
|
||||
close(number(4, 3), -496.0);
|
||||
for row in 0..rows.len() {
|
||||
close(number(row, 4), 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chromium 150 horizontal reference for the same constraint algorithm:
|
||||
/// the sticky subtree pins at x=20, remains distinct from fixed, then
|
||||
/// leaves with its 500px containing block at the right boundary.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn root_scroll_sticky_supports_the_inline_axis() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="box-sizing:border-box;margin-left:40px;width:500px;height:100px;padding:10px;border:4px solid">
|
||||
<div id="sticky" style="box-sizing:border-box;position:sticky;left:20px;width:60px;height:30px;margin:6px">
|
||||
<div id="child" style="width:10px;height:10px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:1600px;height:600px"></div>
|
||||
<div id="fixed" style="position:fixed;left:20px;top:100px;width:60px;height:30px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(800.0, 513.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const sticky = document.getElementById("sticky");
|
||||
const child = document.getElementById("child");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const sample = x => {
|
||||
window.scrollTo(x, 0);
|
||||
return [
|
||||
window.scrollX,
|
||||
sticky.getBoundingClientRect().left,
|
||||
child.getBoundingClientRect().left,
|
||||
fixed.getBoundingClientRect().left,
|
||||
];
|
||||
};
|
||||
return [sample(0), sample(100), sample(400), sample(800)];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rows = result.as_array().unwrap();
|
||||
let expected = [
|
||||
[0.0, 60.0, 60.0, 20.0],
|
||||
[100.0, 20.0, 20.0, 20.0],
|
||||
[400.0, 20.0, 20.0, 20.0],
|
||||
[800.0, -340.0, -340.0, 20.0],
|
||||
];
|
||||
for (row, expected) in rows.iter().zip(expected) {
|
||||
for (actual, expected) in row.as_array().unwrap().iter().zip(expected) {
|
||||
let actual = actual.as_f64().unwrap();
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.05,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="box" style="height:300px;width:40px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const box = document.getElementById("box");
|
||||
const before = [
|
||||
document.documentElement.scrollHeight,
|
||||
box.getBoundingClientRect().height,
|
||||
];
|
||||
box.setAttribute("style", "height:900px;width:80px");
|
||||
const after = [
|
||||
document.documentElement.scrollHeight,
|
||||
box.getBoundingClientRect().height,
|
||||
box.getBoundingClientRect().width,
|
||||
];
|
||||
return [before, after];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let values = result.as_array().expect("result");
|
||||
let before = values[0].as_array().expect("before");
|
||||
let after = values[1].as_array().expect("after");
|
||||
assert!(after[0].as_f64().unwrap() > before[0].as_f64().unwrap());
|
||||
assert_eq!(before[1].as_f64(), Some(300.0));
|
||||
assert_eq!(after[1].as_f64(), Some(900.0));
|
||||
assert_eq!(after[2].as_f64(), Some(80.0));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn rendered_window_scroll_events_require_actual_movement() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:1000px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(320.0, 200.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let moved = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
window.scrollTo(0, 100);
|
||||
setTimeout(() => resolve([win, doc, window.scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 100]));
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 99999)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let no_op = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
const before = window.scrollY;
|
||||
window.scrollTo(0, 99999);
|
||||
setTimeout(() => resolve([win, doc, before, window.scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let values = no_op.value.unwrap();
|
||||
let values = values.as_array().expect("array");
|
||||
assert_eq!(&values[0..2], &serde_json::json!([0, 0]).as_array().unwrap()[..]);
|
||||
assert_eq!(values[2], values[3]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_tracks_viewport_threshold_crossings() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:150px"></div>
|
||||
<div id="target" style="height:100px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const records = [];
|
||||
const target = document.getElementById("target");
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
records.push([
|
||||
entry.isIntersecting,
|
||||
Math.round(entry.intersectionRatio * 100) / 100,
|
||||
Math.round(entry.boundingClientRect.top),
|
||||
Math.round(entry.intersectionRect.height),
|
||||
]);
|
||||
}
|
||||
}, { threshold: [0, 0.5, 1] });
|
||||
observer.observe(target);
|
||||
setTimeout(() => window.scrollTo(0, 100), 5);
|
||||
setTimeout(() => window.scrollTo(0, 260), 15);
|
||||
setTimeout(() => resolve(records), 30);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
[false, 0, 150, 0],
|
||||
[true, 0.5, 50, 50],
|
||||
[false, 0, -110, 0],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_honors_root_margin_zero_area_and_no_fake_refires() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0;position:relative">
|
||||
<div style="height:110px"></div>
|
||||
<div id="margin-target" style="height:10px"></div>
|
||||
<div id="zero" style="position:absolute;left:20px;top:50px;width:0;height:0"></div>
|
||||
<div id="root" style="height:20px;overflow:auto"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const marginRecords = [], zeroRecords = [];
|
||||
const marginObserver = new IntersectionObserver(
|
||||
entries => marginRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
entry.intersectionRatio,
|
||||
entry.rootBounds.bottom,
|
||||
])),
|
||||
{ rootMargin: "0px 0px 20px", threshold: [0, 1] }
|
||||
);
|
||||
const zeroObserver = new IntersectionObserver(
|
||||
entries => zeroRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
entry.intersectionRatio,
|
||||
]))
|
||||
);
|
||||
marginObserver.observe(document.getElementById("margin-target"));
|
||||
zeroObserver.observe(document.getElementById("zero"));
|
||||
let unsupported = "";
|
||||
try {
|
||||
new IntersectionObserver(() => {}, {
|
||||
root: document.getElementById("root")
|
||||
});
|
||||
} catch (error) {
|
||||
unsupported = error.name;
|
||||
}
|
||||
setTimeout(() => resolve([
|
||||
marginRecords, zeroRecords, unsupported,
|
||||
marginObserver.rootMargin, marginObserver.thresholds,
|
||||
]), 200);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
[[true, 1, 120]],
|
||||
[[true, 1]],
|
||||
"NotSupportedError",
|
||||
"0px 0px 20px 0px",
|
||||
[0, 1],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_recomputes_after_style_mutation_and_resize() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="spacer" style="height:150px"></div>
|
||||
<div id="target" style="height:20px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"intersection-mutation",
|
||||
r#"
|
||||
globalThis.__ioRecords = [];
|
||||
globalThis.__io = new IntersectionObserver(entries => {
|
||||
__ioRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
Math.round(entry.boundingClientRect.top),
|
||||
]));
|
||||
});
|
||||
__io.observe(document.getElementById("target"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(10).await.unwrap();
|
||||
|
||||
rt.evaluate(
|
||||
r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(10).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__ioRecords").unwrap(),
|
||||
serde_json::json!([[false, 150]])
|
||||
);
|
||||
|
||||
rt.set_viewport(200.0, 160.0);
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__ioRecords").unwrap(),
|
||||
serde_json::json!([[false, 150], [true, 120]])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn scroll_into_view_aligns_the_root_viewport_and_clamps() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:300px"></div>
|
||||
<div id="target" style="height:40px"></div>
|
||||
<div style="height:340px"></div>
|
||||
<div id="bottom" style="height:20px"></div>
|
||||
<div id="fixed" style="position:fixed;top:10px;height:20px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const target = document.getElementById("target");
|
||||
const bottom = document.getElementById("bottom");
|
||||
const fixed = document.getElementById("fixed");
|
||||
target.scrollIntoView();
|
||||
const start = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "center" });
|
||||
const center = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "end" });
|
||||
const end = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
const nearestOutside = scrollY;
|
||||
scrollTo(0, 250);
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
const nearestVisible = scrollY;
|
||||
fixed.scrollIntoView();
|
||||
const afterFixed = scrollY;
|
||||
bottom.scrollIntoView({ block: "start" });
|
||||
const clamped = scrollY;
|
||||
const max = document.documentElement.scrollHeight - innerHeight;
|
||||
return [
|
||||
start, center, end, nearestOutside, nearestVisible,
|
||||
afterFixed, clamped, max,
|
||||
];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([300, 270, 240, 240, 250, 250, 600, 600])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scroll_into_view_emits_events_only_when_the_root_moves() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:300px"></div>
|
||||
<div id="target" style="height:40px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 250)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let no_op = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
document.getElementById("target").scrollIntoView({ block: "nearest" });
|
||||
setTimeout(() => resolve([win, doc, scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_op.value.unwrap(), serde_json::json!([0, 0, 250]));
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 0)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let moved = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
document.getElementById("target").scrollIntoView({ block: "center" });
|
||||
setTimeout(() => resolve([win, doc, scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 270]));
|
||||
}
|
||||
|
||||
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so
|
||||
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user