From 0068a37de104ab17499b93c533d921618380fc23 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 22 Sep 2026 12:03:16 +0800 Subject: [PATCH 1/8] fix(protocol): complete inspector rendering and navigation contracts --- Cargo.lock | 1 + moli-core/src/page/mod.rs | 6 +- moli-core/src/page/settings_support.rs | 16 + moli-page-types/src/lib.rs | 4 +- moli-paint/Cargo.toml | 1 + moli-paint/src/lib.rs | 2 + moli-paint/src/vision.rs | 214 +++++++++++++ .../conn/browser_context/emulation_owner.rs | 31 +- moli-protocol/src/conn/dispatch.rs | 108 +++++-- moli-protocol/src/conn/dispatch_tests.rs | 58 ++++ .../src/conn/state/devtools_session.rs | 1 + moli-protocol/src/conn/state/emulation.rs | 8 +- moli-protocol/src/domains/actions.rs | 7 + moli-protocol/src/domains/browser.rs | 39 ++- moli-protocol/src/domains/css.rs | 22 +- moli-protocol/src/domains/emulation.rs | 74 ++++- moli-protocol/src/domains/emulation/tests.rs | 1 + .../emulation/tests/inspector_surfaces.rs | 208 ++++++++++++ moli-protocol/src/domains/input.rs | 78 ++++- moli-protocol/src/domains/mod.rs | 3 + .../network/main_document_progress/mod.rs | 71 ++--- .../network/main_document_progress/tests.rs | 132 +++++++- moli-protocol/src/domains/overlay.rs | 184 +++++++++++ moli-protocol/src/domains/page.rs | 4 + moli-protocol/src/domains/schema.rs | 59 ++++ moli-renderer-v8/src/lib.rs | 6 +- moli-renderer-v8/src/runtime/mod.rs | 5 +- moli-renderer-v8/src/runtime/page_commands.rs | 4 + moli-renderer-v8/src/runtime/page_dom.rs | 3 +- moli-renderer-v8/src/runtime/page_overlay.rs | 299 ++++++++++++++++++ .../src/runtime/page_screenshot.rs | 64 +++- moli-renderer-v8/src/runtime/page_surface.rs | 4 + moli-renderer-v8/src/runtime/page_vm/mod.rs | 2 + .../page_vm/tests/computed_size/lifecycle.rs | 1 + .../runtime/page_vm/tests/rendering_update.rs | 1 + moli-renderer-v8/src/runtime/tests.rs | 10 + moli-renderer-v8/src/style_engine/retained.rs | 18 +- .../src/style_engine/stylesheet.rs | 1 + .../src/style_engine/world_environment.rs | 8 + .../src/style_engine/world_lifecycle.rs | 4 +- moli/src/cli.rs | 4 + moli/src/fetch_dump/raster.rs | 1 + moli/tests/cli.rs | 2 + 43 files changed, 1634 insertions(+), 135 deletions(-) create mode 100644 moli-paint/src/vision.rs create mode 100644 moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs create mode 100644 moli-protocol/src/domains/overlay.rs create mode 100644 moli-protocol/src/domains/schema.rs create mode 100644 moli-renderer-v8/src/runtime/page_overlay.rs diff --git a/Cargo.lock b/Cargo.lock index 48764e4dba..e583dd4e22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,6 +2904,7 @@ dependencies = [ "anyrender", "anyrender_svg", "anyrender_vello_cpu", + "image", "moli-image", "moli-layout", "peniko", diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index c0ef0acd6c..e8bafb7e67 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -104,7 +104,7 @@ pub use moli_renderer_v8::{ RendererDomFocusOutcome, RendererDomMutationEvent, RendererDomMutationEventBatch, RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken, - RendererInputDispatchOutcome, RendererInspectorCommandRoute, + RendererInputDispatchOutcome, RendererInspectorCommandRoute, RendererInspectorOverlayCommand, RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, RendererLifecycleTerminationStamp, @@ -131,8 +131,8 @@ pub use moli_renderer_v8::{ RendererServiceWorkerTargetInfo, RendererServiceWorkerVersionStatus, RendererSetDocumentContentResult, RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererSyntheticResponseBody, - RendererTextSearchMatch, RendererTouchPoint, RendererVisualStateToken, - RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, + RendererTextSearchMatch, RendererTouchPoint, RendererVisionDeficiency, + RendererVisualStateToken, RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, }; pub use moli_renderer_v8::{ RendererAppManifest, RendererAppManifestDisplayMode, RendererAppManifestError, diff --git a/moli-core/src/page/settings_support.rs b/moli-core/src/page/settings_support.rs index 6a2845f4fd..a42d546fe4 100644 --- a/moli-core/src/page/settings_support.rs +++ b/moli-core/src/page/settings_support.rs @@ -331,6 +331,22 @@ impl Page { .await } + pub fn start_set_inspector_overlay( + &self, + inspector_session_id: Option, + command: super::RendererInspectorOverlayCommand, + ) -> Result { + self.start_page_command(RendererPageCommand::SetInspectorOverlay { + inspector_session_id, + command, + }) + } + + pub fn finish_set_inspector_overlay(&mut self, completion: CompletedPageCommand) -> Result<()> { + let reply = self.finish_page_command(completion); + expect_page_reply!(reply, "set inspector overlay", "a unit reply", RendererPageReply::Unit => Ok(()),) + } + pub fn start_set_emulated_media( &self, overrides: &EmulatedMediaOverrides, diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index e7664253cd..e838c73aaa 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -3111,8 +3111,10 @@ pub struct RuntimeIsolatedWorldDefinition { pub grant_universal_access: bool, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct EmulatedMediaOverrides { + /// CSS environment preference, independent of media query feature overrides. + pub preferred_text_scale: Option, pub media: Option, pub color_scheme: Option, pub reduced_motion: Option, diff --git a/moli-paint/Cargo.toml b/moli-paint/Cargo.toml index 858d4044a8..45ae6bceec 100644 --- a/moli-paint/Cargo.toml +++ b/moli-paint/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" license = "MIT OR Apache-2.0" [dependencies] +image = { version = "0.25.10", default-features = false } anyrender = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43" } anyrender_svg = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["text"] } anyrender_vello_cpu = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["bitmap-glyphs", "filters"] } diff --git a/moli-paint/src/lib.rs b/moli-paint/src/lib.rs index 4587192289..73c4c94833 100644 --- a/moli-paint/src/lib.rs +++ b/moli-paint/src/lib.rs @@ -7,6 +7,8 @@ //! layout state is copied into this crate. mod raster; +mod vision; +pub use vision::VisionDeficiency; pub use moli_image::RgbaImage as RasterImage; pub use raster::{ diff --git a/moli-paint/src/vision.rs b/moli-paint/src/vision.rs new file mode 100644 index 0000000000..336931ed6a --- /dev/null +++ b/moli-paint/src/vision.rs @@ -0,0 +1,214 @@ +//! Display-wide vision emulation in linear RGB, matching SVG filter color space. + +use crate::{MAX_TRANSIENT_RASTER_BYTES, PaintError, RasterImage}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VisionDeficiency { + #[default] + None, + BlurredVision, + ReducedContrast, + Achromatopsia, + Deuteranopia, + Protanopia, + Tritanopia, +} + +impl VisionDeficiency { + pub fn apply(self, raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { + if self == Self::None { + return Ok(()); + } + let expected = (raster.width as usize) + .checked_mul(raster.height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(PaintError::BufferLengthOverflow { + width: raster.width, + height: raster.height, + })?; + if raster.rgba.len() != expected { + return Err(PaintError::UnexpectedBufferLength { + expected, + actual: raster.rgba.len(), + }); + } + if self == Self::BlurredVision { + return blur(raster, device_scale); + } + // Machado et al. (2009), full-severity matrices rounded as in Blink's + // core/css/vision_deficiency.cc. Alpha is not affected by color emulation. + let matrix = match self { + Self::Achromatopsia => [[0.213, 0.715, 0.072]; 3], + Self::Deuteranopia => [ + [0.367, 0.861, -0.228], + [0.280, 0.673, 0.047], + [-0.012, 0.043, 0.969], + ], + Self::Protanopia => [ + [0.152, 1.053, -0.205], + [0.115, 0.786, 0.099], + [-0.004, -0.048, 1.052], + ], + Self::Tritanopia => [ + [1.256, -0.077, -0.179], + [-0.078, 0.931, 0.148], + [0.005, 0.691, 0.304], + ], + Self::ReducedContrast => [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + Self::None | Self::BlurredVision => unreachable!(), + }; + let offset = if self == Self::ReducedContrast { + 0.5 + } else { + 0.0 + }; + for pixel in raster.rgba.chunks_exact_mut(4) { + let rgb = [pixel[0], pixel[1], pixel[2]].map(srgb_to_linear); + for (channel, row) in pixel[..3].iter_mut().zip(matrix) { + let value = row + .into_iter() + .zip(rgb) + .map(|(weight, value)| weight * value) + .sum::() + + offset; + *channel = linear_to_srgb(value); + } + } + Ok(()) + } +} + +fn srgb_to_linear(value: u8) -> f32 { + let value = f32::from(value) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(value: f32) -> u8 { + let value = value.clamp(0.0, 1.0); + let value = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (value * 255.0).round() as u8 +} + +// Filter in premultiplied linear RGB so transparent pixels cannot bleed their +// hidden color into visible neighbors. Charge the source, intermediate and +// destination float images against the same budget as paint filters. +fn blur(raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { + if !(device_scale * 2.0).is_normal() || device_scale <= 0.0 { + return Err(PaintError::InvalidCaptureDeviceScale { device_scale }); + } + let padding = (6.0 * device_scale).ceil() as u32; + let (width, height) = raster + .width + .checked_add(padding.saturating_mul(2)) + .zip(raster.height.checked_add(padding.saturating_mul(2))) + .ok_or(PaintError::BufferLengthOverflow { + width: raster.width, + height: raster.height, + })?; + let required_bytes = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(48) + .saturating_add(raster.rgba.len()); + if required_bytes > MAX_TRANSIENT_RASTER_BYTES { + return Err(PaintError::TransientRasterBudgetExceeded { + required_bytes, + max_bytes: MAX_TRANSIENT_RASTER_BYTES, + }); + } + if raster.width == 0 || raster.height == 0 { + return Ok(()); + } + let image = image::Rgba32FImage::from_fn(width, height, |x, y| { + if x < padding || y < padding || x >= padding + raster.width || y >= padding + raster.height + { + return image::Rgba([0.0; 4]); + } + let (x, y) = (x - padding, y - padding); + let offset = ((y as usize) * raster.width as usize + x as usize) * 4; + let pixel = &raster.rgba[offset..offset + 4]; + let alpha = f32::from(pixel[3]) / 255.0; + image::Rgba([ + srgb_to_linear(pixel[0]) * alpha, + srgb_to_linear(pixel[1]) * alpha, + srgb_to_linear(pixel[2]) * alpha, + alpha, + ]) + }); + let blurred = image::imageops::blur(&image, 2.0 * device_scale); + for (index, output) in raster.rgba.chunks_exact_mut(4).enumerate() { + let input = blurred.get_pixel( + index as u32 % raster.width + padding, + index as u32 / raster.width + padding, + ); + let alpha = input[3]; + for (out, value) in output[..3].iter_mut().zip(input.0) { + *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + } + output[3] = (alpha * 255.0).round() as u8; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_filters_preserve_alpha_and_neutral_colors() { + for filter in [ + VisionDeficiency::Achromatopsia, + VisionDeficiency::Deuteranopia, + VisionDeficiency::Protanopia, + VisionDeficiency::Tritanopia, + ] { + let mut raster = RasterImage { + width: 3, + height: 1, + rgba: vec![0, 0, 0, 255, 128, 128, 128, 64, 255, 255, 255, 0], + }; + filter.apply(&mut raster, 1.0).unwrap(); + assert_eq!( + raster.rgba, + [0, 0, 0, 255, 128, 128, 128, 64, 255, 255, 255, 0] + ); + } + } + + #[test] + fn blur_does_not_expose_transparent_pixel_colors() { + let mut raster = RasterImage { + width: 2, + height: 1, + rgba: vec![255, 0, 0, 0, 0, 0, 255, 255], + }; + VisionDeficiency::BlurredVision + .apply(&mut raster, 1.0) + .unwrap(); + for pixel in raster.rgba.chunks_exact(4) { + assert_eq!(&pixel[..3], &[0, 0, 255]); + assert!(pixel[3] > 0 && pixel[3] < 255); + } + } + + #[test] + fn invalid_buffer_is_rejected_without_mutation() { + let mut raster = RasterImage { + width: 2, + height: 1, + rgba: vec![1, 2, 3, 4], + }; + assert!(matches!( + VisionDeficiency::BlurredVision.apply(&mut raster, 1.0), + Err(PaintError::UnexpectedBufferLength { .. }) + )); + assert_eq!(raster.rgba, [1, 2, 3, 4]); + } +} diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index e3600769c0..66551ad705 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -12,6 +12,14 @@ pub(crate) struct TargetEmulationStateUpdate<'a> { } impl TargetEmulationStateUpdate<'_> { + pub(crate) fn set_vision_deficiency( + &mut self, + vision: moli_core::page::RendererVisionDeficiency, + ) { + self.raw.vision_deficiency = vision; + self.effective.vision_deficiency = vision; + } + pub(crate) fn set_default_background_color(&mut self, color: Option<[u8; 4]>) { self.raw.default_background_color = color; self.effective.default_background_color = color; @@ -33,8 +41,15 @@ impl TargetEmulationStateUpdate<'_> { self.effective.geolocation_override = geolocation_override; } - pub(crate) fn set_emulated_media(&mut self, emulated_media: EmulatedMediaOverrides) { + pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { + self.raw.emulated_media.preferred_text_scale = scale; + self.effective.emulated_media.preferred_text_scale = scale; + } + + pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { + emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); + emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; self.effective.emulated_media = emulated_media; } @@ -145,6 +160,20 @@ impl TargetSessionOwnerRef<'_> { } impl CdpConnection { + pub(crate) fn vision_deficiency_for_owner( + &self, + owner: &crate::conn::CommandOwnerScope, + ) -> moli_core::page::RendererVisionDeficiency { + self.target_session_owner_ref_for_owner(owner) + .and_then(|owner| { + owner + .browser_context + .page_target(&owner.target_id) + .map(|target| target.effective_emulation_state.vision_deficiency) + }) + .unwrap_or_default() + } + pub(crate) fn default_background_color_for_owner( &self, owner: &crate::conn::CommandOwnerScope, diff --git a/moli-protocol/src/conn/dispatch.rs b/moli-protocol/src/conn/dispatch.rs index 1dcc40e817..ac7742d122 100644 --- a/moli-protocol/src/conn/dispatch.rs +++ b/moli-protocol/src/conn/dispatch.rs @@ -5,6 +5,7 @@ use serde_json::json; use crate::domains::command_output::CommandOutputPlan; use super::*; +use crate::domains::schema::CdpDomain; pub struct PendingCdpCommandDispatch { inner: PendingCdpCommandDispatchKind, @@ -49,6 +50,7 @@ enum PendingCdpCommandDispatchKind { Network(crate::domains::network::PendingNetworkCommandDispatch), Fetch(crate::domains::fetch::PendingFetchCommandDispatch), Io(Box), + Overlay(crate::domains::overlay::PendingOverlayCommandDispatch), Security(crate::domains::security::PendingSecurityCommandDispatch), Browser(crate::domains::browser::PendingBrowserCommandDispatch), Target(crate::domains::target::PendingTargetCommandDispatch), @@ -72,6 +74,7 @@ enum CompletedCdpCommandDispatchKind { Network(crate::domains::network::CompletedNetworkCommandDispatch), Fetch(crate::domains::fetch::CompletedFetchCommandDispatch), Io(Box), + Overlay(crate::domains::overlay::CompletedOverlayCommandDispatch), Security(crate::domains::security::CompletedSecurityCommandDispatch), Browser(crate::domains::browser::CompletedBrowserCommandDispatch), Target(crate::domains::target::CompletedTargetCommandDispatch), @@ -97,6 +100,7 @@ impl PendingCdpCommandDispatchKind { Self::Network(_) => "Network", Self::Fetch(_) => "Fetch", Self::Io(_) => "IO", + Self::Overlay(_) => "Overlay", Self::Security(_) => "Security", Self::Browser(_) => "Browser", Self::Target(_) => "Target", @@ -124,6 +128,7 @@ impl CompletedCdpCommandDispatchKind { Self::Network(_) => "Network", Self::Fetch(_) => "Fetch", Self::Io(_) => "IO", + Self::Overlay(_) => "Overlay", Self::Security(_) => "Security", Self::Browser(_) => "Browser", Self::Target(_) => "Target", @@ -316,6 +321,9 @@ impl PendingCdpCommandDispatch { PendingCdpCommandDispatchKind::Io(pending) => { CompletedCdpCommandDispatchKind::Io(Box::new(Box::pin(pending.wait()).await)) } + PendingCdpCommandDispatchKind::Overlay(pending) => { + CompletedCdpCommandDispatchKind::Overlay(Box::pin(pending.wait()).await) + } PendingCdpCommandDispatchKind::Security(pending) => { CompletedCdpCommandDispatchKind::Security(Box::pin(pending.wait()).await) } @@ -521,8 +529,8 @@ impl CdpConnection { command_context.terminal_response_delivery_override(), ); self.record_tracing_command(cmd.method, cmd.session_id); - let step = match domain { - "Browser" => Some( + let step = match domain.parse::().ok() { + Some(CdpDomain::Browser) => Some( match crate::domains::browser::try_start_browser_command_dispatch(self, &cmd) { crate::domains::browser::BrowserCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Browser(pending)) @@ -532,7 +540,7 @@ impl CdpConnection { } }, ), - "Runtime" => crate::domains::runtime::try_start_runtime_command_dispatch(self, &cmd) + Some(CdpDomain::Runtime) => crate::domains::runtime::try_start_runtime_command_dispatch(self, &cmd) .map(|step| match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)) @@ -541,7 +549,7 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - "HeapProfiler" => { + Some(CdpDomain::HeapProfiler) => { crate::domains::heap_profiler::try_start_heap_profiler_command_dispatch( self, &cmd, @@ -555,7 +563,7 @@ impl CdpConnection { } }) } - "Profiler" => crate::domains::profiler::try_start_profiler_command_dispatch(self, &cmd) + Some(CdpDomain::Profiler) => crate::domains::profiler::try_start_profiler_command_dispatch(self, &cmd) .map(|step| match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)) @@ -564,7 +572,7 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - "Debugger" => { + Some(CdpDomain::Debugger) => { crate::domains::debugger::try_start_debugger_command_dispatch( self, &cmd, @@ -583,7 +591,7 @@ impl CdpConnection { } }) } - "Accessibility" => { + Some(CdpDomain::Accessibility) => { crate::domains::accessibility::try_start_accessibility_command_dispatch(self, &cmd) .map(|step| { match step { @@ -596,7 +604,7 @@ impl CdpConnection { } }) } - "Input" => Some( + Some(CdpDomain::Input) => Some( match crate::domains::input::try_start_input_command_dispatch(self, &cmd) { crate::domains::input::InputCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Input(pending)) @@ -606,7 +614,7 @@ impl CdpConnection { } }, ), - "CSS" => crate::domains::css::try_start_css_command_dispatch(self, &cmd).map(|step| { + Some(CdpDomain::Css) => crate::domains::css::try_start_css_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::css::CssCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Css(pending)) @@ -616,7 +624,13 @@ impl CdpConnection { } } }), - "DOM" => crate::domains::dom::try_start_dom_command_dispatch(self, &cmd).map(|step| { + Some(CdpDomain::Dom) if matches!(cmd.action, "highlightNode" | "highlightRect" | "hideHighlight") => Some( + match crate::domains::overlay::try_start_overlay_command_dispatch(self, &cmd) { + crate::domains::overlay::OverlayCommandTaskStep::Pending(pending) => self.pending_step(PendingCdpCommandDispatchKind::Overlay(pending)), + crate::domains::overlay::OverlayCommandTaskStep::Complete(plan) => self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id), + } + ), + Some(CdpDomain::Dom) => crate::domains::dom::try_start_dom_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::dom::DomCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Dom(*pending)) @@ -626,7 +640,7 @@ impl CdpConnection { } } }), - "DOMStorage" => Some( + Some(CdpDomain::DomStorage) => Some( match crate::domains::dom_storage::try_start_dom_storage_command_dispatch( self, &cmd, ) { @@ -638,7 +652,7 @@ impl CdpConnection { } }, ), - "Console" => { + Some(CdpDomain::Console) => { crate::domains::console::try_start_console_command_dispatch( self, &cmd, @@ -653,13 +667,13 @@ impl CdpConnection { }, ) } - "Emulation" | "Network" if let Some(step) = crate::domains::runtime::try_start_worker_emulation_command_dispatch(self, &cmd) => { + Some(CdpDomain::Emulation | CdpDomain::Network) if let Some(step) = crate::domains::runtime::try_start_worker_emulation_command_dispatch(self, &cmd) => { Some(match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)), crate::domains::runtime::RuntimeCommandTaskStep::Complete(plan) => self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id), }) } - "Network" => Some( + Some(CdpDomain::Network) => Some( match crate::domains::network::start_network_domain_command_dispatch(self, &cmd) { crate::domains::network::NetworkDomainCommandTaskStep::Network( crate::domains::network::NetworkCommandTaskStep::Pending(pending), @@ -678,7 +692,7 @@ impl CdpConnection { } }, ), - "Target" => { + Some(CdpDomain::Target) => { crate::domains::target::try_start_target_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::target::TargetCommandTaskStep::Pending(pending) => { @@ -690,7 +704,7 @@ impl CdpConnection { } }) } - "Tracing" => Some( + Some(CdpDomain::Tracing) => Some( match crate::domains::tracing::try_start_tracing_command_dispatch(self, &cmd) { crate::domains::tracing::TracingCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Tracing(*pending)) @@ -704,7 +718,7 @@ impl CdpConnection { ), }, ), - "Fetch" => { + Some(CdpDomain::Fetch) => { crate::domains::fetch::try_start_fetch_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::fetch::FetchCommandTaskStep::Pending(pending) => { @@ -716,7 +730,7 @@ impl CdpConnection { } }) } - "Page" => { + Some(CdpDomain::Page) => { crate::domains::page::try_start_page_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::page::PageCommandTaskStep::Pending(pending) => { @@ -728,15 +742,15 @@ impl CdpConnection { } }) } - "Inspector" => { + Some(CdpDomain::Inspector) => { let plan = crate::domains::inspector::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Log" => { + Some(CdpDomain::Log) => { let plan = crate::domains::log::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Storage" => Some( + Some(CdpDomain::Storage) => Some( match crate::domains::storage::try_start_storage_command_dispatch(self, &cmd) { crate::domains::storage::StorageCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Storage(pending)) @@ -746,7 +760,7 @@ impl CdpConnection { } }, ), - "DOMSnapshot" => { + Some(CdpDomain::DomSnapshot) => { Some( match crate::domains::dom_snapshot::try_start_dom_snapshot_command_dispatch( self, &cmd, @@ -765,7 +779,16 @@ impl CdpConnection { }, ) } - "Security" => Some( + Some(CdpDomain::Overlay) => Some( + match crate::domains::overlay::try_start_overlay_command_dispatch(self, &cmd) { + crate::domains::overlay::OverlayCommandTaskStep::Pending(pending) => { + self.pending_step(PendingCdpCommandDispatchKind::Overlay(pending)) + } + crate::domains::overlay::OverlayCommandTaskStep::Complete(plan) => { + self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) + } + }, + ), Some(CdpDomain::Security) => Some( match crate::domains::security::try_start_security_command_dispatch(self, &cmd) { crate::domains::security::SecurityCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Security(pending)) @@ -775,11 +798,11 @@ impl CdpConnection { } }, ), - "ServiceWorker" => { + Some(CdpDomain::ServiceWorker) => { let plan = crate::domains::service_worker::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "IO" => Some( + Some(CdpDomain::Io) => Some( match crate::domains::io::try_start_io_command_dispatch(self, &cmd) { crate::domains::io::IoCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Io(pending)) @@ -789,7 +812,7 @@ impl CdpConnection { } }, ), - "Autofill" => Some( + Some(CdpDomain::Autofill) => Some( match crate::domains::autofill::try_start_autofill_command_dispatch(self, &cmd) { crate::domains::autofill::AutofillCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Autofill(pending)) @@ -804,23 +827,23 @@ impl CdpConnection { } }, ), - "Audits" => { + Some(CdpDomain::Audits) => { let plan = crate::domains::audits::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "SystemInfo" => { + Some(CdpDomain::SystemInfo) => { let plan = crate::domains::system_info::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "WebAuthn" => { + Some(CdpDomain::WebAuthn) => { let plan = crate::domains::webauthn::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "WebMCP" => { + Some(CdpDomain::WebMCP) => { let plan = crate::domains::web_mcp::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Performance" => Some( + Some(CdpDomain::Performance) => Some( match crate::domains::performance::try_start_performance_command_dispatch( self, &cmd, @@ -834,7 +857,7 @@ impl CdpConnection { } }, ), - "DOMDebugger" => Some( + Some(CdpDomain::DomDebugger) => Some( match crate::domains::dom_debugger::try_start_dom_debugger_command_dispatch( self, &cmd, ) { @@ -846,7 +869,7 @@ impl CdpConnection { } }, ), - "Emulation" => crate::domains::emulation::try_start_emulation_command_dispatch( + Some(CdpDomain::Emulation) => crate::domains::emulation::try_start_emulation_command_dispatch( self, &cmd, ) .map(|step| match step { @@ -857,7 +880,11 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - _ => { + Some(CdpDomain::Schema) => { + let plan = crate::domains::schema::command_output_plan(&cmd); + Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) + } + None => { Some(self.complete_with_output_plan( command_context, CommandOutputPlan::error(-32601, "Unknown domain"), @@ -1180,6 +1207,19 @@ impl CdpConnection { session_id.as_deref(), ); } + CompletedCdpCommandDispatchKind::Overlay(completed) => { + let command_id = completed.command_id(); + let session_id = completed.session_id().map(str::to_owned); + let plan = + crate::domains::overlay::complete_pending_overlay_command(self, completed); + append_command_output_plan( + &mut out, + command_context, + plan, + command_id, + session_id.as_deref(), + ); + } CompletedCdpCommandDispatchKind::Security(completed) => { let command_id = completed.command_id(); let session_id = completed.session_id().map(str::to_owned); diff --git a/moli-protocol/src/conn/dispatch_tests.rs b/moli-protocol/src/conn/dispatch_tests.rs index 90733366fa..c4bd58a69e 100644 --- a/moli-protocol/src/conn/dispatch_tests.rs +++ b/moli-protocol/src/conn/dispatch_tests.rs @@ -6909,6 +6909,64 @@ fn command_dispatch_completes_browser_sync_commands() { } } +#[test] +fn browser_window_bounds_readback_preserves_unspecified_dimensions() { + let mut conn = CdpConnection::new(); + let request = |conn: &mut CdpConnection, method: &str, params: Value| { + let raw = json!({"id": 1, "method": method, "params": params}).to_string(); + complete_messages(conn.start_command_dispatch(&raw)).remove(0) + }; + let window = request(&mut conn, "Browser.getWindowForTarget", json!({})); + let id = &window["result"]["windowId"]; + for bounds in [json!({"width": 900, "height": 640}), json!({"left": 25})] { + let response = request( + &mut conn, + "Browser.setWindowBounds", + json!({ + "windowId": id, "bounds": bounds + }), + ); + assert!(response.get("error").is_none(), "{response}"); + } + let response = request( + &mut conn, + "Browser.getWindowBounds", + json!({"windowId": id}), + ); + let bounds = &response["result"]["bounds"]; + assert_eq!(bounds["width"], 900, "{response}"); + assert_eq!(bounds["height"], 640); + assert_eq!(bounds["left"], 25); + let response = request( + &mut conn, + "Browser.getWindowBounds", + json!({"windowId": -1}), + ); + assert!(response.get("error").is_some(), "{response}"); +} + +#[test] +fn schema_discovery_lists_unique_dispatchable_domains() { + let mut conn = CdpConnection::new(); + let messages = + complete_messages(conn.start_command_dispatch(r#"{"id":1,"method":"Schema.getDomains"}"#)); + let domains = messages[0]["result"]["domains"] + .as_array() + .expect("domains"); + let mut names = std::collections::HashSet::new(); + for domain in domains { + let name = domain["name"].as_str().expect("domain name"); + assert!(names.insert(name)); + assert_eq!(domain["version"], "1.3"); + let raw = json!({"id":2,"method":format!("{name}.nonexistent")}).to_string(); + let response = complete_messages(conn.start_command_dispatch(&raw)); + assert_ne!(response[0]["error"]["message"], "Unknown domain", "{name}"); + } + for required in ["Page", "DOM", "CSS", "Network", "Runtime", "Schema"] { + assert!(names.contains(required)); + } +} + #[test] fn command_dispatch_completes_browser_owner_commands_without_legacy_fallback() { let mut conn = CdpConnection::new(); diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index 5cf0535dfe..e6abc999f0 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -677,6 +677,7 @@ pub(crate) struct DevToolsNetworkSessionState { #[derive(Clone, Debug, PartialEq, Default)] pub(crate) struct DevToolsEmulationSessionState { pub(crate) default_background_color: Option<[u8; 4]>, + pub(crate) vision_deficiency: moli_core::page::RendererVisionDeficiency, // UA, Accept-Language, and platform are independent handler contributions. pub(crate) browser_identity_override: Option, pub(crate) network_conditions: Option, diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index 1c6cf993d5..c43ff9bdb4 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -89,8 +89,10 @@ impl EmulatedGeolocationOverrideState { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct EmulatedMediaOverrides { + /// CSS environment preference, independent of media query feature overrides. + pub preferred_text_scale: Option, pub media: Option, pub color_scheme: Option, pub reduced_motion: Option, @@ -102,6 +104,7 @@ pub struct EmulatedMediaOverrides { impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { + preferred_text_scale: value.preferred_text_scale, media: value.media, color_scheme: value.color_scheme, reduced_motion: value.reduced_motion, @@ -115,6 +118,7 @@ impl From for moli_core::page::EmulatedMediaOverrides { impl From<&EmulatedMediaOverrides> for moli_core::page::EmulatedMediaOverrides { fn from(value: &EmulatedMediaOverrides) -> Self { Self { + preferred_text_scale: value.preferred_text_scale, media: value.media.clone(), color_scheme: value.color_scheme.clone(), reduced_motion: value.reduced_motion.clone(), @@ -133,6 +137,7 @@ impl From<&EmulatedMediaOverrides> for moli_core::page::EmulatedMediaOverrides { #[derive(Debug, Clone, PartialEq, Default)] pub(crate) struct EffectiveTargetEmulationState { pub(crate) default_background_color: Option<[u8; 4]>, + pub(crate) vision_deficiency: moli_core::page::RendererVisionDeficiency, pub(crate) network_conditions: Option, pub(crate) geolocation_override: Option, pub(crate) emulated_media: EmulatedMediaOverrides, @@ -185,6 +190,7 @@ impl EffectiveTargetEmulationState { // target-wide side effect while retaining each handler's raw copy. self.emulated_media = EmulatedMediaOverrides::default(); self.default_background_color = None; + self.vision_deficiency = Default::default(); if raw.emulated_device_metrics.is_some() { self.emulated_device_metrics = None; } diff --git a/moli-protocol/src/domains/actions.rs b/moli-protocol/src/domains/actions.rs index 82f6f7b1fe..1b8b6830d6 100644 --- a/moli-protocol/src/domains/actions.rs +++ b/moli-protocol/src/domains/actions.rs @@ -48,7 +48,9 @@ pub(crate) enum AutofillAction { #[strum(serialize_all = "camelCase")] pub(crate) enum BrowserAction { GetVersion, + GetBrowserCommandLine, GetWindowForTarget, + GetWindowBounds, SetWindowBounds, SetDownloadBehavior, CancelDownload, @@ -190,6 +192,9 @@ pub(crate) enum EmulationAction { SetUserAgentOverride, SetEmulatedMedia, SetDefaultBackgroundColorOverride, + SetEmulatedVisionDeficiency, + #[strum(serialize = "setEmulatedOSTextScale")] + SetEmulatedOSTextScale, } #[derive(Clone, Copy, Debug, Eq, PartialEq, strum::EnumString)] @@ -240,6 +245,7 @@ pub(crate) enum InputAction { DispatchTouchEvent, EmulateTouchFromMouseEvent, SynthesizeTapGesture, + SynthesizeScrollGesture, } impl InputAction { @@ -252,6 +258,7 @@ impl InputAction { | Self::InsertText | Self::DispatchTouchEvent | Self::EmulateTouchFromMouseEvent + | Self::SynthesizeScrollGesture | Self::SynthesizeTapGesture ) } diff --git a/moli-protocol/src/domains/browser.rs b/moli-protocol/src/domains/browser.rs index 2825e8caf3..3c2c678855 100644 --- a/moli-protocol/src/domains/browser.rs +++ b/moli-protocol/src/domains/browser.rs @@ -1,5 +1,5 @@ use chromiumoxide_cdp::cdp::browser_protocol::browser::{ - CancelDownloadParams, SetWindowBoundsParams, + CancelDownloadParams, GetWindowBoundsParams, SetWindowBoundsParams, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -157,9 +157,15 @@ pub(crate) fn try_start_browser_command_dispatch( }; match action { BrowserAction::GetVersion => BrowserCommandTaskStep::Complete(get_version(conn)), + BrowserAction::GetBrowserCommandLine => { + BrowserCommandTaskStep::Complete(get_browser_command_line()) + } BrowserAction::GetWindowForTarget => { BrowserCommandTaskStep::Complete(get_window_for_target(conn)) } + BrowserAction::GetWindowBounds => { + BrowserCommandTaskStep::Complete(get_window_bounds(conn, cmd)) + } BrowserAction::SetWindowBounds => { BrowserCommandTaskStep::Complete(set_window_bounds(conn, cmd)) } @@ -186,6 +192,19 @@ fn get_version(conn: &CdpConnection) -> CommandOutputPlan { })) } +fn get_browser_command_line() -> CommandOutputPlan { + let arguments: Vec<_> = std::env::args_os().collect(); + if !arguments.iter().any(|arg| arg == "--enable-automation") { + return CommandOutputPlan::error( + -32000, + "Command line not returned because --enable-automation not set.", + ); + } + CommandOutputPlan::result(json!({ + "arguments": arguments.iter().map(|arg| arg.to_string_lossy()).collect::>() + })) +} + fn bounds_json(bounds: &BrowserWindowBounds) -> Value { let mut value = json!({ "windowState": bounds.window_state, @@ -215,6 +234,16 @@ fn get_window_for_target(conn: &CdpConnection) -> CommandOutputPlan { })) } +fn get_window_bounds(conn: &CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + let Ok(Some(params)) = cmd.get_params::() else { + return CommandOutputPlan::error(-32602, "InvalidParams"); + }; + if *params.window_id.inner() != i64::from(DEV_TOOLS_WINDOW_ID) { + return CommandOutputPlan::error(-32602, "InvalidParams"); + } + CommandOutputPlan::result(json!({"bounds": bounds_json(&conn.window_bounds)})) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SetDownloadBehaviorParams { @@ -301,10 +330,10 @@ fn set_window_bounds(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPl return CommandOutputPlan::error(-32602, "InvalidParams"); } }; - conn.window_bounds.left = left; - conn.window_bounds.top = top; - conn.window_bounds.width = width; - conn.window_bounds.height = height; + conn.window_bounds.left = left.or(conn.window_bounds.left); + conn.window_bounds.top = top.or(conn.window_bounds.top); + conn.window_bounds.width = width.or(conn.window_bounds.width); + conn.window_bounds.height = height.or(conn.window_bounds.height); if let Some(window_state) = params.bounds.window_state { conn.window_bounds.window_state = window_state.as_ref().to_owned(); } diff --git a/moli-protocol/src/domains/css.rs b/moli-protocol/src/domains/css.rs index b022d26563..07551740ae 100644 --- a/moli-protocol/src/domains/css.rs +++ b/moli-protocol/src/domains/css.rs @@ -626,12 +626,18 @@ pub(crate) fn complete_pending_css_command( } fn computed_style_command_output_plan(properties: Vec<(String, String)>) -> CommandOutputPlan { - CommandOutputPlan::result(json!({ - "computedStyle": properties - .into_iter() - .map(|(name, value)| json!({ "name": name, "value": value })) - .collect::>() - })) + use chromiumoxide_cdp::cdp::browser_protocol::css::{ + ComputedStyleExtraFields, CssComputedStyleProperty, GetComputedStyleForNodeReturns, + }; + let result = GetComputedStyleForNodeReturns::new( + properties + .into_iter() + .map(|(name, value)| CssComputedStyleProperty::new(name, value)) + .collect(), + // Stylo does not implement Blink's base appearance rendering mode. + ComputedStyleExtraFields::new(false), + ); + CommandOutputPlan::result(json!(result)) } fn inline_style_result_from_attributes_resolution( @@ -1088,6 +1094,10 @@ mod tests { .await; let computed = take_response_by_id(&mut ctx, 204); assert_eq!(computed["sessionId"], "SID-background"); + let typed: chromiumoxide_cdp::cdp::browser_protocol::css::GetComputedStyleForNodeReturns = + serde_json::from_value(computed["result"].clone()) + .expect("computed style must be consumable by typed CDP clients"); + assert!(!typed.extra_fields.is_appearance_base); let computed_properties = computed["result"]["computedStyle"] .as_array() .expect("computedStyle array"); diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 8f80897bfb..5e011287d2 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -236,6 +236,12 @@ pub(crate) fn try_start_emulation_command_dispatch( Some(start_user_agent_override_command(conn, cmd)) } Some(EmulationAction::SetEmulatedMedia) => Some(start_emulated_media_command(conn, cmd)), + Some(EmulationAction::SetEmulatedOSTextScale) => { + Some(start_os_text_scale_command(conn, cmd)) + } + Some(EmulationAction::SetEmulatedVisionDeficiency) => Some( + EmulationCommandTaskStep::Complete(vision_deficiency_command(conn, cmd)), + ), Some(EmulationAction::SetDefaultBackgroundColorOverride) => Some( EmulationCommandTaskStep::Complete(default_background_color_command(conn, cmd)), ), @@ -832,7 +838,48 @@ fn start_emulated_media_command( "BrowserContextNotLoaded", )); } - let page_overrides: moli_core::page::EmulatedMediaOverrides = (&overrides).into(); + start_style_environment_update(conn, cmd) +} + +fn start_os_text_scale_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> EmulationCommandTaskStep { + #[derive(serde::Deserialize, Default)] + struct TextScaleParams { + scale: Option, + } + let params = match cmd.get_params::() { + Ok(params) => params.unwrap_or_default(), + Err(error) => { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-32602, error)); + } + }; + if params + .scale + .is_some_and(|scale| !scale.is_finite() || scale <= 0.0) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error( + -32602, + "Text scale must be finite and positive", + )); + } + if let Err(error) = + page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + state.set_preferred_text_scale(params.scale); + }) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); + } + start_style_environment_update(conn, cmd) +} + +fn start_style_environment_update( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> EmulationCommandTaskStep { + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + let page_overrides = conn.navigation_load_inputs_for_owner(&owner).emulated_media; let pending = if emulation_command_is_context_wide(conn, cmd.session_id) { match start_context_emulated_media_page_commands(conn, &page_overrides) { Ok(pending) => pending, @@ -2856,3 +2903,28 @@ fn finish_emulation_page_operation( } } } + +fn vision_deficiency_command(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + use chromiumoxide_cdp::cdp::browser_protocol::emulation::{ + SetEmulatedVisionDeficiencyParams, SetEmulatedVisionDeficiencyType, + }; + use moli_core::page::RendererVisionDeficiency as Vision; + let Ok(Some(params)) = cmd.get_params::() else { + return CommandOutputPlan::error(-32602, "Invalid vision deficiency"); + }; + let vision = match params.r#type { + SetEmulatedVisionDeficiencyType::None => Vision::None, + SetEmulatedVisionDeficiencyType::BlurredVision => Vision::BlurredVision, + SetEmulatedVisionDeficiencyType::ReducedContrast => Vision::ReducedContrast, + SetEmulatedVisionDeficiencyType::Achromatopsia => Vision::Achromatopsia, + SetEmulatedVisionDeficiencyType::Deuteranopia => Vision::Deuteranopia, + SetEmulatedVisionDeficiencyType::Protanopia => Vision::Protanopia, + SetEmulatedVisionDeficiencyType::Tritanopia => Vision::Tritanopia, + }; + match page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + state.set_vision_deficiency(vision) + }) { + Ok(()) => CommandOutputPlan::success(), + Err(error) => CommandOutputPlan::error(-31998, error), + } +} diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 76ac1936a8..1ff4bd3850 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -20,6 +20,7 @@ use tokio::{ }; mod device_geometry; +mod inspector_surfaces; mod native_navigator; async fn evaluate(ctx: &mut TestContext, expression: &str) -> serde_json::Value { diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs new file mode 100644 index 0000000000..467edf7c56 --- /dev/null +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -0,0 +1,208 @@ +use super::*; +use base64::Engine as _; + +async fn command( + ctx: &mut TestContext, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + ctx.process_async( + json!({"id": 88801, "sessionId": "SID-1", "method": method, "params": params}), + ) + .await; + let response = ctx.take_response_by_id(88801); + assert!(response["error"].is_null(), "{method}: {response}"); + response["result"].clone() +} + +async fn screenshot(ctx: &mut TestContext) -> moli_image::RgbaImage { + let result = command(ctx, "Page.captureScreenshot", json!({"format":"png"})).await; + let bytes = base64::engine::general_purpose::STANDARD + .decode(result["data"].as_str().unwrap()) + .unwrap(); + moli_image::decode_png(&bytes).unwrap() +} +fn pixel(image: &moli_image::RgbaImage, x: usize, y: usize) -> &[u8] { + let offset = (y * image.width as usize + x) * 4; + &image.rgba[offset..offset + 4] +} +async fn page() -> TestContext { + let mut ctx = TestContext::new(); + load_session_page_for_pending_emulation_test(&mut ctx).await; + command( + &mut ctx, + "Emulation.setDeviceMetricsOverride", + json!({"width":100,"height":100,"deviceScaleFactor":1,"mobile":false}), + ) + .await; + evaluate( + &mut ctx, + "document.body.style='margin:0;background:white';undefined", + ) + .await; + ctx +} + +#[tokio::test(flavor = "multi_thread")] +async fn os_text_scale_updates_css_environment_and_preserves_media_preferences() { + let mut ctx = page().await; + evaluate(&mut ctx,r#"document.head.innerHTML='';document.body.innerHTML='
A
A
';undefined"#).await; + for (scale, expected) in [ + (json!({"scale":1.5}), json!(["24px", "30px", "16px"])), + (json!({"scale":2}), json!(["32px", "40px", "16px"])), + (json!({}), json!(["16px", "20px", "16px"])), + ] { + command(&mut ctx, "Emulation.setEmulatedOSTextScale", scale).await; + command( + &mut ctx, + "Emulation.setEmulatedMedia", + json!({"features":[{"name":"prefers-color-scheme","value":"dark"}]}), + ) + .await; + assert_eq!(evaluate(&mut ctx,"[getComputedStyle(scaled).fontSize,getComputedStyle(scaled).width,getComputedStyle(plain).fontSize]").await,expected); + assert_eq!( + evaluate( + &mut ctx, + "matchMedia('(prefers-color-scheme:dark)').matches" + ) + .await, + json!(true) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn vision_deficiency_changes_capture_pixels_without_mutating_dom() { + let mut ctx = page().await; + evaluate(&mut ctx, "document.body.style.background='red';undefined").await; + let original = screenshot(&mut ctx).await; + assert_eq!(pixel(&original, 50, 50), [255, 0, 0, 255]); + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"achromatopsia"}), + ) + .await; + let gray = screenshot(&mut ctx).await; + let channels = pixel(&gray, 50, 50); + assert_eq!(channels[0], channels[1]); + assert_eq!(channels[1], channels[2]); + assert!((126..=128).contains(&channels[0])); + assert_eq!( + evaluate(&mut ctx, "getComputedStyle(document.body).backgroundColor").await, + json!("rgb(255, 0, 0)") + ); + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"none"}), + ) + .await; + assert_eq!(screenshot(&mut ctx).await.rgba, original.rgba); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_rect_is_visible_and_hide_restores_original_pixels() { + let mut ctx = page().await; + let original = screenshot(&mut ctx).await; + command(&mut ctx, "DOM.enable", json!({})).await; + command(&mut ctx, "Overlay.enable", json!({})).await; + command( + &mut ctx, + "DOM.highlightRect", + json!({"x":10,"y":10,"width":30,"height":30,"color":{"r":255,"g":0,"b":0,"a":1}}), + ) + .await; + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"achromatopsia"}), + ) + .await; + let highlighted = screenshot(&mut ctx).await; + assert_eq!(pixel(&highlighted, 20, 20), [255, 0, 0, 255]); + assert_eq!(pixel(&highlighted, 60, 60), pixel(&original, 60, 60)); + assert_eq!( + evaluate(&mut ctx, "document.body.children.length").await, + json!(0) + ); + command(&mut ctx, "DOM.hideHighlight", json!({})).await; + assert_eq!(screenshot(&mut ctx).await.rgba, original.rgba); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { + let mut ctx = page().await; + evaluate(&mut ctx,"document.body.innerHTML='
';undefined").await; + let doc = command(&mut ctx, "DOM.getDocument", json!({})).await; + let node = command( + &mut ctx, + "DOM.querySelector", + json!({"nodeId":doc["root"]["nodeId"],"selector":"#target"}), + ) + .await; + command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 255, 0, 255]); + evaluate(&mut ctx, "target.style.left='60px';undefined").await; + let moved = screenshot(&mut ctx).await; + assert_eq!(pixel(&moved, 70, 20), [0, 255, 0, 255]); + assert_eq!(pixel(&moved, 20, 20), [255, 255, 255, 255]); + command(&mut ctx, "Overlay.disable", json!({})).await; + assert_eq!( + pixel(&screenshot(&mut ctx).await, 70, 20), + [255, 255, 255, 255] + ); + ctx.process_async(json!({"id":88802,"sessionId":"SID-1","method":"DOM.highlightNode","params":{"nodeId":2147483647,"highlightConfig":{}}})).await; + assert!(ctx.take_response_by_id(88802)["error"].is_object()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn scroll_gesture_uses_hit_target_and_honors_wheel_cancellation() { + let mut ctx = page().await; + evaluate(&mut ctx,r#"document.body.innerHTML='
';window.events=[];scroller.addEventListener('wheel',e=>events.push([e.deltaX,e.deltaY,e.isTrusted]));undefined"#).await; + command( + &mut ctx, + "Input.synthesizeScrollGesture", + json!({"x":20,"y":20,"xDistance":-30,"yDistance":-60,"gestureSourceType":"mouse"}), + ) + .await; + assert_eq!( + evaluate( + &mut ctx, + "[scroller.scrollLeft,scroller.scrollTop,scrollY,events.reduce((sum,e)=>sum+e[0],0),events.reduce((sum,e)=>sum+e[1],0),events.every(e=>e[2])]" + ) + .await, + json!([30, 60, 0, 30, 60, true]) + ); + evaluate( + &mut ctx, + "scroller.addEventListener('wheel',e=>e.preventDefault(),{passive:false});undefined", + ) + .await; + command( + &mut ctx, + "Input.synthesizeScrollGesture", + json!({"x":20,"y":20,"yDistance":-60}), + ) + .await; + assert_eq!(evaluate(&mut ctx, "scroller.scrollTop").await, json!(60)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn blurred_vision_composites_transparent_filter_edges_over_browser_background() { + let mut ctx = page().await; + evaluate(&mut ctx, "document.body.style.background='red';undefined").await; + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"blurredVision"}), + ) + .await; + let blurred = screenshot(&mut ctx).await; + assert_eq!(pixel(&blurred, 50, 50), [255, 0, 0, 255]); + let edge = pixel(&blurred, 0, 50); + assert_eq!(edge[0], 255); + assert_eq!(edge[3], 255); + assert!((90..=115).contains(&edge[1]), "{edge:?}"); + assert_eq!(edge[1], edge[2]); +} diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index fd3ba4df06..4333d6ac45 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -35,7 +35,9 @@ use chromiumoxide_cdp::cdp::browser_protocol::input::{ DispatchTouchEventType as CdpDispatchTouchEventType, EmulateTouchFromMouseEventParams as CdpEmulateTouchFromMouseEventParams, EmulateTouchFromMouseEventType as CdpEmulateTouchFromMouseEventType, - MouseButton as CdpMouseButton, SynthesizeTapGestureParams as CdpSynthesizeTapGestureParams, + MouseButton as CdpMouseButton, + SynthesizeScrollGestureParams as CdpSynthesizeScrollGestureParams, + SynthesizeTapGestureParams as CdpSynthesizeTapGestureParams, }; mod drag; @@ -309,6 +311,7 @@ pub(crate) fn try_start_input_command_dispatch( InputAction::DispatchMouseEvent | InputAction::DispatchTouchEvent | InputAction::EmulateTouchFromMouseEvent + | InputAction::SynthesizeScrollGesture | InputAction::SynthesizeTapGesture | InputAction::DispatchDragEvent => match validate_coordinate_input_params(cmd, action) { Ok(()) if conn.layout_policy() == moli_core::LayoutPolicy::Mock => { @@ -492,6 +495,46 @@ fn validate_coordinate_input_params( .then_some(()) .ok_or_else(PendingInputCommandStartError::invalid_params) } + InputAction::SynthesizeScrollGesture => { + let params = scroll_gesture_params(cmd)?; + if !optional_enum_param_matches( + cmd, + "gestureSourceType", + &["default", "mouse", "touch"], + ) || ![ + params.x, + params.y, + params.x_distance.unwrap_or_default(), + params.y_distance.unwrap_or_default(), + ] + .into_iter() + .all(f64::is_finite) + || params.speed.is_some_and(|speed| speed <= 0) + || params.repeat_count.is_some_and(|count| count < 0) + || params.repeat_delay_ms.is_some_and(|delay| delay < 0) + { + return Err(PendingInputCommandStartError::invalid_params()); + } + // Wheel gestures share the hit testing, cancellation and scroll-chain + // machinery of dispatched wheel input. Touch/fling and traced repeated + // gestures need a timed gesture source and are not silently approximated. + if matches!( + params.gesture_source_type, + Some(chromiumoxide_cdp::cdp::browser_protocol::input::GestureSourceType::Touch) + ) || params.repeat_count.unwrap_or_default() != 0 + || params.x_overscroll.unwrap_or_default() != 0.0 + || params.y_overscroll.unwrap_or_default() != 0.0 + || params.interaction_marker_name.is_some() + || params.speed.is_some() + || params.repeat_delay_ms.is_some() + || params.prevent_fling == Some(false) + { + return Err(PendingInputCommandStartError::unsupported( + "Timed, touch, fling, overscroll and repeated scroll gestures are not supported", + )); + } + Ok(()) + } InputAction::SynthesizeTapGesture => { if !optional_enum_param_matches( cmd, @@ -538,6 +581,14 @@ fn validate_coordinate_input_params( } } +fn scroll_gesture_params( + cmd: &Cmd<'_>, +) -> Result { + cmd.get_params::() + .map_err(|_| PendingInputCommandStartError::invalid_params())? + .ok_or_else(PendingInputCommandStartError::invalid_params) +} + fn coordinate_input_unsupported_message(action: InputAction) -> &'static str { match action { InputAction::DispatchMouseEvent => DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE, @@ -546,6 +597,7 @@ fn coordinate_input_unsupported_message(action: InputAction) -> &'static str { EMULATE_TOUCH_FROM_MOUSE_EVENT_UNSUPPORTED_MESSAGE } InputAction::SynthesizeTapGesture => SYNTHESIZE_TAP_GESTURE_UNSUPPORTED_MESSAGE, + InputAction::SynthesizeScrollGesture => DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE, InputAction::DispatchDragEvent => DISPATCH_DRAG_EVENT_UNSUPPORTED_MESSAGE, InputAction::CancelDragging | InputAction::DispatchKeyEvent @@ -627,6 +679,7 @@ fn start_pending_input_command( InputAction::DispatchMouseEvent | InputAction::DispatchTouchEvent | InputAction::EmulateTouchFromMouseEvent + | InputAction::SynthesizeScrollGesture | InputAction::SynthesizeTapGesture | InputAction::DispatchDragEvent => { if conn.layout_policy() == moli_core::LayoutPolicy::Mock { @@ -854,6 +907,29 @@ fn build_cdp_coordinate_input_command( }, )) } + InputAction::SynthesizeScrollGesture => { + let params = scroll_gesture_params(cmd)?; + Ok(DevToolsCommand::DispatchMouseEvent( + DevToolsDispatchMouseEventCommand { + context, + event_type: DevToolsMouseEventType::Wheel, + pointer_type: DevToolsPointerType::Mouse, + x: params.x, + y: params.y, + button: -1, + buttons: None, + click_count: 0, + delta_x: -params.x_distance.unwrap_or_default(), + delta_y: -params.y_distance.unwrap_or_default(), + force: 0.0, + tangential_pressure: 0.0, + tilt_x: 0.0, + tilt_y: 0.0, + twist: 0.0, + modifiers: 0, + }, + )) + } InputAction::SynthesizeTapGesture => { let params = cmd .get_params::() diff --git a/moli-protocol/src/domains/mod.rs b/moli-protocol/src/domains/mod.rs index f923cc5477..eddf9ffe52 100644 --- a/moli-protocol/src/domains/mod.rs +++ b/moli-protocol/src/domains/mod.rs @@ -29,6 +29,7 @@ pub mod performance; pub mod profiler; pub mod runtime; pub(crate) mod runtime_context_events; +pub(crate) mod schema; pub mod security; pub mod service_worker; pub(crate) mod session; @@ -38,3 +39,5 @@ pub mod target; pub mod tracing; pub mod web_mcp; pub mod webauthn; + +pub(crate) mod overlay; diff --git a/moli-protocol/src/domains/network/main_document_progress/mod.rs b/moli-protocol/src/domains/network/main_document_progress/mod.rs index 4cd2a16416..61ae856a9b 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -203,7 +203,6 @@ impl CompletedDownloadProgressTransfer { ) -> (MainDocumentProgressGate, CompletedDownloadBodyArtifact) { let response_headers = self.network_events.response_headers.clone(); let progress = CompletedDownloadBodyNetworkProgress { - encoded_data_length: completed_download_body_len_hint(&self.body, &response_headers), network_events: self.network_events, }; let queue = completed_download_body_progress_queue(progress, conn, state, final_url); @@ -222,31 +221,12 @@ impl CompletedDownloadProgressTransfer { } } -fn completed_download_body_len_hint( - body: &CompletedDownloadProgressBody, - response_headers: &[(String, Vec)], -) -> usize { - match body { - CompletedDownloadProgressBody::Buffered(body) => body.len(), - CompletedDownloadProgressBody::Streaming(_) => response_headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .and_then(|(_, value)| moli_fetch::decode_header_value(value).trim().parse().ok()) - .unwrap_or_default(), - } -} - #[derive(Debug)] struct CompletedDownloadBodyNetworkProgress { - encoded_data_length: usize, network_events: CompletedMainDocumentNetworkEvents, } impl CompletedDownloadBodyNetworkProgress { - fn len(&self) -> usize { - self.encoded_data_length - } - fn into_network_events(self) -> CompletedMainDocumentNetworkEvents { self.network_events } @@ -494,7 +474,7 @@ fn completed_document_body_progress_queue( event_request_id, body_progress_source.into_completed_body_events(), final_url, - encoded_data_length, + MainDocumentNetworkCompletion::Loaded(encoded_data_length), ) } @@ -505,7 +485,6 @@ fn completed_download_body_progress_queue( final_url: &Url, ) -> MainDocumentProgressQueueHandle { let network_observed = main_document_network_observed(conn, state.session_id.as_deref()); - let encoded_data_length = progress.len(); completed_or_streaming_document_progress_queue( conn, state, @@ -513,7 +492,7 @@ fn completed_download_body_progress_queue( state.request_id.clone(), Some(progress.into_network_events()), final_url, - encoded_data_length, + MainDocumentNetworkCompletion::Download, ) } @@ -524,7 +503,7 @@ fn completed_or_streaming_document_progress_queue( request_id: Option, events: Option, final_url: &Url, - encoded_data_length: usize, + completion: MainDocumentNetworkCompletion, ) -> MainDocumentProgressQueueHandle { let Some(events) = events else { return MainDocumentProgressQueueHandle::from_source( @@ -544,7 +523,7 @@ fn completed_or_streaming_document_progress_queue( state.timestamp, ); MainDocumentProgressQueueHandle::from_source(MainDocumentProgressSource::completed_body( - context.event_batches(&events, final_url, encoded_data_length), + context.event_batches(&events, final_url, completion), )) } @@ -1632,6 +1611,13 @@ fn main_document_network_event_session_ids( conn.network_event_session_ids_for_session_owner(trigger_session_id) } +#[derive(Clone, Copy)] +enum MainDocumentNetworkCompletion { + Loaded(usize), + // The response body belongs to the download manager; document loading is aborted. + Download, +} + struct CompletedMainDocumentProgressContext { session_ids: Vec>, request_id: Option, @@ -1676,12 +1662,30 @@ impl CompletedMainDocumentProgressContext { &self, events: &CompletedMainDocumentNetworkEvents, final_url: &Url, - encoded_data_length: usize, + completion: MainDocumentNetworkCompletion, ) -> MainDocumentNavigationProgressEventBatches { + let encoded_data_length = match completion { + MainDocumentNetworkCompletion::Loaded(length) => length, + MainDocumentNetworkCompletion::Download => 0, + }; + let terminal = self.progress_target().map(|target| match completion { + MainDocumentNetworkCompletion::Loaded(encoded_data_length) => { + MainDocumentNavigationProgressEvent::LoadingFinished { + target, + encoded_data_length, + } + } + MainDocumentNetworkCompletion::Download => { + MainDocumentNavigationProgressEvent::LoadingFailed { + target, + error_text: moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned(), + } + } + }); MainDocumentNavigationProgressEventBatches::new( self.request_and_redirect_progress_events(events), self.response_received_progress_events(events, final_url, encoded_data_length), - self.loading_finished_progress_events(encoded_data_length), + terminal.into_iter().collect(), ) } @@ -1885,19 +1889,6 @@ impl CompletedMainDocumentProgressContext { }] } - fn loading_finished_progress_events( - &self, - encoded_data_length: usize, - ) -> Vec { - let Some(target) = self.progress_target() else { - return Vec::new(); - }; - vec![MainDocumentNavigationProgressEvent::LoadingFinished { - target, - encoded_data_length, - }] - } - fn progress_target(&self) -> Option { Some(MainDocumentProgressEventTarget { session_ids: self.session_ids.clone(), diff --git a/moli-protocol/src/domains/network/main_document_progress/tests.rs b/moli-protocol/src/domains/network/main_document_progress/tests.rs index 9241cf0d9b..4bddf851a6 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -51,6 +51,36 @@ fn completed_events() -> CompletedMainDocumentNetworkEvents { ) } +#[test] +fn download_response_terminates_document_request_as_aborted_once() { + let events = completed_events(); + let url = Url::parse("http://example.test/attachment").unwrap(); + let batches = completed_progress_context().event_batches( + &events, + &url, + MainDocumentNetworkCompletion::Download, + ); + let mut drain = + MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + let messages = drain_into_protocol_messages(&mut drain); + let terminal: Vec<_> = messages + .iter() + .filter(|m| { + matches!( + m["method"].as_str(), + Some("Network.loadingFailed" | "Network.loadingFinished") + ) + }) + .collect(); + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0]["method"], "Network.loadingFailed"); + assert_eq!(terminal[0]["params"]["requestId"], "REQ-1"); + assert_eq!(terminal[0]["params"]["errorText"], "net::ERR_ABORTED"); + assert_eq!(terminal[0]["params"]["canceled"], true); + assert!(drain_into_protocol_messages(&mut drain).is_empty()); +} + fn observation_journal( exchanges: Vec<(Vec<(String, String)>, u16, Vec<(String, String)>)>, ) -> NetworkObservationJournal { @@ -743,7 +773,11 @@ fn completed_body_http_response_emits_correlated_empty_cookie_extra_info() { let final_url = Url::parse("http://example.test/final").unwrap(); let mut events = completed_events(); events.network_extra_info_available = true; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -797,7 +831,11 @@ fn completed_body_extra_info_uses_transport_observed_headers() { 200, vec![("X-Raw-Response".to_owned(), "observed".to_owned())], )])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -852,7 +890,11 @@ fn completed_body_auth_retry_uses_initial_request_and_final_response_observation vec![("X-Final-Response".to_owned(), "yes".to_owned())], ), ])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -894,7 +936,11 @@ fn completed_body_revalidation_keeps_raw_304_extra_info() { 304, vec![("ETag".to_owned(), "\"v1\"".to_owned())], )])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -922,7 +968,11 @@ fn completed_body_revalidation_keeps_raw_304_extra_info() { fn completed_body_http_service_worker_response_does_not_infer_extra_info_from_url() { let final_url = Url::parse("http://example.test/sw-controlled").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1007,7 +1057,11 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { "FRAME-1".to_owned(), 12.5, ); - let batches = context.event_batches(&events, &final_url, 17); + let batches = context.event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1099,7 +1153,11 @@ fn completed_body_redirect_without_transport_extra_info_keeps_flag_false() { from_cache: false, negotiated_http_version: None, }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1179,7 +1237,11 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro negotiated_http_version: Some(NegotiatedHttpVersion::Http11), }]; - let batches = completed_progress_context().event_batches(&events, &navigation_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &navigation_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); @@ -1267,7 +1329,11 @@ fn transportless_https_upgrade_attributes_wire_headers_to_upgraded_request() { negotiated_http_version: Some(NegotiatedHttpVersion::Http11), }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); @@ -1321,7 +1387,11 @@ fn completed_body_uses_negotiated_protocol_for_redirect_and_final_response() { from_cache: false, negotiated_http_version: Some(NegotiatedHttpVersion::Http10), }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1347,7 +1417,11 @@ fn cached_completed_body_emits_served_from_cache_before_response() { let final_url = Url::parse("http://example.test/final").unwrap(); let mut events = completed_events(); events.response_from_cache = true; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1402,7 +1476,11 @@ fn cached_completed_body_redirect_emits_cache_event_before_next_request() { from_cache: true, negotiated_http_version: None, }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1543,7 +1621,11 @@ fn progress_output_queue_buffers_body_finished_until_response_received() { fn completed_body_progress_queue_drains_network_events_by_milestone() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1582,7 +1664,11 @@ fn completed_body_progress_queue_drains_network_events_by_milestone() { fn completed_body_progress_queue_can_release_all_materialized_events_at_body_finished() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1602,7 +1688,11 @@ fn completed_body_progress_queue_can_release_all_materialized_events_at_body_fin fn completed_body_progress_drain_keeps_mark_ready_separate_from_output_drain() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1636,7 +1726,11 @@ fn completed_body_progress_drain_keeps_mark_ready_separate_from_output_drain() { fn progress_output_barrier_drains_source_generated_progress_before_cdp_output() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source( MainDocumentProgressSource::completed_body(batches), )); @@ -1660,7 +1754,11 @@ fn progress_output_barrier_drains_source_generated_progress_before_cdp_output() ); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source( MainDocumentProgressSource::completed_body(batches), )); diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs new file mode 100644 index 0000000000..2b1505140b --- /dev/null +++ b/moli-protocol/src/domains/overlay.rs @@ -0,0 +1,184 @@ +use super::command_output::CommandOutputPlan; +use crate::conn::{CdpConnection, Cmd, CommandOwnerScope}; +use moli_core::page::{CompletedPageCommand, PendingPageCommand, RendererInspectorOverlayCommand}; +use serde::Deserialize; + +#[derive(Clone, Copy, Deserialize)] +struct Color { + r: i32, + g: i32, + b: i32, + a: Option, +} +impl Color { + fn components(self) -> [f32; 4] { + [ + self.r.clamp(0, 255) as f32 / 255.0, + self.g.clamp(0, 255) as f32 / 255.0, + self.b.clamp(0, 255) as f32 / 255.0, + self.a.unwrap_or(1.0).clamp(0.0, 1.0), + ] + } +} +fn color(value: Option) -> [f32; 4] { + value.map_or([0.0; 4], Color::components) +} +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct HighlightConfig { + content_color: Option, + padding_color: Option, + border_color: Option, + margin_color: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NodeParams { + node_id: Option, + backend_node_id: Option, + object_id: Option, + highlight_config: HighlightConfig, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RectParams { + x: i32, + y: i32, + width: i32, + height: i32, + color: Option, + outline_color: Option, +} + +pub(crate) struct PendingOverlayCommandDispatch { + command_id: Option, + owner: CommandOwnerScope, + pending: PendingPageCommand, +} +pub(crate) struct CompletedOverlayCommandDispatch { + command_id: Option, + owner: CommandOwnerScope, + completed: Result, +} +pub(crate) enum OverlayCommandTaskStep { + Pending(PendingOverlayCommandDispatch), + Complete(CommandOutputPlan), +} +impl PendingOverlayCommandDispatch { + pub(crate) async fn wait(self) -> CompletedOverlayCommandDispatch { + CompletedOverlayCommandDispatch { + command_id: self.command_id, + owner: self.owner, + completed: self.pending.wait().await.map_err(|error| error.to_string()), + } + } +} +impl CompletedOverlayCommandDispatch { + pub(crate) fn command_id(&self) -> Option { + self.command_id + } + pub(crate) fn session_id(&self) -> Option<&str> { + self.owner.session_id() + } +} + +fn parse_command(cmd: &Cmd<'_>) -> Result { + let invalid = |error| CommandOutputPlan::error(-32602, error); + match cmd.action { + "enable" => Ok(RendererInspectorOverlayCommand::Enable), + "disable" => Ok(RendererInspectorOverlayCommand::Disable), + "hideHighlight" => Ok(RendererInspectorOverlayCommand::Hide), + "highlightRect" => { + let p = cmd + .get_params::() + .map_err(invalid)? + .ok_or_else(|| invalid("Missing rectangle"))?; + if p.width < 0 || p.height < 0 { + return Err(invalid("Rectangle dimensions must be nonnegative")); + } + Ok(RendererInspectorOverlayCommand::Rect { + rect: [p.x as f32, p.y as f32, p.width as f32, p.height as f32], + color: color(p.color), + outline: color(p.outline_color), + }) + } + "highlightNode" => { + let p = cmd + .get_params::() + .map_err(invalid)? + .ok_or_else(|| invalid("Missing node"))?; + if p.node_id.is_none() && p.backend_node_id.is_none() && p.object_id.is_none() { + return Err(invalid("A node reference is required")); + } + let config = p.highlight_config; + Ok(RendererInspectorOverlayCommand::Node { + node_id: p.node_id, + backend_node_id: p.backend_node_id, + object_id: p.object_id, + colors: [ + config.content_color, + config.padding_color, + config.border_color, + config.margin_color, + ] + .map(color), + }) + } + _ => Err(CommandOutputPlan::error(-32601, "UnknownMethod")), + } +} + +pub(crate) fn try_start_overlay_command_dispatch( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> OverlayCommandTaskStep { + let command = match parse_command(cmd) { + Ok(command) => command, + Err(plan) => return OverlayCommandTaskStep::Complete(plan), + }; + if conn.layout_policy() == moli_core::LayoutPolicy::Mock + && matches!( + command, + RendererInspectorOverlayCommand::Rect { .. } + | RendererInspectorOverlayCommand::Node { .. } + ) + { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Inspector highlighting requires layout", + )); + } + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + let session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); + let result = conn + .loaded_page_mut_for_protocol_access_for_owner(&owner) + .and_then(|page| { + page.start_set_inspector_overlay(session, command) + .map_err(|error| error.to_string()) + }); + match result { + Ok(pending) => OverlayCommandTaskStep::Pending(PendingOverlayCommandDispatch { + command_id: cmd.id, + owner, + pending, + }), + Err(error) => OverlayCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)), + } +} + +pub(crate) fn complete_pending_overlay_command( + conn: &mut CdpConnection, + completed: CompletedOverlayCommandDispatch, +) -> CommandOutputPlan { + let result = completed.completed.and_then(|completion| { + conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) + .and_then(|page| { + page.finish_set_inspector_overlay(completion) + .map_err(|error| error.to_string()) + }) + }); + match result { + Ok(()) => CommandOutputPlan::success(), + Err(error) => CommandOutputPlan::error(-32000, error), + } +} diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 9053466484..c9eb77aacd 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -1988,6 +1988,7 @@ impl CdpConnection { }; let request = RendererCaptureScreencastFrameRequest { base_background_color: self.default_background_color_for_owner(&owner_scope), + vision_deficiency: self.vision_deficiency_for_owner(&owner_scope), format: match config.format() { PageScreencastFormat::Png => RendererScreenshotFormat::Png, PageScreencastFormat::Jpeg => RendererScreenshotFormat::Jpeg, @@ -6872,6 +6873,7 @@ fn start_devtools_capture_screenshot_command( let session_id = command.context.session_id.as_ref().map(|id| id.as_str()); let owner_scope = CommandOwnerScope::capture(conn, session_id); let base_background_color = conn.default_background_color_for_owner(&owner_scope); + let vision_deficiency = conn.vision_deficiency_for_owner(&owner_scope); let page = match conn.loaded_page_mut_for_protocol_access(session_id) { Ok(page) => page, Err(message) => { @@ -6914,6 +6916,7 @@ fn start_devtools_capture_screenshot_command( let request = RendererCaptureScreenshotRequest { purpose: RendererScreenshotPurpose::Screenshot, base_background_color, + vision_deficiency, format, quality: command.quality.unwrap_or(80), region, @@ -6975,6 +6978,7 @@ fn start_devtools_print_to_pdf_command( }; let request = RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: RendererScreenshotPurpose::Print { print_background: command.print_background.unwrap_or(false), }, diff --git a/moli-protocol/src/domains/schema.rs b/moli-protocol/src/domains/schema.rs new file mode 100644 index 0000000000..23025bc09e --- /dev/null +++ b/moli-protocol/src/domains/schema.rs @@ -0,0 +1,59 @@ +use serde_json::json; +use strum::IntoEnumIterator; + +use crate::conn::Cmd; +use crate::domains::command_output::CommandOutputPlan; + +/// Domains installed by the connection dispatcher. Keep discovery and dispatch exhaustive. +#[derive(Clone, Copy, strum::EnumString, strum::EnumIter, strum::AsRefStr)] +pub(crate) enum CdpDomain { + Browser, + Runtime, + HeapProfiler, + Profiler, + Debugger, + Accessibility, + Input, + #[strum(serialize = "CSS")] + Css, + #[strum(serialize = "DOM")] + Dom, + #[strum(serialize = "DOMStorage")] + DomStorage, + Console, + Network, + Target, + Tracing, + Fetch, + Page, + Inspector, + Log, + Storage, + #[strum(serialize = "DOMSnapshot")] + DomSnapshot, + Overlay, + Security, + ServiceWorker, + #[strum(serialize = "IO")] + Io, + Autofill, + Audits, + SystemInfo, + WebAuthn, + WebMCP, + Performance, + #[strum(serialize = "DOMDebugger")] + DomDebugger, + Emulation, + Schema, +} + +pub(crate) fn command_output_plan(cmd: &Cmd<'_>) -> CommandOutputPlan { + if cmd.method != "Schema.getDomains" { + return CommandOutputPlan::error(-32601, "UnknownMethod"); + } + let domains: Vec<_> = CdpDomain::iter() + .map(|domain| json!({"name": domain.as_ref(), "version": crate::version::PROTOCOL_VERSION})) + .collect(); + CommandOutputPlan::result(json!({"domains": domains})) +} diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index f52835290e..ca918c2cb4 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -261,7 +261,7 @@ pub use runtime::{ RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken, RendererGeometryQuad, RendererInputDispatchOutcome, RendererInspectorCommandEnvelope, - RendererInspectorCommandRoute, RendererInspectorIngressTicket, + RendererInspectorCommandRoute, RendererInspectorIngressTicket, RendererInspectorOverlayCommand, RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot, RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, @@ -312,8 +312,8 @@ pub use runtime::{ RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererStyleSheetHeader, RendererStyleSheetInventoryUpdate, RendererStyleSheetPayload, RendererSyntheticResponseBody, RendererTextSearchMatch, - RendererTouchPoint, RendererVisualStateToken, RendererWindowDocumentSource, - RuntimeConsoleMessageSnapshot, renderer_output_transport_channel, + RendererTouchPoint, RendererVisionDeficiency, RendererVisualStateToken, + RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, renderer_output_transport_channel, }; pub use runtime::{ RendererElementClickError, RendererElementClickTarget, RendererPreparedPointerClick, diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 0880947fa3..9e6b9ded81 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -304,7 +304,7 @@ pub(crate) use self::page_context_cancel::{ pub use self::page_screenshot::{ RendererCaptureScreencastFrameRequest, RendererCaptureScreenshotRequest, RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotPurpose, - RendererScreenshotRegion, RendererVisualStateToken, + RendererScreenshotRegion, RendererVisionDeficiency, RendererVisualStateToken, }; pub(super) use self::page_state::RendererPageEntry; pub use self::page_state::RendererPageRecord; @@ -800,3 +800,6 @@ pub(super) enum PageVmRuntimeExpressionAwaitAdvance { #[cfg(test)] mod tests; + +mod page_overlay; +pub use page_overlay::RendererInspectorOverlayCommand; diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 255205b4c3..9d4b1c8938 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -1123,6 +1123,10 @@ impl PageVm { self.set_bypass_content_security_policy(bypass); Ok(RendererPageReply::Unit) } + RendererPageCommand::SetInspectorOverlay { inspector_session_id, command } => { + self.set_inspector_overlay(inspector_session_id.as_deref(), command)?; + Ok(RendererPageReply::Unit) + } RendererPageCommand::SetEmulatedMedia(overrides) => { self.set_emulated_media(&overrides); Ok(RendererPageReply::Unit) diff --git a/moli-renderer-v8/src/runtime/page_dom.rs b/moli-renderer-v8/src/runtime/page_dom.rs index 6afb87aa2d..89fc179dc4 100644 --- a/moli-renderer-v8/src/runtime/page_dom.rs +++ b/moli-renderer-v8/src/runtime/page_dom.rs @@ -3921,6 +3921,7 @@ impl PageVm { &mut self, inspector_session_id: Option<&str>, ) -> bool { + self.inspector_overlay.detach_session(inspector_session_id); let session_key = DevToolsSessionKey::from_wire_session_id( inspector_session_id.filter(|session_id| !session_id.is_empty()), ); @@ -3996,7 +3997,7 @@ impl PageVm { .mutate_document_node_attribute(handle, mutation) } - fn live_handle_for_dom_frontend_node_id( + pub(super) fn live_handle_for_dom_frontend_node_id( &mut self, inspector_session_id: Option<&str>, frontend_node_id: u32, diff --git a/moli-renderer-v8/src/runtime/page_overlay.rs b/moli-renderer-v8/src/runtime/page_overlay.rs new file mode 100644 index 0000000000..a2d6aed48e --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_overlay.rs @@ -0,0 +1,299 @@ +//! Inspector-only paint, kept outside the inspected DOM and its style cascade. +use super::{PageVm, RendererDocumentLifecycleIdentity, RendererDocumentNodeGeometry}; +use anyhow::{Result, anyhow}; +use moli_layout::{ + PaintBrush, PaintColor, PaintFragment, PaintPath, PaintPathElement, PaintPoint, PaintRect, + PaintShape, PaintSnapshot, +}; + +#[derive(Clone, Debug)] +pub enum RendererInspectorOverlayCommand { + Enable, + Disable, + Hide, + Rect { + rect: [f32; 4], + color: [f32; 4], + outline: [f32; 4], + }, + Node { + node_id: Option, + backend_node_id: Option, + object_id: Option, + colors: [[f32; 4]; 4], + }, +} + +#[derive(Clone, Debug)] +enum Highlight { + Rect { + rect: [f32; 4], + color: PaintColor, + outline: PaintColor, + }, + Node { + backend_node_id: u32, + colors: [PaintColor; 4], + }, +} + +#[derive(Default)] +pub(super) struct InspectorOverlayState { + highlight: Option<(RendererDocumentLifecycleIdentity, Option, Highlight)>, + pub(super) revision: u64, +} + +impl InspectorOverlayState { + pub(super) fn detach_session(&mut self, session: Option<&str>) { + if self + .highlight + .as_ref() + .is_some_and(|(_, owner, _)| owner.as_deref() == session) + { + self.highlight = None; + self.revision = self.revision.wrapping_add(1); + } + } +} + +impl PageVm { + pub(super) fn set_inspector_overlay( + &mut self, + session: Option<&str>, + command: RendererInspectorOverlayCommand, + ) -> Result<()> { + let highlight = match command { + RendererInspectorOverlayCommand::Enable => return Ok(()), + RendererInspectorOverlayCommand::Disable | RendererInspectorOverlayCommand::Hide => { + None + } + RendererInspectorOverlayCommand::Rect { + rect, + color, + outline, + } => Some(Highlight::Rect { + rect, + color: color_from_components(color), + outline: color_from_components(outline), + }), + RendererInspectorOverlayCommand::Node { + node_id, + backend_node_id, + object_id, + colors, + } => { + let handle = if let Some(id) = node_id { + self.live_handle_for_dom_frontend_node_id(session, id) + } else if let Some(id) = backend_node_id { + self.live_handle_for_backend_node_id(id) + } else if let Some(id) = object_id { + self.vm_mut() + .live_node_handle_for_runtime_object_id(session, &id)? + } else { + None + }; + let backend_node_id = handle + .and_then(|handle| self.renderer_backend_node_id_for_live_handle(handle)) + .ok_or_else(|| anyhow!("Could not find node with given id"))?; + Some(Highlight::Node { + backend_node_id, + colors: colors.map(color_from_components), + }) + } + }; + self.inspector_overlay.highlight = highlight.map(|value| { + ( + self.document_lifecycle.identity(), + session.map(str::to_owned), + value, + ) + }); + self.inspector_overlay.revision = self.inspector_overlay.revision.wrapping_add(1); + Ok(()) + } + + pub(super) fn composite_inspector_overlay( + &mut self, + page: &PaintSnapshot, + raster: &mut moli_paint::RasterImage, + ) -> Result<()> { + let mut snapshot = PaintSnapshot::new(page.viewport, PaintColor::TRANSPARENT); + snapshot.surface = page.surface; + snapshot.viewport_to_surface = page.viewport_to_surface; + self.append_inspector_overlay(&mut snapshot)?; + if snapshot.fragments.is_empty() { + return Ok(()); + } + let required_bytes = raster.rgba.len().saturating_mul(2); + if required_bytes > moli_paint::MAX_TRANSIENT_RASTER_BYTES { + return Err(moli_paint::PaintError::TransientRasterBudgetExceeded { + required_bytes, + max_bytes: moli_paint::MAX_TRANSIENT_RASTER_BYTES, + } + .into()); + } + let overlay = moli_paint::raster_snapshot(&snapshot)?; + // Inspector UI is composited after page effects and never enters print. + for (dst, src) in raster + .rgba + .chunks_exact_mut(4) + .zip(overlay.rgba.chunks_exact(4)) + { + let source = [src[0], src[1], src[2], src[3]]; + let destination = [dst[0], dst[1], dst[2], dst[3]]; + dst.copy_from_slice(&source_over(source, destination)); + } + Ok(()) + } + + pub(super) fn append_inspector_overlay(&mut self, snapshot: &mut PaintSnapshot) -> Result<()> { + let Some((document, _, highlight)) = self.inspector_overlay.highlight.clone() else { + return Ok(()); + }; + if document != self.document_lifecycle.identity() { + return Ok(()); + } + match highlight { + Highlight::Rect { + rect: [x, y, width, height], + color, + outline, + } => { + let outer = [x, y, x + width, y, x + width, y + height, x, y + height]; + fill_quad(snapshot, outer, None, color); + // Inspector rectangle outlines are one CSS pixel wide. + let inner = [ + x + 1.0, + y + 1.0, + x + width - 1.0, + y + 1.0, + x + width - 1.0, + y + height - 1.0, + x + 1.0, + y + height - 1.0, + ]; + fill_quad(snapshot, outer, Some(inner), outline); + } + Highlight::Node { + backend_node_id, + colors, + } => { + if let Some(RendererDocumentNodeGeometry::FoundElement { box_model, .. }) = + self.document_geometry_for_backend_node_id(backend_node_id)? + { + let quads = [ + box_model.content, + box_model.padding, + box_model.border, + box_model.margin, + ] + .map(|quad| quad.points.map(|value| value as f32)); + for (index, (quad, color)) in quads.into_iter().zip(colors).enumerate() { + fill_quad( + snapshot, + quad, + index.checked_sub(1).map(|previous| quads[previous]), + color, + ); + } + } + } + } + Ok(()) + } +} + +fn fill_quad( + snapshot: &mut PaintSnapshot, + outer: [f32; 8], + inner: Option<[f32; 8]>, + color: PaintColor, +) { + if color.alpha <= 0.0 { + return; + } + let mut elements = Vec::with_capacity(10); + let points = |quad: [f32; 8]| { + [ + PaintPoint::new(quad[0], quad[1]), + PaintPoint::new(quad[2], quad[3]), + PaintPoint::new(quad[4], quad[5]), + PaintPoint::new(quad[6], quad[7]), + ] + }; + let outer_points = points(outer); + for (index, point) in outer_points.into_iter().enumerate() { + elements.push(if index == 0 { + PaintPathElement::MoveTo(point) + } else { + PaintPathElement::LineTo(point) + }); + } + elements.push(PaintPathElement::Close); + if let Some(inner) = inner { + for (index, point) in points(inner).into_iter().rev().enumerate() { + elements.push(if index == 0 { + PaintPathElement::MoveTo(point) + } else { + PaintPathElement::LineTo(point) + }); + } + elements.push(PaintPathElement::Close); + } + let min_x = outer_points + .iter() + .map(|p| p.x) + .fold(f32::INFINITY, f32::min); + let min_y = outer_points + .iter() + .map(|p| p.y) + .fold(f32::INFINITY, f32::min); + let max_x = outer_points + .iter() + .map(|p| p.x) + .fold(f32::NEG_INFINITY, f32::max); + let max_y = outer_points + .iter() + .map(|p| p.y) + .fold(f32::NEG_INFINITY, f32::max); + snapshot.fragments.push(PaintFragment::Fill { + shape: PaintShape::Path(PaintPath { + elements, + bounds: PaintRect::new(min_x, min_y, max_x - min_x, max_y - min_y), + }), + brush: PaintBrush::Solid(color), + transform: snapshot.viewport_to_surface, + }); +} + +fn color_from_components([r, g, b, a]: [f32; 4]) -> PaintColor { + PaintColor::new(r, g, b, a) +} + +pub(super) fn composite_background(raster: &mut moli_paint::RasterImage, color: PaintColor) { + let background = color + .components() + .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8); + for pixel in raster.rgba.chunks_exact_mut(4) { + let foreground = [pixel[0], pixel[1], pixel[2], pixel[3]]; + pixel.copy_from_slice(&source_over(foreground, background)); + } +} + +fn source_over(source: [u8; 4], destination: [u8; 4]) -> [u8; 4] { + let source_alpha = u32::from(source[3]); + let destination_alpha = u32::from(destination[3]) * (255 - source_alpha); + let alpha = source_alpha * 255 + destination_alpha; + if alpha == 0 { + return [0; 4]; + } + let mut result = [0; 4]; + for (index, out) in result[..3].iter_mut().enumerate() { + *out = ((u32::from(source[index]) * source_alpha * 255 + + u32::from(destination[index]) * destination_alpha + + alpha / 2) + / alpha) as u8; + } + result[3] = ((alpha + 127) / 255) as u8; + result +} diff --git a/moli-renderer-v8/src/runtime/page_screenshot.rs b/moli-renderer-v8/src/runtime/page_screenshot.rs index 0c2cb656ca..0f1f7186f5 100644 --- a/moli-renderer-v8/src/runtime/page_screenshot.rs +++ b/moli-renderer-v8/src/runtime/page_screenshot.rs @@ -3,6 +3,7 @@ use moli_layout::{ PaintViewport, }; use moli_page_types::{LayoutPolicy, ViewportSurface}; +pub use moli_paint::VisionDeficiency as RendererVisionDeficiency; use std::sync::Arc; use std::time::Instant; @@ -75,6 +76,8 @@ struct RendererVisualState { viewport_height: u32, device_pixel_ratio_bits: u32, base_background_color: [u8; 4], + vision_deficiency: RendererVisionDeficiency, + inspector_overlay_revision: u64, } impl RendererVisualStateToken { @@ -102,9 +105,25 @@ impl RendererVisualStateToken { viewport_height: viewport.css_height, device_pixel_ratio_bits: viewport.device_pixel_ratio.to_bits(), base_background_color, + vision_deficiency: RendererVisionDeficiency::None, + inspector_overlay_revision: 0, })) } + fn with_capture_effects( + mut self, + vision: RendererVisionDeficiency, + overlay_revision: u64, + ) -> Self { + Arc::get_mut(&mut self.0) + .expect("new visual token") + .vision_deficiency = vision; + Arc::get_mut(&mut self.0) + .expect("new visual token") + .inspector_overlay_revision = overlay_revision; + self + } + fn has_same_resource_generation(&self, other: &Self) -> bool { self.0.resource_generation == other.0.resource_generation } @@ -152,6 +171,7 @@ pub struct RendererCaptureScreenshotRequest { pub region: RendererScreenshotRegion, /// Straight-alpha sRGB bytes in RGBA order, beneath author backgrounds. pub base_background_color: [u8; 4], + pub vision_deficiency: RendererVisionDeficiency, pub optimize_for_speed: bool, pub max_width: Option, pub max_height: Option, @@ -166,6 +186,7 @@ pub struct RendererCaptureScreencastFrameRequest { pub quality: u8, /// Straight-alpha sRGB bytes in RGBA order, beneath author backgrounds. pub base_background_color: [u8; 4], + pub vision_deficiency: RendererVisionDeficiency, pub optimize_for_speed: bool, pub max_width: Option, pub max_height: Option, @@ -180,6 +201,7 @@ impl RendererCaptureScreenshotRequest { quality: 100, region: RendererScreenshotRegion::Viewport, base_background_color: [255; 4], + vision_deficiency: RendererVisionDeficiency::None, optimize_for_speed: false, max_width: None, max_height: None, @@ -216,6 +238,8 @@ impl PageVm { request.format, request.quality, request.optimize_for_speed, + request.vision_deficiency, + request.purpose == RendererScreenshotPurpose::Screenshot, paint_capture, moli_layout::LayoutFlushReason::Screenshot, ); @@ -258,11 +282,14 @@ impl PageVm { surface.inner_height, surface.device_pixel_ratio as f32, ); - let visual_state_before = self.vm().visual_state_token( - self.document_lifecycle.identity(), - viewport, - request.base_background_color, - ); + let visual_state_before = self + .vm() + .visual_state_token( + self.document_lifecycle.identity(), + viewport, + request.base_background_color, + ) + .with_capture_effects(request.vision_deficiency, self.inspector_overlay.revision); if request.known_visual_state.as_ref() == Some(&visual_state_before) { return Ok(RendererCaptureScreencastFrameReply::Unchanged); } @@ -279,6 +306,8 @@ impl PageVm { request.format, request.quality, request.optimize_for_speed, + request.vision_deficiency, + true, paint_capture, moli_layout::LayoutFlushReason::Screencast, )? { @@ -290,11 +319,14 @@ impl PageVm { return Ok(RendererCaptureScreencastFrameReply::NoDocument); } }; - let visual_state_after = self.vm().visual_state_token( - self.document_lifecycle.identity(), - viewport, - request.base_background_color, - ); + let visual_state_after = self + .vm() + .visual_state_token( + self.document_lifecycle.identity(), + viewport, + request.base_background_color, + ) + .with_capture_effects(request.vision_deficiency, self.inspector_overlay.revision); let visual_state = visual_state_for_captured_screencast_frame(visual_state_before, visual_state_after); Ok(RendererCaptureScreencastFrameReply::Captured( @@ -311,6 +343,8 @@ impl PageVm { format: RendererScreenshotFormat, quality: u8, optimize_for_speed: bool, + vision_deficiency: RendererVisionDeficiency, + include_inspector_overlay: bool, paint_capture: PaintCaptureRequest, reason: moli_layout::LayoutFlushReason, ) -> anyhow::Result { @@ -359,6 +393,16 @@ impl PageVm { let raster_started = profile_enabled.then(Instant::now); let mut raster = moli_paint::raster_snapshot(&snapshot)?; + vision_deficiency.apply(&mut raster, snapshot.surface.device_scale)?; + if vision_deficiency == RendererVisionDeficiency::BlurredVision { + super::page_overlay::composite_background( + &mut raster, + paint_capture.base_background_color, + ); + } + if include_inspector_overlay { + self.composite_inspector_overlay(&snapshot, &mut raster)?; + } let raster_us = raster_started .map(|started| started.elapsed().as_micros()) .unwrap_or_default(); diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 3472300cdf..0bdeb22a0f 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -5321,6 +5321,10 @@ pub enum RendererPageCommand { SetDocumentActivity(moli_page_types::DocumentActivity), SetScriptExecutionDisabled(bool), SetBypassContentSecurityPolicy(bool), + SetInspectorOverlay { + inspector_session_id: Option, + command: super::RendererInspectorOverlayCommand, + }, SetEmulatedMedia(crate::protocol_types::EmulatedMediaOverrides), SetViewportSurface(Option), SetNetworkOffline(bool), diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index d7f620759b..572baad675 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1573,6 +1573,7 @@ pub(crate) struct PageVm { ScriptExecutionReportSnapshotSignature, Arc, )>, + pub(super) inspector_overlay: super::page_overlay::InspectorOverlayState, dom_agent_state: RendererDomAgentState, pending_dom_mutation_event_batches: Vec, last_published_document_title: String, @@ -4291,6 +4292,7 @@ impl PageVm { report, report_snapshot_cache: None, dom_agent_state, + inspector_overlay: Default::default(), pending_dom_mutation_event_batches: Vec::new(), last_published_document_title: String::new(), css_agent_sessions: HashMap::new(), diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs index 1263915d78..46210acad8 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs @@ -147,6 +147,7 @@ async fn computed_size_reads_do_not_invalidate_screencast_tokens() { set_viewport(&mut page, 40, 30)?; let request = |known_visual_state| RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, 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 10b31385ca..546c741e09 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 @@ -16,6 +16,7 @@ fn viewport_screencast_request( ) -> crate::runtime::RendererCaptureScreencastFrameRequest { crate::runtime::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: crate::runtime::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index e3688d909c..82b0649258 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -574,6 +574,7 @@ async fn screenshot_and_screencast_paint_downloaded_svg_vectors() { &page, super::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: super::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, @@ -687,6 +688,7 @@ async fn capture_screenshot_encodes_jpeg_and_limits_device_dimensions() { let request = super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Jpeg, quality: 80, @@ -756,6 +758,7 @@ async fn print_capture_uses_print_media_controls_backgrounds_and_restores_screen &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Print { print_background: true, }, @@ -775,6 +778,7 @@ async fn print_capture_uses_print_media_controls_backgrounds_and_restores_screen &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Print { print_background: false, }, @@ -842,6 +846,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -860,6 +865,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -927,6 +933,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -957,6 +964,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -989,6 +997,7 @@ async fn capture_screenshot_rejects_full_document_at_the_128k_css_boundary() { .await; let request = super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -12484,6 +12493,7 @@ addEventListener("wheel", event => { &page, super::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: super::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index f860090395..5bd83c5948 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -41,7 +41,7 @@ pub(super) struct RetainedStyleInvalidations { pub(super) shadow_scope_fallbacks: Vec, pub(super) removed_shadow_scopes: Vec, pub(super) viewport_size_changed: bool, - pub(super) used_color_scheme_changed: bool, + pub(super) computed_environment_changed: bool, } /// Compares an explicit full-world snapshot with the canonical retained @@ -78,7 +78,7 @@ impl RetainedStyleInvalidations { shadow_scope_fallbacks: Vec::new(), removed_shadow_scopes: Vec::new(), viewport_size_changed: false, - used_color_scheme_changed: false, + computed_environment_changed: false, } } } @@ -208,7 +208,7 @@ pub(super) fn update_retained_style_system( invalidations.document = document_update.invalidations; invalidations.document_scope_fallback = document_update.scope_fallback; invalidations.viewport_size_changed = document_update.viewport_size_changed; - invalidations.used_color_scheme_changed = document_update.used_color_scheme_changed; + invalidations.computed_environment_changed = document_update.computed_environment_changed; let shadow_reconciliation = reconcile_shadow_scopes( retained, @@ -269,7 +269,7 @@ pub(super) fn update_retained_style_system_incrementally( invalidations.document = document_update.invalidations; invalidations.document_scope_fallback = document_update.scope_fallback; invalidations.viewport_size_changed = document_update.viewport_size_changed; - invalidations.used_color_scheme_changed = document_update.used_color_scheme_changed; + invalidations.computed_environment_changed = document_update.computed_environment_changed; let shadow_reconciliation = reconcile_dirty_shadow_scopes( retained, @@ -327,7 +327,7 @@ struct DocumentScopeUpdate { scope_fallback: bool, device_changed: bool, viewport_size_changed: bool, - used_color_scheme_changed: bool, + computed_environment_changed: bool, device_affected_origins: OriginSet, stylesheets_changed: bool, } @@ -373,8 +373,10 @@ fn update_document_scope( let viewport_size_changed = retained.key.viewport_width_bits != key.viewport_width_bits || retained.key.viewport_height_bits != key.viewport_height_bits; - let used_color_scheme_changed = retained.key.environment.stylo_prefers_color_scheme() - != key.environment.stylo_prefers_color_scheme() + let computed_environment_changed = retained.key.environment.preferred_text_scale() + != key.environment.preferred_text_scale() + || retained.key.environment.stylo_prefers_color_scheme() + != key.environment.stylo_prefers_color_scheme() || retained.key.environment.stylo_page_color_schemes() != key.environment.stylo_page_color_schemes(); let device_changed = viewport_size_changed @@ -437,7 +439,7 @@ fn update_document_scope( scope_fallback, device_changed, viewport_size_changed, - used_color_scheme_changed, + computed_environment_changed, device_affected_origins, stylesheets_changed, } diff --git a/moli-renderer-v8/src/style_engine/stylesheet.rs b/moli-renderer-v8/src/style_engine/stylesheet.rs index aa403d1887..2427f28af3 100644 --- a/moli-renderer-v8/src/style_engine/stylesheet.rs +++ b/moli-renderer-v8/src/style_engine/stylesheet.rs @@ -398,6 +398,7 @@ pub(super) fn new_style_device_with_viewport( PointerCapabilities::default(), PointerCapabilities::default(), ); + device.set_preferred_text_scale(environment.preferred_text_scale()); device.set_media_feature_preferences(environment.stylo_media_feature_preferences()); device.set_page_color_schemes(environment.stylo_page_color_schemes()); device diff --git a/moli-renderer-v8/src/style_engine/world_environment.rs b/moli-renderer-v8/src/style_engine/world_environment.rs index 58069dc8ba..9d39f2ed84 100644 --- a/moli-renderer-v8/src/style_engine/world_environment.rs +++ b/moli-renderer-v8/src/style_engine/world_environment.rs @@ -103,6 +103,7 @@ impl From> for StyleViewport { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub(crate) struct StyloStyleEnvironment { + preferred_text_scale_bits: Option, media_type: StyloStyleMediaType, color_scheme: StyloStyleColorScheme, page_color_scheme_bits: u8, @@ -155,6 +156,7 @@ impl StyloStyleEnvironment { overrides: &crate::protocol_types::EmulatedMediaOverrides, ) -> Self { Self { + preferred_text_scale_bits: overrides.preferred_text_scale.map(f32::to_bits), media_type: if overrides.media.as_deref() == Some("print") { StyloStyleMediaType::Print } else { @@ -197,6 +199,12 @@ impl StyloStyleEnvironment { self } + pub(super) fn preferred_text_scale(self) -> f32 { + self.preferred_text_scale_bits + .map(f32::from_bits) + .unwrap_or(1.0) + } + pub(super) fn stylo_media_type(self) -> MediaType { match self.media_type { StyloStyleMediaType::Screen => MediaType::screen(), diff --git a/moli-renderer-v8/src/style_engine/world_lifecycle.rs b/moli-renderer-v8/src/style_engine/world_lifecycle.rs index d0c7becfd0..595e77cd42 100644 --- a/moli-renderer-v8/src/style_engine/world_lifecycle.rs +++ b/moli-renderer-v8/src/style_engine/world_lifecycle.rs @@ -303,7 +303,7 @@ fn apply_retained_stylesheet_invalidations( shadow_scope_fallbacks, removed_shadow_scopes, viewport_size_changed, - used_color_scheme_changed, + computed_environment_changed, } = invalidations; let mut stylesheet_invalidation_roots = IndexSet::new(); if let Some(invalidations) = document_invalidations { @@ -353,7 +353,7 @@ fn apply_retained_stylesheet_invalidations( document, )); } - if used_color_scheme_changed { + if computed_environment_changed { invalidation_cleanup.invalidate_subtrees(host, [document]); } invalidation_cleanup.retain_stylesheet_invalidation_roots(host, stylesheet_invalidation_roots); diff --git a/moli/src/cli.rs b/moli/src/cli.rs index e8af1cfaa1..79f6732cd4 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -429,6 +429,10 @@ fn parse_request_header_arg(raw: &str) -> Result { #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct ServeArgs { + /// Allow CDP Browser.getBrowserCommandLine to expose the process arguments. + #[arg(long)] + pub enable_automation: bool, + #[arg(long, default_value = "127.0.0.1")] pub host: String, diff --git a/moli/src/fetch_dump/raster.rs b/moli/src/fetch_dump/raster.rs index 41429f5b4c..683f35b1ac 100644 --- a/moli/src/fetch_dump/raster.rs +++ b/moli/src/fetch_dump/raster.rs @@ -33,6 +33,7 @@ pub(super) async fn render_pdf(page: &mut Page) -> Result> { page, RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: RendererScreenshotPurpose::Print { print_background: false, }, diff --git a/moli/tests/cli.rs b/moli/tests/cli.rs index a8bc9166d1..1ffd65ea28 100644 --- a/moli/tests/cli.rs +++ b/moli/tests/cli.rs @@ -611,6 +611,7 @@ fn infers_serve_mode_when_called_without_args() { assert_eq!( cli.command, Commands::Serve(Box::new(ServeArgs { + enable_automation: false, host: "127.0.0.1".to_owned(), port: 9222, timeout: 10, @@ -637,6 +638,7 @@ fn parses_serve_flags_with_explicit_command() { assert_eq!( cli.command, Commands::Serve(Box::new(ServeArgs { + enable_automation: false, host: "0.0.0.0".to_owned(), port: 9333, timeout: 42, From b3cc2773467fd2d76ebf5cfb280a3bed7f3b7245 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:30:55 +0800 Subject: [PATCH 2/8] test(worker): use renderer stack budget for child message test --- moli-renderer-v8/src/runtime/page_vm/tests/worker.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs index 6ba6ca0808..8bf1affd63 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs @@ -2528,9 +2528,10 @@ async fn blob_worker_created_in_data_iframe_inherits_opaque_broadcast_channel_ow .await; } -#[tokio::test] -async fn child_message_handler_external_dedicated_worker_binds_child_client_event_owner() { - run_page_vm_async_test(async move { +#[test] +fn child_message_handler_external_dedicated_worker_binds_child_client_event_owner() { + // Match the render runtime's stack budget for the nested child/Worker turns. + run_page_vm_large_stack_async_test("child-message-worker-owner", || async move { let (base_url, server) = spawn_path_response_http_server(vec![( "/worker.js", "HTTP/1.1 200 OK", @@ -2655,8 +2656,7 @@ document server .await .expect("child message-created Worker server should finish"); - }) - .await; + }); } #[tokio::test] From 9152c7c460cafdd4cc75e74afe79d55c95672dd1 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:14:04 +0800 Subject: [PATCH 3/8] fix(protocol): honor overlay payload and session lifecycle --- .../src/conn/state/devtools_session.rs | 1 + .../emulation/tests/inspector_surfaces.rs | 128 ++++++++++++++- moli-protocol/src/domains/overlay.rs | 155 ++++++++++++------ 3 files changed, 232 insertions(+), 52 deletions(-) diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index e6abc999f0..4bd313e573 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -32,6 +32,7 @@ use moli_page_types::{ #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct DevToolsSessionState { pub(crate) dom_session_state: DevToolsDomSessionState, + pub(crate) overlay_enabled: bool, pub(crate) dom_debugger_event_listener_breakpoints: BTreeSet, pub(crate) dom_debugger_xhr_breakpoints: BTreeSet, diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs index 467edf7c56..e3bebec04f 100644 --- a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -141,7 +141,7 @@ async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { json!({"nodeId":doc["root"]["nodeId"],"selector":"#target"}), ) .await; - command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; + command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"showInfo":false,"showStyles":false,"showRulers":false,"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 255, 0, 255]); evaluate(&mut ctx, "target.style.left='60px';undefined").await; let moved = screenshot(&mut ctx).await; @@ -156,6 +156,132 @@ async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { assert!(ctx.take_response_by_id(88802)["error"].is_object()); } +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_rejects_unsupported_visual_options_explicitly() { + let mut ctx = page().await; + let doc = command(&mut ctx, "DOM.getDocument", json!({})).await; + ctx.process_async(json!({ + "id": 88803, "sessionId": "SID-1", "method": "Overlay.highlightNode", + "params": {"nodeId":doc["root"]["nodeId"],"highlightConfig":{ + "showInfo":true,"contentColor":{"r":255,"g":0,"b":0} + }} + })) + .await; + let response = ctx.take_response_by_id(88803); + assert_eq!(response["error"]["code"], json!(-32602)); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("showInfo") + ); + + ctx.process_async(json!({ + "id": 88804, "sessionId": "SID-1", "method": "Overlay.highlightNode", + "params": {"nodeId":doc["root"]["nodeId"],"selector":"div", "highlightConfig":{}} + })) + .await; + let response = ctx.take_response_by_id(88804); + assert_eq!(response["error"]["code"], json!(-32602)); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("selector") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_domain_state_survives_navigation_and_cleanup_without_document() { + let mut ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-1".into()); + browser_context.set_active_target_id("TID-1"); + browser_context.attach_active_session("SID-1"); + ctx.conn + .install_browser_context_fixture_for_test(browser_context); + + command(&mut ctx, "Overlay.enable", json!({})).await; + command(&mut ctx, "Overlay.hideHighlight", json!({})).await; + let owner = crate::conn::CommandOwnerScope::capture(&ctx.conn, Some("SID-1")); + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,first", + Some("SID-1"), + ) + .await; + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + command( + &mut ctx, + "Overlay.highlightRect", + json!({ + "x":10,"y":10,"width":30,"height":30,"color":{"r":255,"g":0,"b":0} + }), + ) + .await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [255, 0, 0, 255]); + + command( + &mut ctx, + "Page.navigate", + json!({"url":"data:text/html,second"}), + ) + .await; + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + assert_eq!( + pixel(&screenshot(&mut ctx).await, 20, 20), + [255, 255, 255, 255] + ); + command( + &mut ctx, + "Overlay.highlightRect", + json!({"x":10,"y":10,"width":30,"height":30,"color":{"r":0,"g":0,"b":255}}), + ) + .await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 0, 255, 255]); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .start_document_navigation_for_active_target("PENDING-OVERLAY".to_owned()) + .unwrap(); + command(&mut ctx, "Overlay.hideHighlight", json!({})).await; + command(&mut ctx, "Overlay.disable", json!({})).await; + ctx.conn + .browser_context + .as_mut() + .unwrap() + .clear_pending_document_navigation_for_target_if_loader_matches( + Some("TID-1"), + "PENDING-OVERLAY", + ); + assert_eq!( + pixel(&screenshot(&mut ctx).await, 20, 20), + [255, 255, 255, 255] + ); + assert!( + !ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); +} + #[tokio::test(flavor = "multi_thread")] async fn scroll_gesture_uses_hit_target_and_honors_wheel_cancellation() { let mut ctx = page().await; diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs index 2b1505140b..2d665f04dc 100644 --- a/moli-protocol/src/domains/overlay.rs +++ b/moli-protocol/src/domains/overlay.rs @@ -1,43 +1,18 @@ use super::command_output::CommandOutputPlan; use crate::conn::{CdpConnection, Cmd, CommandOwnerScope}; +use chromiumoxide_cdp::cdp::browser_protocol::{dom::Rgba, overlay::HighlightNodeParams}; use moli_core::page::{CompletedPageCommand, PendingPageCommand, RendererInspectorOverlayCommand}; use serde::Deserialize; -#[derive(Clone, Copy, Deserialize)] -struct Color { - r: i32, - g: i32, - b: i32, - a: Option, -} -impl Color { - fn components(self) -> [f32; 4] { +fn color(value: Option) -> [f32; 4] { + value.map_or([0.0; 4], |value| { [ - self.r.clamp(0, 255) as f32 / 255.0, - self.g.clamp(0, 255) as f32 / 255.0, - self.b.clamp(0, 255) as f32 / 255.0, - self.a.unwrap_or(1.0).clamp(0.0, 1.0), + value.r.clamp(0, 255) as f32 / 255.0, + value.g.clamp(0, 255) as f32 / 255.0, + value.b.clamp(0, 255) as f32 / 255.0, + value.a.unwrap_or(1.0).clamp(0.0, 1.0) as f32, ] - } -} -fn color(value: Option) -> [f32; 4] { - value.map_or([0.0; 4], Color::components) -} -#[derive(Deserialize, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HighlightConfig { - content_color: Option, - padding_color: Option, - border_color: Option, - margin_color: Option, -} -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct NodeParams { - node_id: Option, - backend_node_id: Option, - object_id: Option, - highlight_config: HighlightConfig, + }) } #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,18 +21,39 @@ struct RectParams { y: i32, width: i32, height: i32, - color: Option, - outline_color: Option, + color: Option, + outline_color: Option, +} + +fn unsupported_highlight_option( + config: &chromiumoxide_cdp::cdp::browser_protocol::overlay::HighlightConfig, +) -> Option { + let mut fields = serde_json::to_value(config).ok()?.as_object()?.clone(); + for color in ["contentColor", "paddingColor", "borderColor", "marginColor"] { + fields.remove(color); + } + // These values request exactly the protocol defaults and need no painting. + for flag in ["showInfo", "showStyles", "showRulers", "showExtensionLines"] { + if fields.get(flag) == Some(&serde_json::Value::Bool(false)) { + fields.remove(flag); + } + } + if fields.get("showAccessibilityInfo") == Some(&serde_json::Value::Bool(true)) { + fields.remove("showAccessibilityInfo"); + } + fields.into_iter().next().map(|(name, _)| name) } pub(crate) struct PendingOverlayCommandDispatch { command_id: Option, owner: CommandOwnerScope, + cleanup: bool, pending: PendingPageCommand, } pub(crate) struct CompletedOverlayCommandDispatch { command_id: Option, owner: CommandOwnerScope, + cleanup: bool, completed: Result, } pub(crate) enum OverlayCommandTaskStep { @@ -69,6 +65,7 @@ impl PendingOverlayCommandDispatch { CompletedOverlayCommandDispatch { command_id: self.command_id, owner: self.owner, + cleanup: self.cleanup, completed: self.pending.wait().await.map_err(|error| error.to_string()), } } @@ -104,17 +101,34 @@ fn parse_command(cmd: &Cmd<'_>) -> Result { let p = cmd - .get_params::() + .get_params::() .map_err(invalid)? .ok_or_else(|| invalid("Missing node"))?; + if p.selector.is_some() { + return Err(invalid("selector highlighting is not supported")); + } if p.node_id.is_none() && p.backend_node_id.is_none() && p.object_id.is_none() { return Err(invalid("A node reference is required")); } + if let Some(option) = unsupported_highlight_option(&p.highlight_config) { + return Err(CommandOutputPlan::error( + -32602, + format!("Highlight option {option} is not supported"), + )); + } + let node_id = p + .node_id + .map(|id| u32::try_from(*id.inner()).map_err(|_| invalid("Invalid nodeId"))) + .transpose()?; + let backend_node_id = p + .backend_node_id + .map(|id| u32::try_from(*id.inner()).map_err(|_| invalid("Invalid backendNodeId"))) + .transpose()?; let config = p.highlight_config; Ok(RendererInspectorOverlayCommand::Node { - node_id: p.node_id, - backend_node_id: p.backend_node_id, - object_id: p.object_id, + node_id, + backend_node_id, + object_id: p.object_id.map(String::from), colors: [ config.content_color, config.padding_color, @@ -136,6 +150,32 @@ pub(crate) fn try_start_overlay_command_dispatch( Ok(command) => command, Err(plan) => return OverlayCommandTaskStep::Complete(plan), }; + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + if conn + .target_devtools_session_state_for_owner(&owner) + .is_none() + { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "No target for Overlay command", + )); + } + if let Some(enabled) = match command { + RendererInspectorOverlayCommand::Enable => Some(true), + RendererInspectorOverlayCommand::Disable => Some(false), + _ => None, + } { + conn.with_target_devtools_session_state_for_owner_mut(&owner, |state| { + state.overlay_enabled = enabled; + }); + if enabled { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); + } + } + let cleanup = matches!( + command, + RendererInspectorOverlayCommand::Disable | RendererInspectorOverlayCommand::Hide + ); if conn.layout_policy() == moli_core::LayoutPolicy::Mock && matches!( command, @@ -148,18 +188,24 @@ pub(crate) fn try_start_overlay_command_dispatch( "Inspector highlighting requires layout", )); } - let owner = CommandOwnerScope::capture(conn, cmd.session_id); let session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); - let result = conn - .loaded_page_mut_for_protocol_access_for_owner(&owner) - .and_then(|page| { - page.start_set_inspector_overlay(session, command) - .map_err(|error| error.to_string()) - }); + let page = if cleanup { + conn.loaded_page_mut_for_target_configuration_for_owner(&owner) + } else { + conn.loaded_page_mut_for_protocol_access_for_owner(&owner) + }; + if cleanup && page.is_err() { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); + } + let result = page.and_then(|page| { + page.start_set_inspector_overlay(session, command) + .map_err(|error| error.to_string()) + }); match result { Ok(pending) => OverlayCommandTaskStep::Pending(PendingOverlayCommandDispatch { command_id: cmd.id, owner, + cleanup, pending, }), Err(error) => OverlayCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)), @@ -171,11 +217,18 @@ pub(crate) fn complete_pending_overlay_command( completed: CompletedOverlayCommandDispatch, ) -> CommandOutputPlan { let result = completed.completed.and_then(|completion| { - conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) - .and_then(|page| { - page.finish_set_inspector_overlay(completion) - .map_err(|error| error.to_string()) - }) + let page = if completed.cleanup { + conn.loaded_page_mut_for_target_configuration_for_owner(&completed.owner) + } else { + conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) + }; + match page { + Ok(page) => page + .finish_set_inspector_overlay(completion) + .map_err(|error| error.to_string()), + Err(_) if completed.cleanup => Ok(()), + Err(error) => Err(error), + } }); match result { Ok(()) => CommandOutputPlan::success(), From 94bdfe9f59561509feceb181ddabc7c40de75b20 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:07:35 +0800 Subject: [PATCH 4/8] fix: preserve per-target style state and bound vision blur memory --- Cargo.lock | 22 ++-- Cargo.toml | 14 +-- moli-paint/src/vision.rs | 115 +++++++++++------- .../src/conn/state/devtools_session.rs | 1 - moli-protocol/src/domains/emulation.rs | 26 ++-- .../src/domains/emulation/page_session.rs | 42 ++++++- moli-protocol/src/domains/emulation/tests.rs | 47 +++++++ .../emulation/tests/inspector_surfaces.rs | 26 ---- moli-protocol/src/domains/overlay.rs | 13 +- 9 files changed, 191 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e583dd4e22..c5257d9150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "selectors" version = "0.40.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "bitflags", "cssparser", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "serde", "stable_deref_trait", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "stylo" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "arrayvec", @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "stylo_atoms" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "string_cache", "string_cache_codegen", @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "stylo_derive" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "darling", "proc-macro2", @@ -5077,7 +5077,7 @@ dependencies = [ [[package]] name = "stylo_dom" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "bitflags", "stylo_malloc_size_of", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "cssparser", @@ -5103,7 +5103,7 @@ dependencies = [ [[package]] name = "stylo_static_prefs" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "toml", ] @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "stylo_traits" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "bitflags", @@ -5421,7 +5421,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.5.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "cssparser", "servo_arc", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 2cabafaad5..beb8041ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,13 +102,13 @@ curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1 cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" } v8 = { path = "vendor/v8-152.2.0" } deno_v8 = { path = "vendor/deno_v8-0.3.0" } -selectors = { git = "https://github.com/lexmount/stylo", branch = "moli" } -servo_arc = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_atoms = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_dom = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_static_prefs = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_traits = { git = "https://github.com/lexmount/stylo", branch = "moli" } +selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } taffy = { git = "https://github.com/ldm0/taffy", rev = "645bf5a49df607a8bff34ec90c5343901d04f10e" } [workspace.lints.clippy] diff --git a/moli-paint/src/vision.rs b/moli-paint/src/vision.rs index 336931ed6a..8d0f1f8d44 100644 --- a/moli-paint/src/vision.rs +++ b/moli-paint/src/vision.rs @@ -98,62 +98,74 @@ fn linear_to_srgb(value: f32) -> u8 { } // Filter in premultiplied linear RGB so transparent pixels cannot bleed their -// hidden color into visible neighbors. Charge the source, intermediate and -// destination float images against the same budget as paint filters. +// hidden color into visible neighbors. Process horizontal bands with enough +// transparent overlap for the Gaussian kernel instead of retaining full-size +// float images for the entire capture. fn blur(raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { if !(device_scale * 2.0).is_normal() || device_scale <= 0.0 { return Err(PaintError::InvalidCaptureDeviceScale { device_scale }); } - let padding = (6.0 * device_scale).ceil() as u32; - let (width, height) = raster - .width - .checked_add(padding.saturating_mul(2)) - .zip(raster.height.checked_add(padding.saturating_mul(2))) - .ok_or(PaintError::BufferLengthOverflow { + // Four sigma covers image::blur's finite kernel on both sides of a band. + let padding = (8.0 * device_scale).ceil() as u32; + let width = raster.width.checked_add(padding.saturating_mul(2)).ok_or( + PaintError::BufferLengthOverflow { width: raster.width, height: raster.height, - })?; - let required_bytes = (width as usize) - .saturating_mul(height as usize) - .saturating_mul(48) - .saturating_add(raster.rgba.len()); - if required_bytes > MAX_TRANSIENT_RASTER_BYTES { + }, + )?; + if raster.width == 0 || raster.height == 0 { + return Ok(()); + } + // The filter holds its input, two work images, and output simultaneously. + let float_row_bytes = (width as usize).saturating_mul(64); + let raster_bytes = raster.rgba.len().saturating_mul(2); + let max_rows = (MAX_TRANSIENT_RASTER_BYTES.saturating_sub(raster_bytes) / float_row_bytes) + .saturating_sub(padding as usize * 2); + if max_rows == 0 { return Err(PaintError::TransientRasterBudgetExceeded { - required_bytes, + required_bytes: float_row_bytes + .saturating_mul(padding as usize * 2 + 1) + .saturating_add(raster_bytes), max_bytes: MAX_TRANSIENT_RASTER_BYTES, }); } - if raster.width == 0 || raster.height == 0 { - return Ok(()); - } - let image = image::Rgba32FImage::from_fn(width, height, |x, y| { - if x < padding || y < padding || x >= padding + raster.width || y >= padding + raster.height - { - return image::Rgba([0.0; 4]); - } - let (x, y) = (x - padding, y - padding); - let offset = ((y as usize) * raster.width as usize + x as usize) * 4; - let pixel = &raster.rgba[offset..offset + 4]; - let alpha = f32::from(pixel[3]) / 255.0; - image::Rgba([ - srgb_to_linear(pixel[0]) * alpha, - srgb_to_linear(pixel[1]) * alpha, - srgb_to_linear(pixel[2]) * alpha, - alpha, - ]) - }); - let blurred = image::imageops::blur(&image, 2.0 * device_scale); - for (index, output) in raster.rgba.chunks_exact_mut(4).enumerate() { - let input = blurred.get_pixel( - index as u32 % raster.width + padding, - index as u32 / raster.width + padding, - ); - let alpha = input[3]; - for (out, value) in output[..3].iter_mut().zip(input.0) { - *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + let band_rows = max_rows.min(256).min(raster.height as usize) as u32; + let mut result = vec![0; raster.rgba.len()]; + for start_y in (0..raster.height).step_by(band_rows as usize) { + let rows = band_rows.min(raster.height - start_y); + let image = image::Rgba32FImage::from_fn(width, rows + padding * 2, |x, y| { + let source_y = start_y.checked_add(y).and_then(|y| y.checked_sub(padding)); + let Some(source_y) = source_y.filter(|&y| y < raster.height) else { + return image::Rgba([0.0; 4]); + }; + let Some(source_x) = x.checked_sub(padding).filter(|&x| x < raster.width) else { + return image::Rgba([0.0; 4]); + }; + let offset = ((source_y as usize) * raster.width as usize + source_x as usize) * 4; + let pixel = &raster.rgba[offset..offset + 4]; + let alpha = f32::from(pixel[3]) / 255.0; + image::Rgba([ + srgb_to_linear(pixel[0]) * alpha, + srgb_to_linear(pixel[1]) * alpha, + srgb_to_linear(pixel[2]) * alpha, + alpha, + ]) + }); + let blurred = image::imageops::blur(&image, 2.0 * device_scale); + for y in 0..rows { + let offset = ((start_y + y) as usize * raster.width as usize) * 4; + let row = &mut result[offset..offset + raster.width as usize * 4]; + for (x, output) in row.chunks_exact_mut(4).enumerate() { + let input = blurred.get_pixel(x as u32 + padding, y + padding); + let alpha = input[3]; + for (out, value) in output[..3].iter_mut().zip(input.0) { + *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + } + output[3] = (alpha * 255.0).round() as u8; + } } - output[3] = (alpha * 255.0).round() as u8; } + raster.rgba = result; Ok(()) } @@ -211,4 +223,19 @@ mod tests { )); assert_eq!(raster.rgba, [1, 2, 3, 4]); } + + #[test] + fn blurred_vision_accepts_4k_capture() { + let mut raster = RasterImage { + width: 3840, + height: 2160, + rgba: vec![255; 3840 * 2160 * 4], + }; + VisionDeficiency::BlurredVision + .apply(&mut raster, 1.0) + .unwrap(); + for y in [255, 256, 511, 512, 1080] { + assert_eq!(&raster.rgba[(y * 3840 + 1920) * 4..][..4], &[255; 4]); + } + } } diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index 4bd313e573..e6abc999f0 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -32,7 +32,6 @@ use moli_page_types::{ #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct DevToolsSessionState { pub(crate) dom_session_state: DevToolsDomSessionState, - pub(crate) overlay_enabled: bool, pub(crate) dom_debugger_event_listener_breakpoints: BTreeSet, pub(crate) dom_debugger_xhr_breakpoints: BTreeSet, diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 5e011287d2..7011422883 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,15 +828,12 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - if !conn.update_emulation_state_for_session_owner(cmd.session_id, |state| { - if let Some(mut state) = state { + if let Err(error) = + page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_emulated_media(overrides.clone()); - } - }) { - return EmulationCommandTaskStep::Complete(CommandOutputPlan::error( - -31998, - "BrowserContextNotLoaded", - )); + }) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) } @@ -865,7 +862,7 @@ fn start_os_text_scale_command( )); } if let Err(error) = - page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) { @@ -878,10 +875,8 @@ fn start_style_environment_update( conn: &mut CdpConnection, cmd: &Cmd<'_>, ) -> EmulationCommandTaskStep { - let owner = CommandOwnerScope::capture(conn, cmd.session_id); - let page_overrides = conn.navigation_load_inputs_for_owner(&owner).emulated_media; let pending = if emulation_command_is_context_wide(conn, cmd.session_id) { - match start_context_emulated_media_page_commands(conn, &page_overrides) { + match start_context_emulated_media_page_commands(conn) { Ok(pending) => pending, Err(error) => { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)); @@ -889,6 +884,9 @@ fn start_style_environment_update( } } else { let owner_scope = CommandOwnerScope::capture(conn, cmd.session_id); + let page_overrides = conn + .navigation_load_inputs_for_owner(&owner_scope) + .emulated_media; let Some(page) = loaded_page_mut_for_target_configuration(conn, cmd.session_id) else { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); }; @@ -2636,7 +2634,6 @@ fn emulation_command_is_context_wide(conn: &CdpConnection, session_id: Option<&s fn start_context_emulated_media_page_commands( conn: &mut CdpConnection, - overrides: &moli_core::page::EmulatedMediaOverrides, ) -> Result, String> { let Some(browser_context) = conn.browser_context.as_mut() else { return Ok(Vec::new()); @@ -2645,6 +2642,7 @@ fn start_context_emulated_media_page_commands( let mut pending = Vec::new(); for target in browser_context.page_targets.iter_mut() { let target_id = target.target_id().to_owned(); + let overrides = (&target.effective_emulation_state.emulated_media).into(); let Some(page) = target.loaded_page_mut() else { continue; }; @@ -2655,7 +2653,7 @@ fn start_context_emulated_media_page_commands( }, operation: PendingEmulationPageOperation::SetEmulatedMedia, pending: page - .start_set_emulated_media(overrides) + .start_set_emulated_media(&overrides) .map_err(|error| error.to_string())?, runtime_response_rx: None, }); diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index 9a17dc3d7b..0c8624a27f 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -14,3 +14,43 @@ pub(super) fn update_page_emulation_state( } Err("BrowserContextNotLoaded".to_owned()) } + +pub(super) fn update_style_environment_state( + conn: &mut CdpConnection, + session_id: Option<&str>, + mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), +) -> Result<(), String> { + let owners = if super::emulation_command_is_context_wide(conn, session_id) { + conn.browser_context + .as_ref() + .map(|context| { + context + .page_targets + .iter() + .map(|target| { + CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { + browser_context_id: context.id.clone(), + target_id: target.target_id().to_owned(), + session_key: moli_page_types::DevToolsSessionKey::Primary, + }) + }) + .collect::>() + }) + .unwrap_or_default() + } else { + vec![CommandOwnerScope::capture(conn, session_id)] + }; + if owners.is_empty() { + return Err("BrowserContextNotLoaded".to_owned()); + } + for owner in owners { + if !conn.update_emulation_state_for_owner(&owner, |state| { + if let Some(state) = state { + update(state); + } + }) { + return Err("BrowserContextNotLoaded".to_owned()); + } + } + Ok(()) +} diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 1ff4bd3850..d281817ce7 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -3042,6 +3042,53 @@ async fn context_emulated_media_applies_to_loaded_background_page_without_activa ); } +#[tokio::test(flavor = "multi_thread")] +async fn context_media_keeps_each_page_text_scale_across_reload() { + let mut ctx = TestContext::new(); + let background = PageTargetHost::new( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + ); + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-active"); + bc.attach_active_session("SID-active"); + bc.insert_page_target_host(background); + ctx.conn.install_browser_context_fixture_for_test(bc); + let html = "data:text/html,text"; + for (session, scale, id) in [("SID-background", 2.0, 1), ("SID-active", 1.5, 2)] { + ctx.install_navigation_fixture_for_session_owner(html, Some(session)) + .await; + ctx.process_async(json!({"id":id,"sessionId":session,"method":"Emulation.setEmulatedOSTextScale","params":{"scale":scale}})).await; + ctx.expect_result(id, json!({}), Some(session)); + } + ctx.process_async(json!({"id":3,"method":"Emulation.setEmulatedMedia","params":{"features":[{"name":"prefers-color-scheme","value":"dark"}]}})).await; + ctx.expect_result(3, json!({}), None); + for (session, expected, id) in [("SID-background", "32px", 4), ("SID-active", "24px", 5)] { + ctx.process_async(json!({"id":id,"sessionId":session,"method":"Runtime.evaluate","params":{"expression":"[getComputedStyle(document.body).fontSize,matchMedia('(prefers-color-scheme:dark)').matches]","returnByValue":true}})).await; + let response = ctx.take_response_by_id(id); + assert_eq!( + response["result"]["result"]["value"], + json!([expected, true]), + "{session}: {response}" + ); + } + ctx.process_async( + json!({"id":6,"sessionId":"SID-background","method":"Page.navigate","params":{"url":html}}), + ) + .await; + let navigation = ctx.take_response_by_id(6); + assert!(navigation["error"].is_null(), "{navigation}"); + ctx.process_async(json!({"id":7,"sessionId":"SID-background","method":"Runtime.evaluate","params":{"expression":"getComputedStyle(document.body).fontSize","returnByValue":true}})).await; + let response = ctx.take_response_by_id(7); + assert_eq!( + response["result"]["result"]["value"], + json!("32px"), + "{response}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn context_locale_override_applies_to_loaded_background_page_without_activation() { let mut ctx = TestContext::new(); diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs index e3bebec04f..a2d6eb9d3b 100644 --- a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -202,25 +202,11 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document command(&mut ctx, "Overlay.enable", json!({})).await; command(&mut ctx, "Overlay.hideHighlight", json!({})).await; - let owner = crate::conn::CommandOwnerScope::capture(&ctx.conn, Some("SID-1")); - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); - ctx.install_navigation_fixture_for_session_owner( "data:text/html,first", Some("SID-1"), ) .await; - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); command( &mut ctx, "Overlay.highlightRect", @@ -237,12 +223,6 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document json!({"url":"data:text/html,second"}), ) .await; - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); assert_eq!( pixel(&screenshot(&mut ctx).await, 20, 20), [255, 255, 255, 255] @@ -274,12 +254,6 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document pixel(&screenshot(&mut ctx).await, 20, 20), [255, 255, 255, 255] ); - assert!( - !ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); } #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs index 2d665f04dc..a201c6af0d 100644 --- a/moli-protocol/src/domains/overlay.rs +++ b/moli-protocol/src/domains/overlay.rs @@ -160,17 +160,8 @@ pub(crate) fn try_start_overlay_command_dispatch( "No target for Overlay command", )); } - if let Some(enabled) = match command { - RendererInspectorOverlayCommand::Enable => Some(true), - RendererInspectorOverlayCommand::Disable => Some(false), - _ => None, - } { - conn.with_target_devtools_session_state_for_owner_mut(&owner, |state| { - state.overlay_enabled = enabled; - }); - if enabled { - return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); - } + if matches!(command, RendererInspectorOverlayCommand::Enable) { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); } let cleanup = matches!( command, From 9b92c4f8b8d34caf7af8a4515ed6b552761be22a Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:31:16 +0800 Subject: [PATCH 5/8] fix(emulation): compose context style defaults with target overrides --- .../protocol_server/tests/cdp_dynamic_page.rs | 111 ++++++++++++++++++ .../conn/browser_context/emulation_owner.rs | 48 +++++++- .../browser_context/target_session_owner.rs | 1 + .../src/conn/page_state/fetch_state.rs | 1 + .../src/conn/state/browser_context.rs | 2 + moli-protocol/src/conn/state/emulation.rs | 26 ++++ .../src/conn/state/page_target_host.rs | 10 ++ moli-protocol/src/domains/emulation.rs | 16 ++- .../src/domains/emulation/page_session.rs | 60 ++++------ 9 files changed, 232 insertions(+), 43 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index ef841cad45..2b7bd1585d 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -2849,6 +2849,117 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( abort_test_cdp_server(server).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { + let (addr, server) = spawn_test_protocol_server().await; + let (mut browser, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .expect("connect browser websocket"); + + let media = send_cdp_command( + &mut browser, + 1, + "Emulation.setEmulatedMedia", + None, + json!({ + "features": [ + { "name": "prefers-color-scheme", "value": "dark" } + ] + }), + ) + .await; + assert_eq!(response_by_id(&media, 1)["result"], json!({})); + let text_scale = send_cdp_command( + &mut browser, + 2, + "Emulation.setEmulatedOSTextScale", + None, + json!({ "scale": 1.5 }), + ) + .await; + assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); + + let target_id = create_dynamic_target(&mut browser, 3).await; + let attach = send_cdp_command( + &mut browser, + 4, + "Target.attachToTarget", + None, + json!({ "targetId": target_id, "flatten": true }), + ) + .await; + let session_id = response_by_id(&attach, 4)["result"]["sessionId"] + .as_str() + .expect("new target session") + .to_owned(); + let url = "data:text/html,text"; + let navigation = send_cdp_command( + &mut browser, + 5, + "Page.navigate", + Some(&session_id), + json!({ "url": url }), + ) + .await; + assert!(response_by_id(&navigation, 5).get("result").is_some()); + + let inherited = send_cdp_command( + &mut browser, + 6, + "Runtime.evaluate", + Some(&session_id), + json!({ + "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", + "returnByValue": true + }), + ) + .await; + assert_eq!( + response_by_id(&inherited, 6)["result"]["result"]["value"], + json!([true, "24px"]) + ); + + let detach = send_cdp_command( + &mut browser, + 7, + "Target.detachFromTarget", + None, + json!({ "sessionId": session_id }), + ) + .await; + assert_eq!(response_by_id(&detach, 7)["result"], json!({})); + let reattach = send_cdp_command( + &mut browser, + 8, + "Target.attachToTarget", + None, + json!({ "targetId": target_id, "flatten": true }), + ) + .await; + let replacement_session_id = response_by_id(&reattach, 8)["result"]["sessionId"] + .as_str() + .expect("replacement target session") + .to_owned(); + let retained = send_cdp_command( + &mut browser, + 9, + "Runtime.evaluate", + Some(&replacement_session_id), + json!({ + "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", + "returnByValue": true + }), + ) + .await; + assert_eq!( + response_by_id(&retained, 9)["result"]["result"]["value"], + json!([true, "24px"]) + ); + + abort_test_cdp_server(server).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_owner_registry_shutdown_joins_shared_default_page_owner() { let (addr, server, owner_registry) = spawn_test_protocol_server_with_owner_registry().await; diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index 66551ad705..52a9996aa6 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -9,6 +9,8 @@ use crate::conn::{ pub(crate) struct TargetEmulationStateUpdate<'a> { raw: &'a mut DevToolsEmulationSessionState, effective: &'a mut EffectiveTargetEmulationState, + emulated_media_override: &'a mut EmulatedMediaOverrides, + default_emulated_media: EmulatedMediaOverrides, } impl TargetEmulationStateUpdate<'_> { @@ -43,14 +45,20 @@ impl TargetEmulationStateUpdate<'_> { pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { self.raw.emulated_media.preferred_text_scale = scale; - self.effective.emulated_media.preferred_text_scale = scale; + self.emulated_media_override.preferred_text_scale = scale; + self.effective.emulated_media = self + .emulated_media_override + .overlaid_on(&self.default_emulated_media); } pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); - emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; - self.effective.emulated_media = emulated_media; + emulated_media.preferred_text_scale = self.emulated_media_override.preferred_text_scale; + *self.emulated_media_override = emulated_media; + self.effective.emulated_media = self + .emulated_media_override + .overlaid_on(&self.default_emulated_media); } pub(crate) fn set_emulated_device_metrics( @@ -87,6 +95,7 @@ impl TargetSessionOwnerMut<'_> { self, f: impl FnOnce(Option>), ) -> bool { + let default_emulated_media = self.browser_context.default_emulated_media.clone(); let Some(state) = self.browser_context.page_target_mut(&self.target_id) else { f(None); return false; @@ -96,7 +105,13 @@ impl TargetSessionOwnerMut<'_> { .ensure_session(&self.session_key) .emulation_session_state; let effective = &mut state.effective_emulation_state; - f(Some(TargetEmulationStateUpdate { raw, effective })); + let emulated_media_override = &mut state.emulated_media_override; + f(Some(TargetEmulationStateUpdate { + raw, + effective, + emulated_media_override, + default_emulated_media, + })); true } @@ -287,6 +302,11 @@ impl CdpConnection { &mut self, session_id: &str, ) -> Option { + let default_emulated_media = self + .browser_context + .as_ref()? + .default_emulated_media + .clone(); let mut owner = self.target_session_owner_mut(Some(session_id))?; Some(owner.mutate_page_state(|target, session_key| { let raw = std::mem::take( @@ -300,9 +320,14 @@ impl CdpConnection { .devtools_sessions .navigator_emulation .remove(session_key); + let previous_emulated_media = target.effective_emulation_state.emulated_media.clone(); let mut delta = target .effective_emulation_state .disable_session_handler(&raw); + target.emulated_media_override = Default::default(); + target.refresh_emulated_media(&default_emulated_media); + delta.emulated_media = + previous_emulated_media != target.effective_emulation_state.emulated_media; delta.navigator_queries = previous_queries != target.devtools_sessions.navigator_emulation.effective(); delta @@ -311,6 +336,21 @@ impl CdpConnection { } impl BrowserContext { + pub(crate) fn set_default_emulated_media(&mut self, mut media: EmulatedMediaOverrides) { + media.preferred_text_scale = self.default_emulated_media.preferred_text_scale; + self.default_emulated_media = media; + for target in self.page_targets.iter_mut() { + target.refresh_emulated_media(&self.default_emulated_media); + } + } + + pub(crate) fn set_default_preferred_text_scale(&mut self, scale: Option) { + self.default_emulated_media.preferred_text_scale = scale; + for target in self.page_targets.iter_mut() { + target.refresh_emulated_media(&self.default_emulated_media); + } + } + pub(crate) fn effective_active_emulated_device_metrics(&self) -> Option { self.active_page_target() .effective_emulation_state diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 1a6dfa1d7a..83ca908341 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -370,6 +370,7 @@ impl TargetNavigationLoadInputs { .default_emulated_device_metrics .as_ref() .map(|metrics| metrics.viewport_surface()); + inputs.emulated_media = (&browser_context.default_emulated_media).into(); inputs.document_activity = browser_context.active_document_activity(); inputs.network_offline = browser_context.effective_active_network_offline(); inputs diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index e34aeb519b..b342d2205a 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -65,6 +65,7 @@ impl BrowserContext { self.default_document_cookie_manager_surface.clone(); } host.set_base_cache_disabled(self.global_cache_disabled); + host.refresh_emulated_media(&self.default_emulated_media); if let Some(config) = self.page_navigation_runtime_config.clone() { let engine = self.new_page_navigation_engine(config); host.install_navigation_engine(engine); diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 952bc8520c..6c9f5110c3 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -76,6 +76,7 @@ pub struct BrowserContext { pub proxy_autoconfig_url: Option, pub proxy_socks_version: Option, pub(crate) default_emulated_device_metrics: Option, + pub(crate) default_emulated_media: super::EmulatedMediaOverrides, pub(crate) next_default_document_start_script_id: u32, pub(crate) default_document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) storage_quota_overrides: HashMap, @@ -527,6 +528,7 @@ impl BrowserContext { proxy_autoconfig_url: None, proxy_socks_version: None, default_emulated_device_metrics: None, + default_emulated_media: Default::default(), next_default_document_start_script_id: 0, default_document_start_scripts: Vec::new(), storage_quota_overrides: HashMap::new(), diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index c43ff9bdb4..ed57528643 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -101,6 +101,32 @@ pub struct EmulatedMediaOverrides { pub contrast: Option, } +impl EmulatedMediaOverrides { + pub(crate) fn overlaid_on(&self, defaults: &Self) -> Self { + Self { + preferred_text_scale: self.preferred_text_scale.or(defaults.preferred_text_scale), + media: self.media.clone().or_else(|| defaults.media.clone()), + color_scheme: self + .color_scheme + .clone() + .or_else(|| defaults.color_scheme.clone()), + reduced_motion: self + .reduced_motion + .clone() + .or_else(|| defaults.reduced_motion.clone()), + reduced_transparency: self + .reduced_transparency + .clone() + .or_else(|| defaults.reduced_transparency.clone()), + forced_colors: self + .forced_colors + .clone() + .or_else(|| defaults.forced_colors.clone()), + contrast: self.contrast.clone().or_else(|| defaults.contrast.clone()), + } + } +} + impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { diff --git a/moli-protocol/src/conn/state/page_target_host.rs b/moli-protocol/src/conn/state/page_target_host.rs index d7810335ab..9c62c2df04 100644 --- a/moli-protocol/src/conn/state/page_target_host.rs +++ b/moli-protocol/src/conn/state/page_target_host.rs @@ -30,6 +30,7 @@ pub struct PageTargetHost { pub(crate) tls_verify_host_override: Option, pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner, pub(crate) effective_emulation_state: EffectiveTargetEmulationState, + pub(crate) emulated_media_override: super::emulation::EmulatedMediaOverrides, pub(crate) input_intercept_drags_enabled: bool, pub(crate) input_drag_intercepted: bool, pub(crate) css_enabled: bool, @@ -54,6 +55,7 @@ impl PageTargetHost { tls_verify_host_override: None, environment_owner: Default::default(), effective_emulation_state: EffectiveTargetEmulationState::default(), + emulated_media_override: Default::default(), input_intercept_drags_enabled: false, input_drag_intercepted: false, css_enabled: false, @@ -112,6 +114,14 @@ impl PageTargetHost { &self.target_id } + pub(crate) fn refresh_emulated_media( + &mut self, + defaults: &super::emulation::EmulatedMediaOverrides, + ) { + self.effective_emulation_state.emulated_media = + self.emulated_media_override.overlaid_on(defaults); + } + fn replace_target_id(&mut self, target_id: String) { self.target_id = target_id; } diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 7011422883..fe7b4c3819 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,11 +828,14 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - if let Err(error) = + let update = if emulation_command_is_context_wide(conn, cmd.session_id) { + page_session::set_context_emulated_media(conn, overrides) + } else { page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { - state.set_emulated_media(overrides.clone()); + state.set_emulated_media(overrides); }) - { + }; + if let Err(error) = update { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) @@ -861,11 +864,14 @@ fn start_os_text_scale_command( "Text scale must be finite and positive", )); } - if let Err(error) = + let update = if emulation_command_is_context_wide(conn, cmd.session_id) { + page_session::set_context_preferred_text_scale(conn, params.scale) + } else { page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) - { + }; + if let Err(error) = update { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index 0c8624a27f..be04cf109a 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -18,39 +18,31 @@ pub(super) fn update_page_emulation_state( pub(super) fn update_style_environment_state( conn: &mut CdpConnection, session_id: Option<&str>, - mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), + update: impl FnOnce(TargetEmulationStateUpdate<'_>), ) -> Result<(), String> { - let owners = if super::emulation_command_is_context_wide(conn, session_id) { - conn.browser_context - .as_ref() - .map(|context| { - context - .page_targets - .iter() - .map(|target| { - CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { - browser_context_id: context.id.clone(), - target_id: target.target_id().to_owned(), - session_key: moli_page_types::DevToolsSessionKey::Primary, - }) - }) - .collect::>() - }) - .unwrap_or_default() - } else { - vec![CommandOwnerScope::capture(conn, session_id)] - }; - if owners.is_empty() { - return Err("BrowserContextNotLoaded".to_owned()); - } - for owner in owners { - if !conn.update_emulation_state_for_owner(&owner, |state| { - if let Some(state) = state { - update(state); - } - }) { - return Err("BrowserContextNotLoaded".to_owned()); - } - } + update_page_emulation_state(conn, session_id, update) +} + +pub(super) fn set_context_emulated_media( + conn: &mut CdpConnection, + media: crate::conn::EmulatedMediaOverrides, +) -> Result<(), String> { + let context = conn + .browser_context + .as_mut() + .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; + context.set_default_emulated_media(media); + Ok(()) +} + +pub(super) fn set_context_preferred_text_scale( + conn: &mut CdpConnection, + scale: Option, +) -> Result<(), String> { + let context = conn + .browser_context + .as_mut() + .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; + context.set_default_preferred_text_scale(scale); Ok(()) } From e692b1f76c6778a222e066d98fb1de3a3a63ec83 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:12 +0800 Subject: [PATCH 6/8] fix(ci): retain Stylo baseline and use page-root emulation --- Cargo.lock | 22 +++++++++---------- Cargo.toml | 14 ++++++------ .../protocol_server/tests/cdp_dynamic_page.rs | 5 +++-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5257d9150..9dfa1bcc2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "selectors" version = "0.40.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "cssparser", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "serde", "stable_deref_trait", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "stylo" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "arrayvec", @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "stylo_atoms" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "string_cache", "string_cache_codegen", @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "stylo_derive" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", @@ -5077,7 +5077,7 @@ dependencies = [ [[package]] name = "stylo_dom" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "stylo_malloc_size_of", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "cssparser", @@ -5103,7 +5103,7 @@ dependencies = [ [[package]] name = "stylo_static_prefs" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "toml", ] @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "stylo_traits" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "bitflags", @@ -5421,7 +5421,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.5.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "cssparser", "servo_arc", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index beb8041ded..eab945c591 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,13 +102,13 @@ curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1 cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" } v8 = { path = "vendor/v8-152.2.0" } deno_v8 = { path = "vendor/deno_v8-0.3.0" } -selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } taffy = { git = "https://github.com/ldm0/taffy", rev = "645bf5a49df607a8bff34ec90c5343901d04f10e" } [workspace.lints.clippy] diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 2b7bd1585d..903625829f 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -2856,9 +2856,10 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) .await .expect("connect browser websocket"); + let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; let media = send_cdp_command( - &mut browser, + &mut root_page, 1, "Emulation.setEmulatedMedia", None, @@ -2871,7 +2872,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&media, 1)["result"], json!({})); let text_scale = send_cdp_command( - &mut browser, + &mut root_page, 2, "Emulation.setEmulatedOSTextScale", None, From 38822756e39df07c4fe1857e64525cbf6b4d71f4 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:08:04 +0800 Subject: [PATCH 7/8] test(cdp): keep style defaults within one frontend owner --- .../protocol_server/tests/cdp_dynamic_page.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 903625829f..0234102fc6 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -2852,10 +2852,6 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { let (addr, server) = spawn_test_protocol_server().await; - let (mut browser, _) = - connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) - .await - .expect("connect browser websocket"); let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; let media = send_cdp_command( @@ -2881,9 +2877,9 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); - let target_id = create_dynamic_target(&mut browser, 3).await; + let target_id = create_dynamic_target(&mut root_page, 3).await; let attach = send_cdp_command( - &mut browser, + &mut root_page, 4, "Target.attachToTarget", None, @@ -2896,7 +2892,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .to_owned(); let url = "data:text/html,text"; let navigation = send_cdp_command( - &mut browser, + &mut root_page, 5, "Page.navigate", Some(&session_id), @@ -2906,7 +2902,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se assert!(response_by_id(&navigation, 5).get("result").is_some()); let inherited = send_cdp_command( - &mut browser, + &mut root_page, 6, "Runtime.evaluate", Some(&session_id), @@ -2922,7 +2918,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se ); let detach = send_cdp_command( - &mut browser, + &mut root_page, 7, "Target.detachFromTarget", None, @@ -2931,7 +2927,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&detach, 7)["result"], json!({})); let reattach = send_cdp_command( - &mut browser, + &mut root_page, 8, "Target.attachToTarget", None, @@ -2943,7 +2939,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .expect("replacement target session") .to_owned(); let retained = send_cdp_command( - &mut browser, + &mut root_page, 9, "Runtime.evaluate", Some(&replacement_session_id), From 66a74a67542140f0679a001bf22a5187d37bd850 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:17:05 +0800 Subject: [PATCH 8/8] Revert "fix(emulation): compose context style defaults with target overrides" This reverts commit 9b92c4f8b8d34caf7af8a4515ed6b552761be22a. --- .../protocol_server/tests/cdp_dynamic_page.rs | 108 ------------------ .../conn/browser_context/emulation_owner.rs | 48 +------- .../browser_context/target_session_owner.rs | 1 - .../src/conn/page_state/fetch_state.rs | 1 - .../src/conn/state/browser_context.rs | 2 - moli-protocol/src/conn/state/emulation.rs | 26 ----- .../src/conn/state/page_target_host.rs | 10 -- moli-protocol/src/domains/emulation.rs | 16 +-- .../src/domains/emulation/page_session.rs | 60 +++++----- 9 files changed, 43 insertions(+), 229 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 0234102fc6..ef841cad45 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -2849,114 +2849,6 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( abort_test_cdp_server(server).await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { - let (addr, server) = spawn_test_protocol_server().await; - let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; - - let media = send_cdp_command( - &mut root_page, - 1, - "Emulation.setEmulatedMedia", - None, - json!({ - "features": [ - { "name": "prefers-color-scheme", "value": "dark" } - ] - }), - ) - .await; - assert_eq!(response_by_id(&media, 1)["result"], json!({})); - let text_scale = send_cdp_command( - &mut root_page, - 2, - "Emulation.setEmulatedOSTextScale", - None, - json!({ "scale": 1.5 }), - ) - .await; - assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); - - let target_id = create_dynamic_target(&mut root_page, 3).await; - let attach = send_cdp_command( - &mut root_page, - 4, - "Target.attachToTarget", - None, - json!({ "targetId": target_id, "flatten": true }), - ) - .await; - let session_id = response_by_id(&attach, 4)["result"]["sessionId"] - .as_str() - .expect("new target session") - .to_owned(); - let url = "data:text/html,text"; - let navigation = send_cdp_command( - &mut root_page, - 5, - "Page.navigate", - Some(&session_id), - json!({ "url": url }), - ) - .await; - assert!(response_by_id(&navigation, 5).get("result").is_some()); - - let inherited = send_cdp_command( - &mut root_page, - 6, - "Runtime.evaluate", - Some(&session_id), - json!({ - "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", - "returnByValue": true - }), - ) - .await; - assert_eq!( - response_by_id(&inherited, 6)["result"]["result"]["value"], - json!([true, "24px"]) - ); - - let detach = send_cdp_command( - &mut root_page, - 7, - "Target.detachFromTarget", - None, - json!({ "sessionId": session_id }), - ) - .await; - assert_eq!(response_by_id(&detach, 7)["result"], json!({})); - let reattach = send_cdp_command( - &mut root_page, - 8, - "Target.attachToTarget", - None, - json!({ "targetId": target_id, "flatten": true }), - ) - .await; - let replacement_session_id = response_by_id(&reattach, 8)["result"]["sessionId"] - .as_str() - .expect("replacement target session") - .to_owned(); - let retained = send_cdp_command( - &mut root_page, - 9, - "Runtime.evaluate", - Some(&replacement_session_id), - json!({ - "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", - "returnByValue": true - }), - ) - .await; - assert_eq!( - response_by_id(&retained, 9)["result"]["result"]["value"], - json!([true, "24px"]) - ); - - abort_test_cdp_server(server).await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_owner_registry_shutdown_joins_shared_default_page_owner() { let (addr, server, owner_registry) = spawn_test_protocol_server_with_owner_registry().await; diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index 52a9996aa6..66551ad705 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -9,8 +9,6 @@ use crate::conn::{ pub(crate) struct TargetEmulationStateUpdate<'a> { raw: &'a mut DevToolsEmulationSessionState, effective: &'a mut EffectiveTargetEmulationState, - emulated_media_override: &'a mut EmulatedMediaOverrides, - default_emulated_media: EmulatedMediaOverrides, } impl TargetEmulationStateUpdate<'_> { @@ -45,20 +43,14 @@ impl TargetEmulationStateUpdate<'_> { pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { self.raw.emulated_media.preferred_text_scale = scale; - self.emulated_media_override.preferred_text_scale = scale; - self.effective.emulated_media = self - .emulated_media_override - .overlaid_on(&self.default_emulated_media); + self.effective.emulated_media.preferred_text_scale = scale; } pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); - emulated_media.preferred_text_scale = self.emulated_media_override.preferred_text_scale; - *self.emulated_media_override = emulated_media; - self.effective.emulated_media = self - .emulated_media_override - .overlaid_on(&self.default_emulated_media); + emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; + self.effective.emulated_media = emulated_media; } pub(crate) fn set_emulated_device_metrics( @@ -95,7 +87,6 @@ impl TargetSessionOwnerMut<'_> { self, f: impl FnOnce(Option>), ) -> bool { - let default_emulated_media = self.browser_context.default_emulated_media.clone(); let Some(state) = self.browser_context.page_target_mut(&self.target_id) else { f(None); return false; @@ -105,13 +96,7 @@ impl TargetSessionOwnerMut<'_> { .ensure_session(&self.session_key) .emulation_session_state; let effective = &mut state.effective_emulation_state; - let emulated_media_override = &mut state.emulated_media_override; - f(Some(TargetEmulationStateUpdate { - raw, - effective, - emulated_media_override, - default_emulated_media, - })); + f(Some(TargetEmulationStateUpdate { raw, effective })); true } @@ -302,11 +287,6 @@ impl CdpConnection { &mut self, session_id: &str, ) -> Option { - let default_emulated_media = self - .browser_context - .as_ref()? - .default_emulated_media - .clone(); let mut owner = self.target_session_owner_mut(Some(session_id))?; Some(owner.mutate_page_state(|target, session_key| { let raw = std::mem::take( @@ -320,14 +300,9 @@ impl CdpConnection { .devtools_sessions .navigator_emulation .remove(session_key); - let previous_emulated_media = target.effective_emulation_state.emulated_media.clone(); let mut delta = target .effective_emulation_state .disable_session_handler(&raw); - target.emulated_media_override = Default::default(); - target.refresh_emulated_media(&default_emulated_media); - delta.emulated_media = - previous_emulated_media != target.effective_emulation_state.emulated_media; delta.navigator_queries = previous_queries != target.devtools_sessions.navigator_emulation.effective(); delta @@ -336,21 +311,6 @@ impl CdpConnection { } impl BrowserContext { - pub(crate) fn set_default_emulated_media(&mut self, mut media: EmulatedMediaOverrides) { - media.preferred_text_scale = self.default_emulated_media.preferred_text_scale; - self.default_emulated_media = media; - for target in self.page_targets.iter_mut() { - target.refresh_emulated_media(&self.default_emulated_media); - } - } - - pub(crate) fn set_default_preferred_text_scale(&mut self, scale: Option) { - self.default_emulated_media.preferred_text_scale = scale; - for target in self.page_targets.iter_mut() { - target.refresh_emulated_media(&self.default_emulated_media); - } - } - pub(crate) fn effective_active_emulated_device_metrics(&self) -> Option { self.active_page_target() .effective_emulation_state diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 83ca908341..1a6dfa1d7a 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -370,7 +370,6 @@ impl TargetNavigationLoadInputs { .default_emulated_device_metrics .as_ref() .map(|metrics| metrics.viewport_surface()); - inputs.emulated_media = (&browser_context.default_emulated_media).into(); inputs.document_activity = browser_context.active_document_activity(); inputs.network_offline = browser_context.effective_active_network_offline(); inputs diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index b342d2205a..e34aeb519b 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -65,7 +65,6 @@ impl BrowserContext { self.default_document_cookie_manager_surface.clone(); } host.set_base_cache_disabled(self.global_cache_disabled); - host.refresh_emulated_media(&self.default_emulated_media); if let Some(config) = self.page_navigation_runtime_config.clone() { let engine = self.new_page_navigation_engine(config); host.install_navigation_engine(engine); diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 6c9f5110c3..952bc8520c 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -76,7 +76,6 @@ pub struct BrowserContext { pub proxy_autoconfig_url: Option, pub proxy_socks_version: Option, pub(crate) default_emulated_device_metrics: Option, - pub(crate) default_emulated_media: super::EmulatedMediaOverrides, pub(crate) next_default_document_start_script_id: u32, pub(crate) default_document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) storage_quota_overrides: HashMap, @@ -528,7 +527,6 @@ impl BrowserContext { proxy_autoconfig_url: None, proxy_socks_version: None, default_emulated_device_metrics: None, - default_emulated_media: Default::default(), next_default_document_start_script_id: 0, default_document_start_scripts: Vec::new(), storage_quota_overrides: HashMap::new(), diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index ed57528643..c43ff9bdb4 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -101,32 +101,6 @@ pub struct EmulatedMediaOverrides { pub contrast: Option, } -impl EmulatedMediaOverrides { - pub(crate) fn overlaid_on(&self, defaults: &Self) -> Self { - Self { - preferred_text_scale: self.preferred_text_scale.or(defaults.preferred_text_scale), - media: self.media.clone().or_else(|| defaults.media.clone()), - color_scheme: self - .color_scheme - .clone() - .or_else(|| defaults.color_scheme.clone()), - reduced_motion: self - .reduced_motion - .clone() - .or_else(|| defaults.reduced_motion.clone()), - reduced_transparency: self - .reduced_transparency - .clone() - .or_else(|| defaults.reduced_transparency.clone()), - forced_colors: self - .forced_colors - .clone() - .or_else(|| defaults.forced_colors.clone()), - contrast: self.contrast.clone().or_else(|| defaults.contrast.clone()), - } - } -} - impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { diff --git a/moli-protocol/src/conn/state/page_target_host.rs b/moli-protocol/src/conn/state/page_target_host.rs index 9c62c2df04..d7810335ab 100644 --- a/moli-protocol/src/conn/state/page_target_host.rs +++ b/moli-protocol/src/conn/state/page_target_host.rs @@ -30,7 +30,6 @@ pub struct PageTargetHost { pub(crate) tls_verify_host_override: Option, pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner, pub(crate) effective_emulation_state: EffectiveTargetEmulationState, - pub(crate) emulated_media_override: super::emulation::EmulatedMediaOverrides, pub(crate) input_intercept_drags_enabled: bool, pub(crate) input_drag_intercepted: bool, pub(crate) css_enabled: bool, @@ -55,7 +54,6 @@ impl PageTargetHost { tls_verify_host_override: None, environment_owner: Default::default(), effective_emulation_state: EffectiveTargetEmulationState::default(), - emulated_media_override: Default::default(), input_intercept_drags_enabled: false, input_drag_intercepted: false, css_enabled: false, @@ -114,14 +112,6 @@ impl PageTargetHost { &self.target_id } - pub(crate) fn refresh_emulated_media( - &mut self, - defaults: &super::emulation::EmulatedMediaOverrides, - ) { - self.effective_emulation_state.emulated_media = - self.emulated_media_override.overlaid_on(defaults); - } - fn replace_target_id(&mut self, target_id: String) { self.target_id = target_id; } diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index fe7b4c3819..7011422883 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,14 +828,11 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - let update = if emulation_command_is_context_wide(conn, cmd.session_id) { - page_session::set_context_emulated_media(conn, overrides) - } else { + if let Err(error) = page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { - state.set_emulated_media(overrides); + state.set_emulated_media(overrides.clone()); }) - }; - if let Err(error) = update { + { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) @@ -864,14 +861,11 @@ fn start_os_text_scale_command( "Text scale must be finite and positive", )); } - let update = if emulation_command_is_context_wide(conn, cmd.session_id) { - page_session::set_context_preferred_text_scale(conn, params.scale) - } else { + if let Err(error) = page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) - }; - if let Err(error) = update { + { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index be04cf109a..0c8624a27f 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -18,31 +18,39 @@ pub(super) fn update_page_emulation_state( pub(super) fn update_style_environment_state( conn: &mut CdpConnection, session_id: Option<&str>, - update: impl FnOnce(TargetEmulationStateUpdate<'_>), + mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), ) -> Result<(), String> { - update_page_emulation_state(conn, session_id, update) -} - -pub(super) fn set_context_emulated_media( - conn: &mut CdpConnection, - media: crate::conn::EmulatedMediaOverrides, -) -> Result<(), String> { - let context = conn - .browser_context - .as_mut() - .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; - context.set_default_emulated_media(media); - Ok(()) -} - -pub(super) fn set_context_preferred_text_scale( - conn: &mut CdpConnection, - scale: Option, -) -> Result<(), String> { - let context = conn - .browser_context - .as_mut() - .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; - context.set_default_preferred_text_scale(scale); + let owners = if super::emulation_command_is_context_wide(conn, session_id) { + conn.browser_context + .as_ref() + .map(|context| { + context + .page_targets + .iter() + .map(|target| { + CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { + browser_context_id: context.id.clone(), + target_id: target.target_id().to_owned(), + session_key: moli_page_types::DevToolsSessionKey::Primary, + }) + }) + .collect::>() + }) + .unwrap_or_default() + } else { + vec![CommandOwnerScope::capture(conn, session_id)] + }; + if owners.is_empty() { + return Err("BrowserContextNotLoaded".to_owned()); + } + for owner in owners { + if !conn.update_emulation_state_for_owner(&owner, |state| { + if let Some(state) = state { + update(state); + } + }) { + return Err("BrowserContextNotLoaded".to_owned()); + } + } Ok(()) }