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,
";