From df410aeeb74f1e87ae438a49579c8bddeffdee71 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 3 Oct 2026 16:06:06 +0800 Subject: [PATCH] feat(layout): add --hide-scrollbars for fetch and serve Hide native scrollbars and automatic gutters while preserving scrolling and explicit stable gutters. Require layout mode and retain the setting across navigation, frames, and protocol sessions. Refs #1081 --- moli-core/src/config/mod.rs | 15 +++ moli-core/src/runtime/mod.rs | 2 +- moli-core/src/runtime/navigation_engine.rs | 77 ++++++++++- moli-layout/src/builder.rs | 3 +- moli-layout/src/style.rs | 33 ++++- moli-layout/src/world.rs | 10 +- moli-layout/tests/phase5_output_contract.rs | 71 ++++++++++ .../src/protocol_server/mod.rs | 2 + .../bidi/extracted/visual_and_emulation.rs | 41 ++++++ .../tests/classic/extracted/elements.rs | 30 +++++ .../src/protocol_server/tests/screenshot.rs | 91 +++++++++++++ .../src/protocol_server/webdriver_bidi.rs | 3 +- .../src/protocol_server/webdriver_classic.rs | 3 +- moli-protocol/src/conn/tests/mod.rs | 30 +++++ .../src/layout_renderer/style_resolver.rs | 21 ++- .../src/native_bridge/context_host/core.rs | 1 + .../src/native_bridge/context_host/layout.rs | 8 ++ .../src/native_bridge/context_host/mod.rs | 1 + moli-renderer-v8/src/runtime/owner.rs | 4 +- .../src/runtime/owner/owner_commands.rs | 43 ++++-- .../src/runtime/owner/page_creation.rs | 12 +- .../runtime/page_vm/followed_navigation.rs | 1 + moli-renderer-v8/src/runtime/page_vm/mod.rs | 4 + .../src/runtime/page_vm/test_support.rs | 1 + .../tests/extracted/page_task_dispatch.rs | 1 + .../src/runtime/page_vm/tests/mod.rs | 1 + .../runtime/page_vm/tests/rendering_update.rs | 1 + .../tests/rendering_update/scrollbars.rs | 122 ++++++++++++++++++ moli-renderer-v8/src/runtime/phase_one/mod.rs | 1 + .../src/runtime/phase_one/streaming.rs | 1 + .../extracted/parser_blocking_scripts.rs | 4 + .../tests/extracted/parser_script_handoffs.rs | 6 + .../tree_mutations_and_document_write.rs | 1 + .../src/script_vm/document_environment.rs | 6 + moli/src/cli.rs | 4 + moli/src/config.rs | 5 + moli/tests/cli.rs | 91 +++++++++++++ moli/tests/fetch_cli/eval.rs | 47 +++++++ 38 files changed, 770 insertions(+), 28 deletions(-) create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/scrollbars.rs diff --git a/moli-core/src/config/mod.rs b/moli-core/src/config/mod.rs index a50401ee9a..2c3400c869 100644 --- a/moli-core/src/config/mod.rs +++ b/moli-core/src/config/mod.rs @@ -9,6 +9,7 @@ pub struct BrowserConfig { fetch: FetchConfig, profile_dir: Option, layout_policy: LayoutPolicy, + scrollbars_hidden: bool, optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, script_execution_disabled: bool, @@ -23,6 +24,7 @@ impl Default for BrowserConfig { fetch: FetchConfig::default(), profile_dir: None, layout_policy: LayoutPolicy::default(), + scrollbars_hidden: false, optional_resource_fetch_mask: OptionalResourceFetchMask::NONE, subframe_loading_enabled: true, script_execution_disabled: false, @@ -75,6 +77,19 @@ impl BrowserConfig { self } + pub fn scrollbars_hidden(&self) -> bool { + self.scrollbars_hidden + } + + pub fn set_scrollbars_hidden(&mut self, hidden: bool) { + self.scrollbars_hidden = hidden; + } + + pub fn with_scrollbars_hidden(mut self, hidden: bool) -> Self { + self.set_scrollbars_hidden(hidden); + self + } + pub fn image_fetch_enabled(&self) -> bool { self.optional_resource_fetch_enabled(SubresourceResourceType::Image) } diff --git a/moli-core/src/runtime/mod.rs b/moli-core/src/runtime/mod.rs index 1875e9032c..47d5875115 100644 --- a/moli-core/src/runtime/mod.rs +++ b/moli-core/src/runtime/mod.rs @@ -283,7 +283,7 @@ impl Browser { JsRuntime::initialize_with_browser_context_owner_access(&browser_context_access)?; js_runtime .renderer_owner_handle() - .configure_layout_policy(config.layout_policy())?; + .configure_layout(config.layout_policy(), config.scrollbars_hidden())?; let lifetime_owner = Rc::new(BrowserLifetimeOwner { js_runtime: Some(js_runtime.clone()), browser_context_owner, diff --git a/moli-core/src/runtime/navigation_engine.rs b/moli-core/src/runtime/navigation_engine.rs index c96b3c891e..5fa6894647 100644 --- a/moli-core/src/runtime/navigation_engine.rs +++ b/moli-core/src/runtime/navigation_engine.rs @@ -356,6 +356,7 @@ pub struct NavigationRuntimeConfig { optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, layout_policy: LayoutPolicy, + scrollbars_hidden: bool, } impl NavigationRuntimeConfig { @@ -370,6 +371,7 @@ impl NavigationRuntimeConfig { optional_resource_fetch_mask, subframe_loading_enabled, layout_policy, + scrollbars_hidden: false, } } @@ -392,6 +394,15 @@ impl NavigationRuntimeConfig { pub fn layout_policy(&self) -> LayoutPolicy { self.layout_policy } + + pub fn scrollbars_hidden(&self) -> bool { + self.scrollbars_hidden + } + + pub fn with_scrollbars_hidden(mut self, hidden: bool) -> Self { + self.scrollbars_hidden = hidden; + self + } } impl Default for NavigationRuntimeConfig { @@ -413,6 +424,7 @@ impl From<&crate::config::BrowserConfig> for NavigationRuntimeConfig { config.subframe_loading_enabled(), config.layout_policy(), ) + .with_scrollbars_hidden(config.scrollbars_hidden()) } } @@ -421,6 +433,7 @@ pub struct NavigationEngine { fetch_config: FetchConfig, page_network_policy: PageNetworkPolicy, layout_policy: LayoutPolicy, + scrollbars_hidden: bool, js_runtime: JsRuntime, resource_runtime: Option, browser_context_access: RendererBrowserContextRuntimeOwnerAccess, @@ -557,6 +570,7 @@ impl NavigationEngine { optional_resource_fetch_mask, subframe_loading_enabled, layout_policy, + scrollbars_hidden, } = config; let resource_runtime = browser_context_access .current_browser_resource_runtime() @@ -565,7 +579,7 @@ impl NavigationEngine { JsRuntime::initialize_with_browser_context_owner_access(&browser_context_access)?; js_runtime .renderer_owner_handle() - .configure_layout_policy(layout_policy)?; + .configure_layout(layout_policy, scrollbars_hidden)?; let standalone_lifetime_owner = standalone_browser_context_owner.map(|owner| { Rc::new(NavigationEngineLifetimeOwner { js_runtime: Some(js_runtime.clone()), @@ -580,6 +594,7 @@ impl NavigationEngine { false, ), layout_policy, + scrollbars_hidden, js_runtime, resource_runtime: Some(resource_runtime), browser_context_access, @@ -600,7 +615,8 @@ impl NavigationEngine { optional_resource_fetch_mask, subframe_loading_enabled, renderer_owner_source.layout_policy, - ), + ) + .with_scrollbars_hidden(renderer_owner_source.scrollbars_hidden), renderer_owner_source, ) } @@ -614,11 +630,12 @@ impl NavigationEngine { optional_resource_fetch_mask, subframe_loading_enabled, layout_policy, + scrollbars_hidden, } = config; renderer_owner_source .js_runtime .renderer_owner_handle() - .configure_layout_policy(layout_policy)?; + .configure_layout(layout_policy, scrollbars_hidden)?; let resource_runtime = renderer_owner_source .browser_context_access .current_browser_resource_runtime() @@ -631,6 +648,7 @@ impl NavigationEngine { false, ), layout_policy, + scrollbars_hidden, js_runtime: renderer_owner_source.js_runtime.clone(), resource_runtime: Some(resource_runtime), browser_context_access: renderer_owner_source.browser_context_access.clone(), @@ -698,6 +716,7 @@ impl NavigationEngine { self.subframe_loading_enabled(), self.layout_policy, ) + .with_scrollbars_hidden(self.scrollbars_hidden) } pub fn layout_policy(&self) -> LayoutPolicy { @@ -2531,6 +2550,58 @@ mod tests { ); } + #[test] + fn hidden_scrollbars_follow_browser_and_shared_navigation_configuration() { + let browser_config = BrowserConfig::default() + .with_layout_policy(LayoutPolicy::OnDemand) + .with_scrollbars_hidden(true); + let browser = Browser::new(browser_config.clone()).expect("hidden scrollbar browser"); + assert!( + browser + .js_runtime + .renderer_owner_handle() + .scrollbars_hidden() + ); + + let source = NavigationEngine::new_with_runtime_config((&browser_config).into()); + assert!(source.runtime_config().scrollbars_hidden()); + assert!( + source + .js_runtime + .renderer_owner_handle() + .scrollbars_hidden() + ); + let shared = NavigationEngine::new_with_fetch_config_and_shared_renderer_owner( + FetchConfig::default(), + &source, + OptionalResourceFetchMask::NONE, + true, + ) + .expect("matching owner configuration"); + assert!(shared.runtime_config().scrollbars_hidden()); + assert!(shared.shares_renderer_owner_with(&source)); + + let conflicting = source.runtime_config().with_scrollbars_hidden(false); + assert!( + NavigationEngine::new_with_runtime_config_and_shared_renderer_owner( + conflicting, + &source + ) + .is_err() + ); + assert!( + source + .js_runtime + .renderer_owner_handle() + .scrollbars_hidden() + ); + } + + #[test] + fn hidden_scrollbars_require_real_layout_for_rust_callers() { + assert!(Browser::new(BrowserConfig::default().with_scrollbars_hidden(true)).is_err()); + } + #[test] fn adopting_shared_backend_does_not_share_page_network_policy() { let left = NavigationEngine::new(); diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 5e5c3c0fc3..9311dcf4f2 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -377,7 +377,8 @@ where root_style.scrollbar_colors(), root_style.uses_horizontal_writing_mode(), self.source.disables_viewport_user_scrolling(), - ); + ) + .with_scrollbars_hidden(root_style.viewport_scrollbars_hidden()); } fn principal_box( diff --git a/moli-layout/src/style.rs b/moli-layout/src/style.rs index 478e3bb4fc..beb962601e 100644 --- a/moli-layout/src/style.rs +++ b/moli-layout/src/style.rs @@ -507,6 +507,7 @@ pub struct ResolvedLayoutStyle { overflow_x: LayoutOverflowMode, overflow_y: LayoutOverflowMode, scrollbar_width: LayoutScrollbarWidth, + scrollbars_hidden: bool, scrollbar_gutter: LayoutScrollbarGutter, scrollbar_colors: Option, revealed_scrollbar_x: bool, @@ -888,6 +889,7 @@ impl ResolvedLayoutStyle { overflow_x, overflow_y, scrollbar_width, + scrollbars_hidden: false, scrollbar_gutter, scrollbar_colors, revealed_scrollbar_x: false, @@ -965,6 +967,7 @@ impl ResolvedLayoutStyle { overflow_x, overflow_y, scrollbar_width: LayoutScrollbarWidth::Auto, + scrollbars_hidden: false, scrollbar_gutter: LayoutScrollbarGutter::Auto, scrollbar_colors: None, revealed_scrollbar_x: false, @@ -1042,6 +1045,11 @@ impl ResolvedLayoutStyle { self.scrollbar_colors = colors; } + /// Hides scrollbar UI and automatic gutters, preserving explicit stable gutters. + pub fn suppress_scrollbars(&mut self) { + self.scrollbars_hidden = true; + } + /// Overrides the alignment keyword for a synthetic inline style. pub fn with_inline_alignment(mut self, alignment: LayoutInlineAlignment) -> Self { self.vertical_align.kind = alignment; @@ -1726,6 +1734,10 @@ impl ResolvedLayoutStyle { self.scrollbar_width } + pub(crate) const fn viewport_scrollbars_hidden(&self) -> bool { + self.scrollbars_hidden + } + pub(crate) const fn viewport_scrollbar_gutter(&self) -> LayoutScrollbarGutter { self.scrollbar_gutter } @@ -1754,8 +1766,10 @@ impl ResolvedLayoutStyle { } pub(crate) fn prepare_scrollbar_layout(&mut self, is_root: bool) { - self.revealed_scrollbar_x = self.overflow_x == LayoutOverflowMode::Scroll; - self.revealed_scrollbar_y = self.overflow_y == LayoutOverflowMode::Scroll; + self.revealed_scrollbar_x = + !self.scrollbars_hidden && self.overflow_x == LayoutOverflowMode::Scroll; + self.revealed_scrollbar_y = + !self.scrollbars_hidden && self.overflow_y == LayoutOverflowMode::Scroll; // Moli resolves CSS scrollbar policy to physical edge insets through // `LayoutPartialTree::get_scrollbar_insets`. Keep Taffy's legacy // scalar disabled so no algorithm reserves the same gutter twice. @@ -1806,7 +1820,10 @@ impl ResolvedLayoutStyle { is_root: bool, overflowing: bool, ) -> bool { - if !overflowing || self.scrollbar_width == LayoutScrollbarWidth::None { + if !overflowing + || self.scrollbars_hidden + || self.scrollbar_width == LayoutScrollbarWidth::None + { return false; } let mode = self.overflow_mode(axis); @@ -1842,7 +1859,7 @@ impl ResolvedLayoutStyle { is_root: bool, overflowing: bool, ) -> bool { - if self.scrollbar_width == LayoutScrollbarWidth::None { + if self.scrollbars_hidden || self.scrollbar_width == LayoutScrollbarWidth::None { return false; } match self.overflow_mode(axis) { @@ -1867,7 +1884,11 @@ impl ResolvedLayoutStyle { } pub(crate) fn scrollbar_control_thickness(&self) -> f32 { - self.scrollbar_width.thickness() + if self.scrollbars_hidden { + 0.0 + } else { + self.scrollbar_width.thickness() + } } pub(crate) fn scrollbar_gutter_thickness(&self, axis: LayoutScrollbarAxis) -> f32 { @@ -1985,6 +2006,7 @@ impl ResolvedLayoutStyle { overflow_x: LayoutOverflowMode::Visible, overflow_y: LayoutOverflowMode::Visible, scrollbar_width: parent.scrollbar_width, + scrollbars_hidden: parent.scrollbars_hidden, scrollbar_gutter: LayoutScrollbarGutter::Auto, scrollbar_colors: parent.scrollbar_colors, revealed_scrollbar_x: false, @@ -2053,6 +2075,7 @@ impl ResolvedLayoutStyle { overflow_x: LayoutOverflowMode::Visible, overflow_y: LayoutOverflowMode::Visible, scrollbar_width: parent.scrollbar_width, + scrollbars_hidden: parent.scrollbars_hidden, scrollbar_gutter: LayoutScrollbarGutter::Auto, scrollbar_colors: parent.scrollbar_colors, revealed_scrollbar_x: false, diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 7650280121..67896fb031 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -320,6 +320,7 @@ pub(crate) struct ViewportScrollPolicy { scrollbar_style: ViewportScrollbarStyle, embedder_allows_user_scroll: bool, embedder_allows_scrollbar_controls: bool, + scrollbars_hidden: bool, revealed_scrollbar_x: bool, revealed_scrollbar_y: bool, } @@ -370,11 +371,17 @@ impl ViewportScrollPolicy { }, embedder_allows_user_scroll: !user_scrolling_disabled, embedder_allows_scrollbar_controls: !user_scrolling_disabled, + scrollbars_hidden: false, revealed_scrollbar_x: false, revealed_scrollbar_y: false, } } + pub(crate) const fn with_scrollbars_hidden(mut self, hidden: bool) -> Self { + self.scrollbars_hidden = hidden; + self + } + pub(crate) const fn defining_body(self) -> Option { match self.defining_box { ViewportDefiningBox::Root => None, @@ -461,7 +468,8 @@ impl ViewportScrollPolicy { } const fn allows_scrollbar_controls(self) -> bool { - self.embedder_allows_scrollbar_controls + !self.scrollbars_hidden + && self.embedder_allows_scrollbar_controls && !matches!(self.scrollbar_style.width, LayoutScrollbarWidth::None) } diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index e696d3bec3..413ade4c7c 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -1160,6 +1160,77 @@ fn thin_and_hidden_scrollbars_match_chromium_client_geometry() { } } +#[test] +fn hidden_scrollbar_policy_preserves_explicit_stable_gutters_without_controls() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("scroller", vec![2]), + Node::element("content", Vec::new()), + ]); + // Chromium --hide-scrollbars removes automatic gutters, but stable gutters + // still use the authored auto/thin width even though no UI is painted. + for (width, thickness) in [ + (LayoutScrollbarWidth::Auto, 15.0), + (LayoutScrollbarWidth::Thin, 10.0), + (LayoutScrollbarWidth::None, 0.0), + ] { + for (gutter, edges) in [ + (LayoutScrollbarGutter::Auto, 0.0), + (LayoutScrollbarGutter::Stable, 1.0), + (LayoutScrollbarGutter::StableBothEdges, 2.0), + ] { + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + let mut scroller = resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(200.0), + height: length(100.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ); + scroller.set_scrollbar_style(width, gutter, None); + scroller.suppress_scrollbars(); + styles.0.insert(1, scroller); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 400.0, 300.0)); + let output = build(&source, &mut styles); + let metrics = output.element_metrics_for_source(1).unwrap(); + assert_eq!( + metrics.client_size, + moli_layout::LayoutSize::new(200.0 - edges * thickness, 100.0) + ); + assert_eq!( + metrics.client_border.x, + if edges == 2.0 { thickness } else { 0.0 } + ); + assert_eq!( + metrics.scroll_size, + moli_layout::LayoutSize::new(400.0, 300.0) + ); + let box_id = output.source_output(1).unwrap().principal_box.unwrap(); + let extent = output.scroll_extent(box_id).unwrap(); + assert!(extent.horizontal_scrollbar.is_none()); + assert!(extent.vertical_scrollbar.is_none()); + assert!(extent.scrollbar_corner.is_none()); + assert!( + output + .scrollbar_hit_test(LayoutPoint::new(190.0, 30.0)) + .is_none() + ); + } + } +} + #[test] fn stable_both_edges_reserves_and_offsets_both_chromium_gutters() { let source = Source(vec![ diff --git a/moli-protocol-server/src/protocol_server/mod.rs b/moli-protocol-server/src/protocol_server/mod.rs index 200e763d31..918e39e1c1 100644 --- a/moli-protocol-server/src/protocol_server/mod.rs +++ b/moli-protocol-server/src/protocol_server/mod.rs @@ -751,6 +751,7 @@ struct AppState { optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, layout_policy: LayoutPolicy, + scrollbars_hidden: bool, } impl AppState { @@ -826,6 +827,7 @@ impl AppState { optional_resource_fetch_mask: navigation_runtime_config.optional_resource_fetch_mask(), subframe_loading_enabled: navigation_runtime_config.subframe_loading_enabled(), layout_policy: navigation_runtime_config.layout_policy(), + scrollbars_hidden: navigation_runtime_config.scrollbars_hidden(), } } diff --git a/moli-protocol-server/src/protocol_server/tests/bidi/extracted/visual_and_emulation.rs b/moli-protocol-server/src/protocol_server/tests/bidi/extracted/visual_and_emulation.rs index 9082790453..6099bbbae3 100644 --- a/moli-protocol-server/src/protocol_server/tests/bidi/extracted/visual_and_emulation.rs +++ b/moli-protocol-server/src/protocol_server/tests/bidi/extracted/visual_and_emulation.rs @@ -1,5 +1,46 @@ use super::*; +#[tokio::test] +async fn websocket_bidi_hidden_scrollbars_preserve_server_configuration() { + let config = protocol_server_test_runtime_config( + protocol_server_test_fetch_config(FetchConfig::default()), + OptionalResourceFetchMask::NONE, + ) + .with_scrollbars_hidden(true); + let (addr, server) = spawn_test_protocol_server_with_runtime_config(config).await; + let (mut socket, context) = bidi_session_with_context(addr).await; + let viewport = send_bidi_command( + &mut socket, + 3, + "browsingContext.setViewport", + json!({ + "context":context,"viewport":{"width":1280,"height":720} + }), + ) + .await; + assert_eq!(viewport["type"], "success", "{viewport}"); + let navigated = send_bidi_command(&mut socket,4,"browsingContext.navigate",json!({ + "context":context,"url":"data:text/html,
","wait":"complete" + })).await; + assert_eq!(navigated["type"], "success", "{navigated}"); + let captured = send_bidi_command( + &mut socket, + 5, + "browsingContext.captureScreenshot", + json!({"context":context}), + ) + .await; + assert_eq!(captured["type"], "success", "{captured}"); + let metrics = send_bidi_command(&mut socket,6,"script.evaluate",json!({ + "target":{"context":context},"awaitPromise":false, + "expression":"JSON.stringify([innerWidth,document.documentElement.clientWidth,document.documentElement.scrollWidth])" + })).await; + assert_eq!(metrics["type"], "success", "{metrics}"); + assert_eq!(metrics["result"]["result"]["value"], "[1280,1280,1280]"); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; +} + #[tokio::test] async fn websocket_bidi_page_and_box_screenshot_publish_real_png() { // Ported from Chromium/WPT diff --git a/moli-protocol-server/src/protocol_server/tests/classic/extracted/elements.rs b/moli-protocol-server/src/protocol_server/tests/classic/extracted/elements.rs index c0cc43af40..f71c01cd12 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic/extracted/elements.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic/extracted/elements.rs @@ -1,5 +1,35 @@ use super::*; +#[tokio::test] +async fn webdriver_classic_hidden_scrollbars_preserve_server_configuration() { + let state = AppState::new_with_storage_partition_and_runtime_config( + "127.0.0.1:9222".parse().unwrap(), + Arc::new(StoragePartitionState::open(None).unwrap()), + protocol_server_test_runtime_config( + protocol_server_test_fetch_config(FetchConfig::default()), + OptionalResourceFetchMask::NONE, + ) + .with_scrollbars_hidden(true), + crate::config::DEFAULT_SCREENCAST_INTERVAL_MS, + ) + .unwrap(); + let app = build_router(state); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let id = session["value"]["sessionId"].as_str().expect("session id"); + let navigated = classic_request_json_with_body(app.clone(),Method::POST,&format!("/session/{id}/url"),json!({ + "url":"data:text/html,
" + })).await; + assert_eq!(navigated, json!({"value":null})); + classic_capture_layout(app.clone(), id).await; + let metrics = classic_request_json_with_body(app.clone(),Method::POST,&format!("/session/{id}/execute/sync"),json!({ + "script":"return [innerWidth - document.documentElement.clientWidth,document.documentElement.scrollWidth - innerWidth];", + "args":[] + })).await; + assert_eq!(metrics, json!({"value":[0,0]})); + let deleted = classic_request_json(app, Method::DELETE, &format!("/session/{id}")).await; + assert_eq!(deleted, json!({"value":null})); +} + #[tokio::test] async fn webdriver_classic_element_equality_cases_ported_from_selenium() { // Ported from Selenium's Python element_equality_tests.py: diff --git a/moli-protocol-server/src/protocol_server/tests/screenshot.rs b/moli-protocol-server/src/protocol_server/tests/screenshot.rs index 4d6e9434c3..b8af0295c6 100644 --- a/moli-protocol-server/src/protocol_server/tests/screenshot.rs +++ b/moli-protocol-server/src/protocol_server/tests/screenshot.rs @@ -156,6 +156,97 @@ fn pixel(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { .expect("one RGBA pixel") } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_cdp_hidden_scrollbars_follow_targets_navigation_and_resize() { + let config = protocol_server_test_runtime_config( + protocol_server_test_fetch_config(FetchConfig::default()), + OptionalResourceFetchMask::NONE, + ) + .with_scrollbars_hidden(true); + let (addr, server) = spawn_test_protocol_server_with_runtime_config(config).await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .expect("CDP socket"); + let url = "data:text/html,
"; + for _ in 0..2 { + let target = open_screenshot_target(&mut socket, url).await; + for width in [1280, 1000] { + assert_cdp_success( + &send_cdp_command( + &mut socket, + 20, + "Emulation.setDeviceMetricsOverride", + Some(&target.session_id), + json!({"width":width,"height":720,"deviceScaleFactor":1,"mobile":false}), + ) + .await, + 20, + ); + let png = capture_png(&mut socket, &target.session_id, 21).await; + let (image_width, _, pixels) = decode_png(&png); + assert_eq!(image_width, width); + assert_eq!(pixel(&pixels, width, width - 8, 300), [0, 255, 0, 255]); + let result = send_cdp_command( + &mut socket, 22, "Runtime.evaluate", Some(&target.session_id), + json!({"expression":"JSON.stringify([innerWidth,document.documentElement.clientWidth,document.documentElement.scrollWidth])","returnByValue":true}) + ).await; + assert_cdp_success(&result, 22); + assert_eq!( + response_by_id(&result, 22)["result"]["result"]["value"], + json!(format!("[{width},{width},{width}]")) + ); + } + assert_cdp_success( + &send_cdp_command( + &mut socket, + 23, + "Input.dispatchMouseEvent", + Some(&target.session_id), + json!({"type":"mouseWheel","x":100,"y":300,"deltaX":0,"deltaY":100}), + ) + .await, + 23, + ); + let scroll = send_cdp_command( + &mut socket, + 24, + "Runtime.evaluate", + Some(&target.session_id), + json!({"expression":"scrollY > 0","returnByValue":true}), + ) + .await; + assert_cdp_success(&scroll, 24); + assert_eq!( + response_by_id(&scroll, 24)["result"]["result"]["value"], + true + ); + assert_cdp_success( + &cdp_navigate_and_wait_for_load(&mut socket, 25, &target.session_id, url).await, + 25, + ); + capture_png(&mut socket, &target.session_id, 26).await; + let result = send_cdp_command( + &mut socket, 27, "Runtime.evaluate", Some(&target.session_id), + json!({"expression":"innerWidth === document.documentElement.clientWidth","returnByValue":true}) + ).await; + assert_cdp_success(&result, 27); + assert_eq!( + response_by_id(&result, 27)["result"]["result"]["value"], + true + ); + send_cdp_command( + &mut socket, + 28, + "Target.closeTarget", + None, + json!({"targetId":target.target_id}), + ) + .await; + } + abort_test_cdp_server(server).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_capture_screenshot_tracks_paint_and_layout_mutations() { let (fixture_addr, _fixture_server) = diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs index cf35b0c908..11f86319ea 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs @@ -76,7 +76,8 @@ pub(super) async fn ws_bidi_session_upgrade_handler( state.optional_resource_fetch_mask, state.subframe_loading_enabled, state.layout_policy, - ); + ) + .with_scrollbars_hidden(state.scrollbars_hidden); ws.on_upgrade(move |socket| { handle_bidi_session_socket( socket, diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic.rs b/moli-protocol-server/src/protocol_server/webdriver_classic.rs index 5529ed4daa..081f347160 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs @@ -1669,7 +1669,8 @@ pub(super) async fn webdriver_classic_new_session( state.optional_resource_fetch_mask, state.subframe_loading_enabled, state.layout_policy, - ), + ) + .with_scrollbars_hidden(state.scrollbars_hidden), ); let create_context = ClassicDevToolsCommandContext::new(session.session_id.as_str()); let target_id = match runtime diff --git a/moli-protocol/src/conn/tests/mod.rs b/moli-protocol/src/conn/tests/mod.rs index 67ea6f78cb..b839567f66 100644 --- a/moli-protocol/src/conn/tests/mod.rs +++ b/moli-protocol/src/conn/tests/mod.rs @@ -244,6 +244,36 @@ fn idle_navigation_engine_reset_preserves_mock_layout_policy() { ); } +#[tokio::test] +async fn hidden_scrollbars_survive_idle_reset_and_browser_context_removal() { + let mut conn = CdpConnection::new_with_initial_storage_partition_and_runtime_config( + crate::CdpInitialStoragePartition::memory(), + NavigationRuntimeConfig::new( + FetchConfig::default(), + OptionalResourceFetchMask::NONE, + true, + LayoutPolicy::OnDemand, + ) + .with_scrollbars_hidden(true), + ); + assert!(conn.release_idle_navigation_engine_memory_if_idle().reset); + assert!( + conn.standalone_navigation_engine + .runtime_config() + .scrollbars_hidden() + ); + conn.insert_browser_context(BrowserContext::new("CTX-hidden-scrollbars".to_owned())); + let removed = conn + .remove_browser_context_by_id_restoring_active_async("CTX-hidden-scrollbars", None) + .await; + assert!(removed.is_some()); + assert!( + conn.standalone_navigation_engine + .runtime_config() + .scrollbars_hidden() + ); +} + #[tokio::test] async fn browser_context_install_and_removal_preserve_mock_layout_policy() { let mut conn = CdpConnection::new_with_initial_storage_partition_and_runtime_config( diff --git a/moli-renderer-v8/src/layout_renderer/style_resolver.rs b/moli-renderer-v8/src/layout_renderer/style_resolver.rs index 4c14be7dcd..bb7a4a07fb 100644 --- a/moli-renderer-v8/src/layout_renderer/style_resolver.rs +++ b/moli-renderer-v8/src/layout_renderer/style_resolver.rs @@ -14,6 +14,7 @@ pub(super) struct NativeLayoutStyleResolver<'a> { runtime: &'a JsContextHost, reads: StyleObservation<'a>, scripting_enabled: bool, + scrollbars_hidden: bool, profile: Option, } @@ -46,6 +47,7 @@ impl<'a> NativeLayoutStyleResolver<'a> { runtime, reads, scripting_enabled: runtime.document_scripting_enabled(document), + scrollbars_hidden: runtime.scrollbars_hidden(), profile: moli_trace::cpu_profile_enabled() .then(NativeLayoutStyleResolverProfile::default), } @@ -138,8 +140,14 @@ impl LayoutStyleResolver for NativeLayoutStyleResolver<'_> { ); } let phase_started = self.profile.as_ref().map(|_| Instant::now()); - let before = before.map(ResolvedLayoutStyle::from_stylo); - let after = after.map(ResolvedLayoutStyle::from_stylo); + let mut before = before.map(ResolvedLayoutStyle::from_stylo); + let mut after = after.map(ResolvedLayoutStyle::from_stylo); + if self.scrollbars_hidden { + resolved.suppress_scrollbars(); + for pseudo in before.iter_mut().chain(after.iter_mut()) { + pseudo.suppress_scrollbars(); + } + } if let Some(profile) = self.profile.as_mut() { profile.eager_pseudo_count = profile .eager_pseudo_count @@ -200,7 +208,11 @@ impl LayoutStyleResolver for NativeLayoutStyleResolver<'_> { } let phase_started = self.profile.as_ref().map(|_| Instant::now()); let resolved = computed.map(|computed| { - ResolvedLayoutPseudoStyle::new(ResolvedLayoutStyle::from_stylo(computed)) + let mut resolved = ResolvedLayoutStyle::from_stylo(computed); + if self.scrollbars_hidden { + resolved.suppress_scrollbars(); + } + ResolvedLayoutPseudoStyle::new(resolved) }); if let Some(profile) = self.profile.as_mut() { profile.marker_projection_ns = profile.marker_projection_ns.saturating_add( @@ -246,6 +258,9 @@ impl LayoutStyleResolver for NativeLayoutStyleResolver<'_> { let phase_started = self.profile.as_ref().map(|_| Instant::now()); let mut resolved = ResolvedLayoutStyle::from_stylo(computed); resolved.force_layout_display(display); + if self.scrollbars_hidden { + resolved.suppress_scrollbars(); + } if let Some(profile) = self.profile.as_mut() { profile.anonymous_projection_ns = profile.anonymous_projection_ns.saturating_add( phase_started diff --git a/moli-renderer-v8/src/native_bridge/context_host/core.rs b/moli-renderer-v8/src/native_bridge/context_host/core.rs index fd64345902..4785565221 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -199,6 +199,7 @@ impl JsContextHost { pending_history_traversal_admissions: Default::default(), runtime: runtime as *mut DocumentRuntime, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, document_layout_state: RefCell::new(super::layout_state::DocumentLayoutState::default()), layout_pass_active: Cell::new(false), completed_layout_pass_count: Cell::new(0), diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 83978dc183..256efa8afe 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -57,6 +57,14 @@ impl JsContextHost { self.layout_policy } + pub(crate) fn set_scrollbars_hidden(&mut self, hidden: bool) { + self.scrollbars_hidden = hidden; + } + + pub(crate) const fn scrollbars_hidden(&self) -> bool { + self.scrollbars_hidden + } + pub(crate) fn active_layout_document_handles(&self) -> Vec { let mut documents = vec![self.document_handle()]; documents.extend( diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 39c793e708..4ed2f2e68d 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -807,6 +807,7 @@ pub(crate) struct JsContextHost { super::history_traversal::PendingHistoryTraversalAdmissions, runtime: *mut DocumentRuntime, layout_policy: moli_page_types::LayoutPolicy, + scrollbars_hidden: bool, document_layout_state: RefCell, layout_pass_active: Cell, completed_layout_pass_count: Cell, diff --git a/moli-renderer-v8/src/runtime/owner.rs b/moli-renderer-v8/src/runtime/owner.rs index b7e0078d74..7dc7646f85 100644 --- a/moli-renderer-v8/src/runtime/owner.rs +++ b/moli-renderer-v8/src/runtime/owner.rs @@ -171,6 +171,7 @@ pub struct RendererCreateHtmlPageRequest { pub fetch_subresource_interception_enabled: bool, pub fetch_subresource_interception_resource_type: Option, pub layout_policy: LayoutPolicy, + pub scrollbars_hidden: bool, pub wpt_extensions_enabled: bool, pub stage: PageVmInitStage, pub reply_boundary: crate::RendererReplyBoundary, @@ -221,6 +222,7 @@ pub struct RendererCreateStreamingRawPageRequest { pub fetch_subresource_interception_enabled: bool, pub fetch_subresource_interception_resource_type: Option, pub layout_policy: LayoutPolicy, + pub scrollbars_hidden: bool, pub wpt_extensions_enabled: bool, pub stage: PageVmInitStage, pub reply_boundary: crate::RendererReplyBoundary, @@ -1034,7 +1036,7 @@ pub(super) struct RendererOwnerState { #[derive(Debug, Clone, Copy, Default)] struct RendererOwnerLayoutPolicyState { - policy: Option, + configuration: Option<(LayoutPolicy, bool)>, } #[cfg(test)] diff --git a/moli-renderer-v8/src/runtime/owner/owner_commands.rs b/moli-renderer-v8/src/runtime/owner/owner_commands.rs index 37cf32112c..60c92a2e53 100644 --- a/moli-renderer-v8/src/runtime/owner/owner_commands.rs +++ b/moli-renderer-v8/src/runtime/owner/owner_commands.rs @@ -7,27 +7,52 @@ impl RendererOwnerHandle { /// is constructed. Page construction seals the value so a shared renderer /// owner can never host Pages with conflicting browser-level policies. pub fn configure_layout_policy(&self, policy: LayoutPolicy) -> Result<()> { + self.configure_layout(policy, false) + } + + /// Fixes both layout policy and scrollbar visibility before Page creation. + pub fn configure_layout(&self, policy: LayoutPolicy, scrollbars_hidden: bool) -> Result<()> { + ensure!( + !scrollbars_hidden || policy.uses_real_layout(), + "hiding scrollbars requires real layout" + ); let mut state = self.state.layout_policy.lock(); - if let Some(configured) = state.policy { + if let Some((configured_policy, configured_hidden)) = state.configuration { ensure!( - configured == policy, - "renderer owner layout policy is configured as {:?}, cannot change it to {:?}", - configured, - policy + (configured_policy, configured_hidden) == (policy, scrollbars_hidden), + "renderer owner layout policy is configured as {:?} (scrollbars hidden: {}), cannot change it to {:?} (scrollbars hidden: {})", + configured_policy, + configured_hidden, + policy, + scrollbars_hidden ); } else { - state.policy = Some(policy); + state.configuration = Some((policy, scrollbars_hidden)); } Ok(()) } pub fn layout_policy(&self) -> LayoutPolicy { - self.state.layout_policy.lock().policy.unwrap_or_default() + self.state + .layout_policy + .lock() + .configuration + .unwrap_or_default() + .0 } - pub(super) fn seal_layout_policy_for_page_creation(&self) -> LayoutPolicy { + pub fn scrollbars_hidden(&self) -> bool { + self.state + .layout_policy + .lock() + .configuration + .unwrap_or_default() + .1 + } + + pub(super) fn seal_layout_configuration_for_page_creation(&self) -> (LayoutPolicy, bool) { let mut state = self.state.layout_policy.lock(); - *state.policy.get_or_insert_with(LayoutPolicy::default) + *state.configuration.get_or_insert_with(Default::default) } pub(crate) fn new( diff --git a/moli-renderer-v8/src/runtime/owner/page_creation.rs b/moli-renderer-v8/src/runtime/owner/page_creation.rs index 3125726221..40b6db810f 100644 --- a/moli-renderer-v8/src/runtime/owner/page_creation.rs +++ b/moli-renderer-v8/src/runtime/owner/page_creation.rs @@ -657,6 +657,7 @@ impl RendererOwnerHandle { stage: PageVmInitStage, options: crate::RendererDocumentOptions, ) -> RendererCreateHtmlPageRequest { + let (layout_policy, scrollbars_hidden) = self.seal_layout_configuration_for_page_creation(); RendererCreateHtmlPageRequest { page_reservation, root_frame_id: options.root_frame_id, @@ -694,7 +695,8 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled: options.fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type: options .fetch_subresource_interception_resource_type, - layout_policy: self.seal_layout_policy_for_page_creation(), + layout_policy, + scrollbars_hidden, wpt_extensions_enabled: false, stage, reply_boundary: crate::RendererReplyBoundary::Stage, @@ -721,6 +723,7 @@ impl RendererOwnerHandle { stage: PageVmInitStage, options: crate::RendererDocumentOptions, ) -> RendererCreateStreamingRawPageRequest { + let (layout_policy, scrollbars_hidden) = self.seal_layout_configuration_for_page_creation(); RendererCreateStreamingRawPageRequest { document_replacement: None, root_frame_id: options.root_frame_id, @@ -758,7 +761,8 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled: options.fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type: options .fetch_subresource_interception_resource_type, - layout_policy: self.seal_layout_policy_for_page_creation(), + layout_policy, + scrollbars_hidden, wpt_extensions_enabled: false, stage, reply_boundary: crate::RendererReplyBoundary::Stage, @@ -832,6 +836,7 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type, layout_policy, + scrollbars_hidden, wpt_extensions_enabled, stage, reply_boundary, @@ -921,6 +926,7 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type, layout_policy, + scrollbars_hidden, wpt_extensions_enabled, root_frame_id, main_document_commit, @@ -1144,6 +1150,7 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type, layout_policy, + scrollbars_hidden, wpt_extensions_enabled, stage, reply_boundary, @@ -1221,6 +1228,7 @@ impl RendererOwnerHandle { fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type, layout_policy, + scrollbars_hidden, wpt_extensions_enabled, root_frame_id, main_document_commit, diff --git a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs index 89dfacb4cb..07c0ea6ba5 100644 --- a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs +++ b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs @@ -1207,6 +1207,7 @@ impl PageVm { fetch_subresource_interception_resource_type: self .fetch_subresource_interception_resource_type, layout_policy: self.layout_policy, + scrollbars_hidden: self.scrollbars_hidden, wpt_extensions_enabled: self.wpt_extensions_enabled, root_frame_id: self.vm().root_frame_id().map(str::to_owned), top_level_storage_key: None, diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index d118f429d8..9187412243 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1095,6 +1095,7 @@ pub(crate) struct PageVmEnvConfig { pub(crate) fetch_subresource_interception_enabled: bool, pub(crate) fetch_subresource_interception_resource_type: Option, pub(crate) layout_policy: LayoutPolicy, + pub(crate) scrollbars_hidden: bool, pub(crate) wpt_extensions_enabled: bool, pub(crate) navigation_bootstrap_entry: Option, pub(crate) reserved_service_worker_client_id: @@ -1616,6 +1617,7 @@ pub(crate) struct PageVm { pub(super) fetch_subresource_interception_enabled: bool, pub(super) fetch_subresource_interception_resource_type: Option, pub(super) layout_policy: LayoutPolicy, + pub(super) scrollbars_hidden: bool, pub(super) wpt_extensions_enabled: bool, pub(crate) runtime_hooks: PageVmRuntimeHooks, pub(super) navigation_response: Option, @@ -4294,6 +4296,7 @@ impl PageVm { let mut vm = vm_bootstrap.finish()?; vm.set_document_navigator_identity(&env.navigator_identity); vm.set_layout_policy(env.layout_policy); + vm.set_scrollbars_hidden(env.scrollbars_hidden); vm.install_page_task_capabilities(page_task_capabilities); vm.set_root_document_lifecycle(document_lifecycle.clone()); let dom_agent_state = vm.renderer_dom_agent_state(); @@ -4357,6 +4360,7 @@ impl PageVm { fetch_subresource_interception_resource_type: env .fetch_subresource_interception_resource_type, layout_policy: env.layout_policy, + scrollbars_hidden: env.scrollbars_hidden, wpt_extensions_enabled: env.wpt_extensions_enabled, runtime_hooks, navigation_response: None, diff --git a/moli-renderer-v8/src/runtime/page_vm/test_support.rs b/moli-renderer-v8/src/runtime/page_vm/test_support.rs index 359d7d853d..a4fa9c533f 100644 --- a/moli-renderer-v8/src/runtime/page_vm/test_support.rs +++ b/moli-renderer-v8/src/runtime/page_vm/test_support.rs @@ -697,6 +697,7 @@ fn minimal_test_page_vm_env_config() -> PageVmEnvConfig { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: crate::real_layout_test_policy(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs index 435ae18128..f3d5b32c9e 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs @@ -39,6 +39,7 @@ fn default_runtime_hooks_reject_direct_no_owner_page_vm_construction() { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: crate::real_layout_test_policy(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 7d4f8ac18e..2c759693c4 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -689,6 +689,7 @@ fn test_page_vm_with_loader_dom_host_hooks_and_response_referrer_policy( fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: crate::real_layout_test_policy(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index d1abff05fb..229142871c 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -52,6 +52,7 @@ async fn dispatch_main_document_domcontentloaded_for_rendering_test( mod layout_geometry; mod painting_tables; +mod scrollbars; mod text_styles; mod update_lifecycle; mod visual_output; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/scrollbars.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/scrollbars.rs new file mode 100644 index 0000000000..59e4437764 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/scrollbars.rs @@ -0,0 +1,122 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn hidden_scrollbars_preserve_geometry_scrolling_and_computed_css_in_all_frames() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + let mut page = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/hidden-scrollbars.html")?, + ); + page.vm_mut().set_scrollbars_hidden(true); + page.vm_mut().set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 1280, + inner_height: 720, + device_pixel_ratio: 1.0, + ..Default::default() + }))?; + page.vm_mut().eval(r#" +document.head.innerHTML = ``; +document.body.innerHTML = `
+
+
+
+
`; +const shadow = document.getElementById('shadow-host').attachShadow({mode:'open'}); +shadow.innerHTML = `
+
`; +const child = document.getElementById('frame').contentDocument; +child.documentElement.style.cssText = 'scrollbar-width:thin'; +child.body.style.margin = '0'; +child.body.innerHTML = `
+ `; +const nested = child.getElementById('nested').contentDocument; +nested.body.style.margin = '0'; +nested.body.innerHTML = `
`; +'installed' +"#)?; + page.vm_mut().sync_live_document_style_sources(); + let snapshot = page.vm_mut().screenshot_layout_snapshot( + moli_layout::PaintViewport::new(1280, 720, 1.0), + )?.expect("fixture layout"); + { + let host = page.vm().context_host_weak_for_test().upgrade().expect("context host"); + let host = host.borrow(); + assert!(host.with_latest_layout_tree_for_document(host.document_handle(), |tree| { + for (x, y) in [(190.0, 30.0), (1270.0, 30.0), (1070.0, 30.0)] { + assert!(tree.scrollbar_hit_test(moli_layout::LayoutPoint::new(x, y)).is_none()); + } + }).is_some()); + } + let image = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| { + let offset = ((y * image.width + x) * 4) as usize; + <[u8; 4]>::try_from(&image.rgba[offset..offset + 4]).unwrap() + }; + assert_eq!(pixel(1270, 500), [0, 255, 0, 255], "viewport has no scrollbar paint"); + assert_eq!(pixel(190, 70), [255, 0, 0, 255], "element has no scrollbar paint"); + assert_eq!(pixel(1070, 70), [255, 0, 0, 255], "pseudo has no scrollbar paint"); + + let metrics: serde_json::Value = serde_json::from_str(&page.vm_mut().eval(r#"JSON.stringify({ + root: [innerWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth], + elements: ['auto','thin','vertical'].map(id => { + const e = document.getElementById(id); + return [e.clientWidth,e.clientHeight,e.clientLeft,e.clientTop,e.scrollWidth,e.scrollHeight]; + }), + shadow: (() => { + const e = document.getElementById('shadow-host').shadowRoot.getElementById('scroller'); + return [e.clientWidth,e.clientHeight,e.scrollWidth,e.scrollHeight]; + })(), + frames: (() => { + const child = document.getElementById('frame').contentDocument; + const nested = child.getElementById('nested').contentDocument; + return [child.documentElement.clientWidth,child.documentElement.scrollWidth, + nested.documentElement.clientWidth,nested.documentElement.scrollWidth]; + })(), + css: [getComputedStyle(document.getElementById('auto')).scrollbarWidth, + getComputedStyle(document.getElementById('thin')).scrollbarWidth, + getComputedStyle(document.getElementById('auto')).scrollbarGutter] +})"#)?)?; + assert_eq!(metrics, json!({ + "root": [1280,1280,1280], + "elements": [[200,100,0,0,400,300],[200,100,0,0,400,300],[200,100,0,0,400,300]], + "shadow": [200,100,400,300], + "frames": [300,300,120,120], + "css": ["auto","thin","auto"] + })); + let scroll = page.vm_mut().eval(r#" +const scroller = document.getElementById('auto'); +scroller.scrollLeft = 20; +scroller.scrollTop = 40; +window.scrollTo(0,100); +JSON.stringify([scroller.scrollLeft,scroller.scrollTop,scrollY]) +"#)?; + assert_eq!(scroll, "[20,40,100]"); + page.vm_mut().eval("document.documentElement.style.scrollbarGutter = 'stable both-edges'")?; + page.vm_mut().publish_layout_for_test()?; + // Root clientWidth excludes only painted scrollbar UI, while stable + // gutters continue to reduce the root's layout box. + assert_eq!(page.vm_mut().eval("JSON.stringify([document.documentElement.clientWidth,document.documentElement.getBoundingClientRect().width])")?, "[1280,1250]", "explicit viewport gutters remain reserved"); + page.vm_mut().eval("document.documentElement.style.scrollbarGutter = 'auto'")?; + page.vm_mut().eval("document.getElementById('viewport').style.width = '1400px'")?; + page.vm_mut().publish_layout_for_test()?; + assert_eq!(page.vm_mut().eval("JSON.stringify([document.documentElement.clientWidth,document.documentElement.scrollWidth])")?, "[1280,1400]", "real horizontal overflow remains observable"); + Ok::<_, anyhow::Error>(()) + }).await.expect("hidden scrollbar layout"); +} diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index e23da710ee..e31a6bd061 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -680,6 +680,7 @@ mod tests { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index 395da9c8c2..a32e1a0084 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -1083,6 +1083,7 @@ mod tests { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs index 3eafa3a496..98af0487b7 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs @@ -753,6 +753,7 @@ fn parser_owner_boundary_with_live_backend_queues_document_turn_before_runtime_w fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -1044,6 +1045,7 @@ fn parser_connected_head_script_does_not_push_later_head_tokens_into_body() { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -1223,6 +1225,7 @@ fn parser_connected_external_head_script_with_live_head_and_body_mutation_keeps_ fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -1824,6 +1827,7 @@ fn parser_owner_body_stylesheet_pause_preserves_unconsumed_tail_on_live_page_vm( fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs index 8f5431be17..b55624399a 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs @@ -360,6 +360,7 @@ fn external_async_handoff_marks_parser_stream_already_started_on_live_backend() fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -486,6 +487,7 @@ fn blocking_classic_handoff_registers_parser_owned_handle_on_live_backend() { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -622,6 +624,7 @@ fn non_async_post_parse_handoff_registers_pending_before_source_and_seals_withou fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -793,6 +796,7 @@ fn parser_owned_external_module_handoff_starts_pending_script_tree_root_fetch() fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -1110,6 +1114,7 @@ fn parser_owned_inline_importmap_handoff_registers_parser_owned_handle_on_live_b fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, @@ -1277,6 +1282,7 @@ fn parser_owner_style_import_handoff_is_stylesheet_gated_on_live_page_vm() { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs index 9b013e68da..3690f00e07 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs @@ -1154,6 +1154,7 @@ fn parser_connected_head_document_write_keeps_later_head_tokens_in_head() { fetch_subresource_interception_enabled: false, fetch_subresource_interception_resource_type: None, layout_policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, reserved_service_worker_client_id: None, diff --git a/moli-renderer-v8/src/script_vm/document_environment.rs b/moli-renderer-v8/src/script_vm/document_environment.rs index 1a20a4e048..e450dddd60 100644 --- a/moli-renderer-v8/src/script_vm/document_environment.rs +++ b/moli-renderer-v8/src/script_vm/document_environment.rs @@ -198,6 +198,12 @@ impl ScriptVm { self._context_host.borrow_mut().set_layout_policy(policy); } + pub(crate) fn set_scrollbars_hidden(&mut self, hidden: bool) { + self._context_host + .borrow_mut() + .set_scrollbars_hidden(hidden); + } + pub(super) fn sync_document_fonts_for_environment(&mut self) { self.renderer_document_isolate .with_renderer_document_isolate_mut(|isolate| { diff --git a/moli/src/cli.rs b/moli/src/cli.rs index 6728874d4c..12fc92fc92 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -594,6 +594,10 @@ pub struct CommonArgs { )] pub layout: bool, + /// Hide native scrollbars without disabling scrolling. Requires --layout. + #[arg(long, requires = "layout")] + pub hide_scrollbars: bool, + #[arg(short, long = "cookie-file")] pub cookie_file: Vec, diff --git a/moli/src/config.rs b/moli/src/config.rs index f6bc5c8d08..9b1ea3e502 100644 --- a/moli/src/config.rs +++ b/moli/src/config.rs @@ -90,6 +90,10 @@ impl AppConfig { } fn apply_common_args(config: &mut AppConfig, common: &CommonArgs) -> Result<()> { + if common.hide_scrollbars && !common.layout { + bail!("--hide-scrollbars requires --layout or MOLI_LAYOUT=true"); + } + if let Some(log_level) = common.log_level { config.log_filter = log_level.as_tracing_filter().to_owned(); } @@ -188,6 +192,7 @@ fn apply_common_args(config: &mut AppConfig, common: &CommonArgs) -> Result<()> } else { LayoutPolicy::Mock }); + config.browser.set_scrollbars_hidden(common.hide_scrollbars); config.fetch.cookie_files = common.cookie_file.clone(); config.browser.fetch_mut().set_network_blocking( common.block_private_networks, diff --git a/moli/tests/cli.rs b/moli/tests/cli.rs index a8bc9166d1..e6c32f6e71 100644 --- a/moli/tests/cli.rs +++ b/moli/tests/cli.rs @@ -140,6 +140,7 @@ fn parses_explicit_fetch_command_with_compatibility_flags() { resource: false, disable_subframes: false, layout: false, + hide_scrollbars: false, cookie_file: Vec::new(), document_start_script: Vec::new(), document_start_script_file: Vec::new(), @@ -1642,6 +1643,96 @@ fn layout_selects_on_demand_policy_for_fetch_and_serve() { } } +#[test] +fn hide_scrollbars_is_opt_in_for_fetch_and_serve() { + for command in ["fetch", "serve"] { + for hidden in [false, true] { + let mut args = vec!["moli", command, "--layout"]; + if hidden { + args.push("--hide-scrollbars"); + } + if command == "fetch" { + args.push("about:blank"); + } + let cli = Cli::try_parse_from(args).unwrap(); + let config = AppConfig::from_cli(&cli).unwrap(); + assert_eq!(config.browser.scrollbars_hidden(), hidden); + assert_eq!( + config.browser.layout_policy(), + moli_core::LayoutPolicy::OnDemand + ); + } + } +} + +#[test] +fn hide_scrollbars_requires_enabled_layout_from_cli_or_environment() { + for command in ["fetch", "serve"] { + for (case, env_value, explicit_layout, expected) in [ + ("missing", None, false, false), + ("true", Some("true"), false, true), + ("one", Some("1"), false, true), + ("false", Some("false"), false, false), + ("zero", Some("0"), false, false), + ("cli-overrides", Some("false"), true, true), + ] { + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "parse_hide_scrollbars_in_child_process", + "--nocapture", + ]) + .env_remove("MOLI_LAYOUT") + .env("MOLI_TEST_HIDE_SCROLLBARS_COMMAND", command) + .env( + "MOLI_TEST_HIDE_SCROLLBARS_CLI_LAYOUT", + explicit_layout.to_string(), + ) + .env("MOLI_TEST_HIDE_SCROLLBARS_EXPECTED", expected.to_string()); + if let Some(value) = env_value { + child.env("MOLI_LAYOUT", value); + } + let output = child.output().unwrap(); + assert!( + output.status.success(), + "{command}/{case}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } +} + +#[test] +fn parse_hide_scrollbars_in_child_process() { + let Ok(command) = std::env::var("MOLI_TEST_HIDE_SCROLLBARS_COMMAND") else { + return; + }; + let explicit_layout = std::env::var("MOLI_TEST_HIDE_SCROLLBARS_CLI_LAYOUT").unwrap() == "true"; + let expected = std::env::var("MOLI_TEST_HIDE_SCROLLBARS_EXPECTED").unwrap() == "true"; + let mut args = vec!["moli", command.as_str(), "--hide-scrollbars"]; + if explicit_layout { + args.push("--layout"); + } + if command == "fetch" { + args.push("about:blank"); + } + let result = Cli::try_parse_from(args) + .map_err(anyhow::Error::from) + .and_then(|cli| AppConfig::from_cli(&cli)); + if expected { + let config = result.unwrap(); + assert!(config.browser.scrollbars_hidden()); + assert_eq!( + config.browser.layout_policy(), + moli_core::LayoutPolicy::OnDemand + ); + } else { + assert!(result.unwrap_err().to_string().contains("--layout")); + } +} + #[test] fn screencast_interval_requires_layout_and_accepts_positive_milliseconds() { for interval_ms in ["1", "16", "17", "33", "250", "1000", "1500", "4294967295"] { diff --git a/moli/tests/fetch_cli/eval.rs b/moli/tests/fetch_cli/eval.rs index 1926462a79..e12dfdc097 100644 --- a/moli/tests/fetch_cli/eval.rs +++ b/moli/tests/fetch_cli/eval.rs @@ -173,6 +173,53 @@ fn eval_with_layout_publishes_static_geometry() -> Result<()> { Ok(()) } +#[test] +fn eval_hide_scrollbars_removes_gutters_and_preserves_scrolling() -> Result<()> { + let url = "data:text/html,
"; + let script = r#"(() => { + const scroller = document.getElementById('scroller'); + scroller.scrollLeft = 20; + scroller.scrollTop = 40; + window.scrollTo(0, 100); + return { + inner: innerWidth, + client: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + elementClient: [scroller.clientWidth, scroller.clientHeight, scroller.clientLeft], + elementScroll: [scroller.scrollWidth, scroller.scrollHeight], + offsets: [scroller.scrollLeft, scroller.scrollTop, scrollY], + css: [getComputedStyle(scroller).scrollbarWidth, getComputedStyle(scroller).scrollbarGutter] + }; + })()"#; + for hidden in [false, true] { + let mut args = vec!["--layout"]; + if hidden { + args.push("--hide-scrollbars"); + } + let output = run_eval(url, script, &args)?; + assert!(output.status.success(), "{}", clean_output(&output.stderr)); + let value: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let inner = value["inner"].as_u64().expect("viewport width"); + assert_eq!(value["client"], inner - if hidden { 0 } else { 15 }); + assert_eq!(value["scrollWidth"], inner); + assert_eq!( + value["elementClient"], + serde_json::json!(if hidden { + [170, 100, 15] + } else { + [170, 85, 15] + }) + ); + assert_eq!(value["elementScroll"], serde_json::json!([400, 300])); + assert_eq!(value["offsets"], serde_json::json!([20, 40, 100])); + assert_eq!( + value["css"], + serde_json::json!(["auto", "stable both-edges"]) + ); + } + Ok(()) +} + #[test] fn eval_computed_sizes_follow_layout_option_with_or_without_css() -> Result<()> { let url = "data:text/html,
";