diff --git a/README.md b/README.md index fb92f39a4c..b4cdde8567 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ Both `fetch` and `serve` accept the flag; it takes precedence when combined with `--layout`. Optional resources remain independent: add `--image --font` or `--resource` when needed. Native scrollbars remain opt-in with `--scrollbars`. The mode is experimental: the core pipeline has regression coverage, while -broader observer/frame compatibility, temporary print-environment isolation +broader observer/frame compatibility, print-specific resource integration and release performance validation remain in progress. ## Architecture diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index 8c831ddbd9..451762808d 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -129,15 +129,16 @@ pub use moli_renderer_v8::{ RendererRuntimeInspectorMessage, RendererRuntimeInspectorMessageBatch, RendererRuntimeInspectorProtocolMessage, RendererRuntimeObservableSourceItem, RendererRuntimeObservableSourceSummary, RendererRuntimeRealmInfo, RendererScreenshotClip, - RendererScreenshotFormat, RendererScreenshotRegion, RendererScrollIntoViewResult, - RendererServiceWorkerConsoleMessage, RendererServiceWorkerExceptionMessage, - RendererServiceWorkerFetchDiagnostic, RendererServiceWorkerFetchDiagnosticResult, - RendererServiceWorkerRunIdentity, RendererServiceWorkerTargetEvent, - RendererServiceWorkerTargetInfo, RendererServiceWorkerVersionStatus, - RendererSetDocumentContentResult, RendererSharedWorkerConsoleMessage, - RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererSyntheticResponseBody, - RendererTextSearchMatch, RendererTouchPoint, RendererVisualStateToken, - RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, WorkerInspectorError, + RendererScreenshotFormat, RendererScreenshotPurpose, RendererScreenshotRegion, + RendererScrollIntoViewResult, RendererServiceWorkerConsoleMessage, + RendererServiceWorkerExceptionMessage, RendererServiceWorkerFetchDiagnostic, + RendererServiceWorkerFetchDiagnosticResult, RendererServiceWorkerRunIdentity, + RendererServiceWorkerTargetEvent, RendererServiceWorkerTargetInfo, + RendererServiceWorkerVersionStatus, RendererSetDocumentContentResult, + RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, + RendererSharedWorkerTargetInfo, RendererSyntheticResponseBody, RendererTextSearchMatch, + RendererTouchPoint, RendererVisualStateToken, RendererWindowDocumentSource, + RuntimeConsoleMessageSnapshot, WorkerInspectorError, }; pub use moli_renderer_v8::{ RendererAppManifest, RendererAppManifestDisplayMode, RendererAppManifestError, diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index bbf2ea5777..70a7969abd 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -29,8 +29,8 @@ use moli_core::page::{ RendererDocumentSourcedSameDocumentNavigation, RendererDocumentSourcedTopLevelLocationNavigation, RendererLayoutMetrics, RendererPendingTopLevelHistoryTraversal, RendererPendingWindowOpenEvent, - RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotRegion, - RendererSetDocumentContentResult, RendererVisualStateToken, + RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotPurpose, + RendererScreenshotRegion, RendererSetDocumentContentResult, RendererVisualStateToken, }; use serde::Deserialize; use serde_json::{Value, json}; diff --git a/moli-protocol/src/domains/page/capture.rs b/moli-protocol/src/domains/page/capture.rs index c1cf90d145..672c6e1b00 100644 --- a/moli-protocol/src/domains/page/capture.rs +++ b/moli-protocol/src/domains/page/capture.rs @@ -406,6 +406,7 @@ pub(super) fn renderer_screenshot_request( None => RendererScreenshotRegion::Viewport, }; Ok(RendererCaptureScreenshotRequest { + purpose: RendererScreenshotPurpose::Screenshot, base_background_color, format, quality: command.quality.unwrap_or(80), diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index 53bcd2e495..f8103a4d74 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -328,19 +328,19 @@ pub use runtime::{ RendererRuntimeInspectorResponseChannel, RendererRuntimeInspectorResponseSender, RendererRuntimeObservableSourceItem, RendererRuntimeObservableSourceSummary, RendererRuntimeRealmInfo, RendererRuntimeRemoteObject, RendererRuntimeRemoteObjectResolution, - RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotRegion, - RendererScriptExecutionMemoryDiagnostics, RendererScriptSourceMemoryDiagnostics, - RendererScrollIntoViewResult, RendererServiceWorkerConsoleMessage, - RendererServiceWorkerExceptionMessage, RendererServiceWorkerFetchDiagnostic, - RendererServiceWorkerFetchDiagnosticResult, RendererServiceWorkerMainResourceFetch, - RendererServiceWorkerRunIdentity, RendererServiceWorkerTargetEvent, - RendererServiceWorkerTargetInfo, RendererServiceWorkerVersionStatus, - RendererSetDocumentContentResult, RendererSharedWorkerConsoleMessage, - RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererStyleSheetHeader, - RendererStyleSheetInventoryUpdate, RendererStyleSheetPayload, RendererSyntheticResponseBody, - RendererTextSearchMatch, RendererTouchPoint, RendererVisualStateToken, - RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, WorkerInspectorError, - renderer_output_transport_channel, + RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotPurpose, + RendererScreenshotRegion, RendererScriptExecutionMemoryDiagnostics, + RendererScriptSourceMemoryDiagnostics, RendererScrollIntoViewResult, + RendererServiceWorkerConsoleMessage, RendererServiceWorkerExceptionMessage, + RendererServiceWorkerFetchDiagnostic, RendererServiceWorkerFetchDiagnosticResult, + RendererServiceWorkerMainResourceFetch, RendererServiceWorkerRunIdentity, + RendererServiceWorkerTargetEvent, RendererServiceWorkerTargetInfo, + RendererServiceWorkerVersionStatus, RendererSetDocumentContentResult, + RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, + RendererSharedWorkerTargetInfo, RendererStyleSheetHeader, RendererStyleSheetInventoryUpdate, + RendererStyleSheetPayload, RendererSyntheticResponseBody, RendererTextSearchMatch, + RendererTouchPoint, RendererVisualStateToken, RendererWindowDocumentSource, + RuntimeConsoleMessageSnapshot, WorkerInspectorError, renderer_output_transport_channel, }; pub use runtime::{ RendererElementClickError, RendererElementClickTarget, RendererPreparedPointerClick, diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 8a1674ffef..3348b7a675 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -232,13 +232,15 @@ impl PageScriptHost { self.style_viewport_generation.get() } - /// Marks the exact viewport selected by an active layout pass before that - /// pass reads the Document's styles. + /// Marks the exact style environment selected by an active layout pass + /// before that pass reads the Document's styles. /// /// An embedded Document's persistent frame viewport is published only /// after the recursive pass succeeds. Advancing this input generation /// first also distinguishes an explicit main-Document capture viewport - /// from ordinary DOM APIs selecting conflicting implicit viewports. + /// or media from ordinary DOM APIs selecting conflicting implicit inputs. + /// This observation epoch does not invalidate canonical screen geometry or + /// schedule lifecycle work: the live source inputs have not changed. pub(crate) fn note_layout_style_environment( &self, document: DomHandle, diff --git a/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs b/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs index 3ebcb718f7..be59251705 100644 --- a/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs +++ b/moli-renderer-v8/src/page_task_queue/rendering_update/source.rs @@ -240,6 +240,7 @@ impl RendererPageRenderingUpdateSource { }) } + #[cfg(any(test, debug_assertions))] pub(crate) fn local_deadline( &self, current: Option, diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 13c4dcdb93..ee28134a5a 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -335,7 +335,8 @@ pub use self::page_screenshot::{ RendererCaptureScreencastFrameReply, RendererCaptureScreencastFrameRequest, RendererCaptureScreenshotReply, RendererCaptureScreenshotRequest, RendererCapturedScreencastFrame, RendererCapturedScreenshot, RendererScreenshotClip, - RendererScreenshotFormat, RendererScreenshotRegion, RendererVisualStateToken, + RendererScreenshotFormat, RendererScreenshotPurpose, RendererScreenshotRegion, + RendererVisualStateToken, }; pub(super) use self::page_state::RendererPageEntry; pub use self::page_state::RendererPageRecord; diff --git a/moli-renderer-v8/src/runtime/page_print.rs b/moli-renderer-v8/src/runtime/page_print.rs index f489ed7ac1..8b22f083d9 100644 --- a/moli-renderer-v8/src/runtime/page_print.rs +++ b/moli-renderer-v8/src/runtime/page_print.rs @@ -46,18 +46,16 @@ impl PageVm { surface.inner_height, surface.device_pixel_ratio as f32, ); - let result = self - .vm_mut() - .paint_layout_snapshot_with_capture_in_environment( - viewport, - moli_layout::LayoutFlushReason::Print, - PaintCaptureRequest { - include_backgrounds: request.print_background, - ..PaintCaptureRequest::print_document() - }, - crate::native_bridge::LayoutPublicationRequest::Transient, - environment_override, - ); + let result = self.vm_mut().paint_layout_snapshot_with_capture( + viewport, + moli_layout::LayoutFlushReason::Print, + PaintCaptureRequest { + include_backgrounds: request.print_background, + ..PaintCaptureRequest::print_document() + }, + crate::native_bridge::LayoutPublicationRequest::Transient, + environment_override, + ); Ok(match result? { Some(snapshot) => match moli_paint::pdf::pdf_snapshot(&snapshot, &request.options) { Ok(bytes) => Reply::Printed(bytes), diff --git a/moli-renderer-v8/src/runtime/page_screenshot.rs b/moli-renderer-v8/src/runtime/page_screenshot.rs index ad33c1f325..896e089caa 100644 --- a/moli-renderer-v8/src/runtime/page_screenshot.rs +++ b/moli-renderer-v8/src/runtime/page_screenshot.rs @@ -1,6 +1,7 @@ //! Screenshot and screencast requests, replies and pixel capture. use crate::native_bridge::LayoutPublicationRequest; +use crate::style_engine::StyloStyleEnvironment; use moli_layout::{ LayoutRect, LayoutTransform2D, PaintCaptureRegion, PaintCaptureRequest, PaintCaptureSurface, PaintViewport, @@ -114,6 +115,15 @@ pub enum RendererScreenshotFormat { Jpeg, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RendererScreenshotPurpose { + Screenshot, + /// Pixel capture under print media, independent of native PDF output. + Print { + print_background: bool, + }, +} + /// A CDP page-coordinate clip. Validation remains at the renderer boundary so /// every protocol frontend shares the same finite/range checks. #[derive(Clone, Copy, Debug, PartialEq)] @@ -138,6 +148,7 @@ pub enum RendererScreenshotRegion { #[derive(Clone, Debug, PartialEq)] pub struct RendererCaptureScreenshotRequest { + pub purpose: RendererScreenshotPurpose, pub format: RendererScreenshotFormat, pub quality: u8, pub region: RendererScreenshotRegion, @@ -197,6 +208,7 @@ pub enum RendererCaptureScreencastFrameReply { impl RendererCaptureScreenshotRequest { pub fn viewport_png() -> Self { Self { + purpose: RendererScreenshotPurpose::Screenshot, format: RendererScreenshotFormat::Png, quality: 100, region: RendererScreenshotRegion::Viewport, @@ -215,7 +227,11 @@ impl PageVm { &mut self, request: RendererCaptureScreenshotRequest, ) -> anyhow::Result { - self.flush_page_action_window(moli_action_window::ActionBarrier::Screenshot)?; + let barrier = match request.purpose { + RendererScreenshotPurpose::Screenshot => moli_action_window::ActionBarrier::Screenshot, + RendererScreenshotPurpose::Print { .. } => moli_action_window::ActionBarrier::Explicit, + }; + self.flush_page_action_window(barrier)?; let paint_capture = request .paint_capture_request(self.document_settings.viewport_surface.unwrap_or_default())?; self.capture_image( @@ -223,12 +239,34 @@ impl PageVm { request.quality, request.optimize_for_speed, paint_capture, - moli_layout::LayoutFlushReason::Screenshot, - LayoutPublicationRequest::Screen, + match request.purpose { + RendererScreenshotPurpose::Screenshot => moli_layout::LayoutFlushReason::Screenshot, + RendererScreenshotPurpose::Print { .. } => moli_layout::LayoutFlushReason::Print, + }, + match request.purpose { + RendererScreenshotPurpose::Screenshot => LayoutPublicationRequest::Screen, + RendererScreenshotPurpose::Print { .. } => LayoutPublicationRequest::Transient, + }, + self.capture_style_environment_override(request.purpose), ) .map(RendererImageCaptureOutcome::into_screenshot_reply) } + pub(super) fn capture_style_environment_override( + &self, + purpose: RendererScreenshotPurpose, + ) -> Option { + if matches!(purpose, RendererScreenshotPurpose::Print { .. }) + && self.document_settings.emulated_media.media.is_none() + { + let mut print = self.document_settings.emulated_media.clone(); + print.media = Some("print".to_owned()); + Some(StyloStyleEnvironment::from_emulated_media(&print)) + } else { + None + } + } + /// Polls one screencast subscription. Only this request accepts a known /// visual state and only this reply can report `Unchanged`. pub(super) fn capture_screencast_frame( @@ -286,6 +324,7 @@ impl PageVm { paint_capture, moli_layout::LayoutFlushReason::Screencast, LayoutPublicationRequest::Screen, + None, )? { RendererImageCaptureOutcome::Captured(image) => image, RendererImageCaptureOutcome::LayoutDisabled => { @@ -319,6 +358,7 @@ impl PageVm { paint_capture: PaintCaptureRequest, reason: moli_layout::LayoutFlushReason, publication: LayoutPublicationRequest, + environment_override: Option, ) -> anyhow::Result { let profile_enabled = moli_trace::cpu_profile_enabled(); let total_started = profile_enabled.then(Instant::now); @@ -337,6 +377,7 @@ impl PageVm { reason, paint_capture, publication, + environment_override, )? else { return Ok(RendererImageCaptureOutcome::NoDocument); @@ -485,7 +526,10 @@ impl RendererCaptureScreenshotRequest { }; Ok(PaintCaptureRequest { region, - include_backgrounds: true, + include_backgrounds: match self.purpose { + RendererScreenshotPurpose::Screenshot => true, + RendererScreenshotPurpose::Print { print_background } => print_background, + }, include_viewport_controls, base_background_color: paint_background_color(self.base_background_color), max_width: self.max_width, 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 30f28c0a4d..d33847d3e6 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 @@ -62,6 +62,7 @@ mod realtime_capture; mod realtime_input; mod realtime_intersection_observer; mod realtime_opportunity; +mod realtime_print; mod realtime_resize_observer; mod scrollbars; mod text_stroke; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/form_controls.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/form_controls.rs index e21abc67d6..21669da2ce 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/form_controls.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/form_controls.rs @@ -462,6 +462,7 @@ async fn contenteditable_atomic_inline_selection_keeps_order_effects_and_culling moli_layout::LayoutFlushReason::Screenshot, capture, crate::native_bridge::LayoutPublicationRequest::Screen, + None, )? .expect("atomic range effects layout"); let image = moli_paint::raster_snapshot(&snapshot)?; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_print.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_print.rs new file mode 100644 index 0000000000..ccfa18d2e8 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_print.rs @@ -0,0 +1,270 @@ +use super::realtime_opportunity::run_opportunity; +use super::*; +use crate::runtime::{ + RendererCaptureScreencastFrameReply as CastReply, RendererCaptureScreenshotReply, + RendererCaptureScreenshotRequest, RendererScreenshotPurpose, +}; +use moli_page_types::LayoutPolicy; + +async fn fixture(loader: &crate::network::ResourceRequestClient) -> PageVm { + let (mut page, _, _) = page_vm_with_bound_task_sources_and_owner_wake( + loader, + Url::parse("https://example.com/realtime-print").unwrap(), + ); + page.layout_configuration.policy = LayoutPolicy::Realtime; + page.vm_mut().set_layout_policy(LayoutPolicy::Realtime); + page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 320, + inner_height: 100, + device_pixel_ratio: 1.0, + ..Default::default() + })) + .unwrap(); + assert!( + page.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::RenderingUpdate, + loader, + ) + .await + .unwrap() + ); + page.vm_mut().eval(r#" + document.head.innerHTML=``; + document.body.innerHTML='
'; + frame.contentDocument.head.innerHTML=''; + window.events=[];window.ticks=0; + window.ro=new ResizeObserver(es=>events.push('ro:'+es[0].contentRect.width));ro.observe(target); + window.io=new IntersectionObserver(es=>events.push('io:'+es[0].boundingClientRect.width));io.observe(target); + matchMedia('print').onchange=()=>events.push('media'); + frame.contentWindow.matchMedia('print').onchange=()=>events.push('child-media'); + window.fontSheet=new CSSStyleSheet(); + fontSheet.replaceSync('@media screen{@font-face{font-family:ScreenOnly;src:local(ScreenOnly)}}@media print{@font-face{font-family:PrintOnly;src:local(PrintOnly)}}'); + document.adoptedStyleSheets=[fontSheet]; + window.liveFonts=document.fonts;window.screenFace=[...liveFonts][0]; + "#).unwrap(); + run_opportunity(&mut page, loader).await.unwrap(); + assert!( + page.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::IntersectionObserver, + loader, + ) + .await + .unwrap() + ); + assert_eq!( + page.vm_mut().eval("events.join('|')").unwrap(), + "ro:40|io:40" + ); + page +} + +fn print_request() -> RendererCaptureScreenshotRequest { + RendererCaptureScreenshotRequest { + purpose: RendererScreenshotPurpose::Print { + print_background: true, + }, + ..RendererCaptureScreenshotRequest::viewport_png() + } +} + +fn screen_token(page: &PageVm) -> crate::runtime::RendererVisualStateToken { + page.vm().visual_state_token( + page.document_lifecycle.identity(), + moli_layout::PaintViewport::new(320, 100, 1.0), + [255; 4], + ) +} + +fn assert_screen_state(page: &mut PageVm) { + assert_eq!(page.vm_mut().eval("JSON.stringify([[...liveFonts].map(face=>face.family),liveFonts.has(screenFace),document.fonts===liveFonts])").unwrap(), r#"[["ScreenOnly"],true,true]"#); + assert_eq!( + page.vm_mut() + .eval( + r#"JSON.stringify([ + target.offsetWidth,frame.offsetWidth,frame.contentWindow.innerWidth, + frame.contentDocument.body.offsetWidth,getComputedStyle(target).width, + frame.contentWindow.getComputedStyle(frame.contentDocument.body).backgroundColor, + matchMedia('print').matches,frame.contentWindow.matchMedia('print').matches, + events.join('|') + ])"# + ) + .unwrap(), + r#"[40,40,40,40,"40px","rgb(255, 0, 0)",false,false,"ro:40|io:40"]"# + ); +} + +#[test] +fn realtime_print_preserves_screen_observers_and_screencast_without_an_extra_frame() { + run_page_vm_large_stack_async_test("realtime-print-isolation", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let mut page = fixture(&loader).await; + let CastReply::Captured(first) = page + .capture_screencast_frame(viewport_screencast_request(None)) + .unwrap() + else { + panic!("screen capture"); + }; + // Screen publication asks existing IO targets to be checked. Settle + // this genuine screen work before attributing any work to printing. + run_opportunity(&mut page, &loader).await.unwrap(); + assert_screen_state(&mut page); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + let builds = page.vm().layout_pass_observability_for_test().1; + let publishes = page.vm().layout_snapshot_cache_observability_for_test().2; + assert_eq!( + screen_token(&page), + first.visual_state, + "clean frame and reads preserve the captured token" + ); + + let RendererCaptureScreenshotReply::Captured(print) = + page.capture_screenshot(print_request()).unwrap() + else { + panic!("print capture"); + }; + assert_eq!( + screen_token(&page), + first.visual_state, + "printing preserves the captured screen token" + ); + let image = moli_image::decode_png(&print.bytes).unwrap(); + let pixel = |x: usize, y: usize| { + let offset = (y * image.width as usize + x) * 4; + &image.rgba[offset..offset + 4] + }; + assert_eq!(pixel(20, 20), [255, 255, 255, 255]); + assert_eq!(pixel(230, 20), [0, 255, 0, 255]); + assert_eq!( + pixel(230, 70), + [0, 0, 255, 255], + "child uses print media too" + ); + assert_screen_state(&mut page); + assert_eq!( + screen_token(&page), + first.visual_state, + "screen reads after printing preserve the token" + ); + assert_eq!( + page.vm().layout_snapshot_cache_observability_for_test().2, + publishes + ); + assert_eq!(page.vm().layout_pass_observability_for_test().1, builds + 1); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + for selector in [ + PageSelectedTaskTestSelector::RenderingUpdate, + PageSelectedTaskTestSelector::IntersectionObserver, + ] { + assert!( + page.claim_exact_selected_page_task_for_test(selector) + .is_none() + ); + } + assert!(matches!( + page.capture_screencast_frame(viewport_screencast_request(Some(first.visual_state))) + .unwrap(), + CastReply::Unchanged + )); + assert_eq!(page.vm().layout_pass_observability_for_test().1, builds + 1); + }); +} + +#[test] +fn realtime_print_success_and_failure_preserve_pending_animation_work() { + run_page_vm_large_stack_async_test("realtime-print-pending", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let mut page = fixture(&loader).await; + page.vm_mut() + .eval("requestAnimationFrame(()=>ticks++)") + .unwrap(); + let ticket = page.current_realtime_rendering_opportunity().unwrap(); + let deadline = page.next_rendering_opportunity_deadline_for_test(); + let builds = page.vm().layout_pass_observability_for_test().1; + page.capture_screenshot(print_request()).unwrap(); + assert_eq!(page.current_realtime_rendering_opportunity(), Some(ticket)); + assert_eq!( + page.next_rendering_opportunity_deadline_for_test(), + deadline + ); + assert_eq!(page.vm_mut().eval("String(ticks)").unwrap(), "0"); + + let host = page.vm().context_host_weak_for_test().upgrade().unwrap(); + host.borrow().simulate_active_layout_pass_for_test(true); + let error = page.capture_screenshot(print_request()).unwrap_err(); + host.borrow().simulate_active_layout_pass_for_test(false); + assert_eq!( + error.downcast_ref::(), + Some(&moli_layout::LayoutError::ReentrantLayoutPass) + ); + assert_eq!(page.current_realtime_rendering_opportunity(), Some(ticket)); + assert_eq!( + page.next_rendering_opportunity_deadline_for_test(), + deadline + ); + assert_screen_state(&mut page); + run_opportunity(&mut page, &loader).await.unwrap(); + assert_eq!(page.vm_mut().eval("String(ticks)").unwrap(), "1"); + assert_eq!(page.vm().layout_pass_observability_for_test().1, builds + 1); + assert!( + page.next_rendering_opportunity_deadline_for_test() + .is_none() + ); + assert_screen_state(&mut page); + }); +} + +#[test] +fn realtime_print_respects_explicit_live_media_and_live_changes_invalidate_screencast() { + run_page_vm_large_stack_async_test("realtime-print-live-media", || async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap(); + let mut page = fixture(&loader).await; + let CastReply::Captured(first) = page + .capture_screencast_frame(viewport_screencast_request(None)) + .unwrap() + else { + panic!("screen capture"); + }; + for (media, x, color) in [ + ("screen", 20, [255, 0, 0, 255]), + ("print", 230, [0, 255, 0, 255]), + ] { + let mut emulation = page.document_settings.emulated_media.clone(); + emulation.media = Some(media.to_owned()); + page.set_emulated_media(&emulation).unwrap(); + let RendererCaptureScreenshotReply::Captured(print) = + page.capture_screenshot(print_request()).unwrap() + else { + panic!("explicit-media print capture"); + }; + let image = moli_image::decode_png(&print.bytes).unwrap(); + let offset = (20 * image.width as usize + x) * 4; + assert_eq!(&image.rgba[offset..offset + 4], color); + assert_eq!( + page.vm_mut() + .eval("String(matchMedia('print').matches)") + .unwrap(), + (media == "print").to_string() + ); + } + let CastReply::Captured(changed) = page + .capture_screencast_frame(viewport_screencast_request(Some( + first.visual_state.clone(), + ))) + .unwrap() + else { + panic!("live media change must invalidate the screen token"); + }; + assert_ne!(changed.visual_state, first.visual_state); + }); +} diff --git a/moli-renderer-v8/src/runtime/tests/extracted/runtime_scripts.rs b/moli-renderer-v8/src/runtime/tests/extracted/runtime_scripts.rs index ee045dee73..25215d3ad0 100644 --- a/moli-renderer-v8/src/runtime/tests/extracted/runtime_scripts.rs +++ b/moli-renderer-v8/src/runtime/tests/extracted/runtime_scripts.rs @@ -230,6 +230,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() let viewport_clip = capture_screenshot_with_request( &page, super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Png, quality: 100, @@ -259,6 +260,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() let page_clip = capture_screenshot_with_request( &page, super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Png, quality: 100, diff --git a/moli-renderer-v8/src/runtime/tests/extracted/visual_output.rs b/moli-renderer-v8/src/runtime/tests/extracted/visual_output.rs index f802dcf222..3bb1cfedd3 100644 --- a/moli-renderer-v8/src/runtime/tests/extracted/visual_output.rs +++ b/moli-renderer-v8/src/runtime/tests/extracted/visual_output.rs @@ -326,6 +326,7 @@ async fn capture_screenshot_encodes_jpeg_and_limits_device_dimensions() { .expect("viewport should update"); let request = super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Jpeg, quality: 80, @@ -492,6 +493,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport let full = capture_screenshot_with_request( &page, super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Png, quality: 100, @@ -509,6 +511,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport let clip = capture_screenshot_with_request( &page, super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Png, quality: 100, @@ -542,6 +545,7 @@ async fn capture_screenshot_rejects_full_document_at_the_128k_css_boundary() { ) .await; let request = super::RendererCaptureScreenshotRequest { + purpose: crate::runtime::RendererScreenshotPurpose::Screenshot, base_background_color: [255; 4], format: super::RendererScreenshotFormat::Png, quality: 100, diff --git a/moli-renderer-v8/src/script_vm/layout.rs b/moli-renderer-v8/src/script_vm/layout.rs index 161b030a17..3a6c276f5d 100644 --- a/moli-renderer-v8/src/script_vm/layout.rs +++ b/moli-renderer-v8/src/script_vm/layout.rs @@ -168,26 +168,11 @@ impl ScriptVm { reason, moli_layout::PaintCaptureRequest::viewport(), publication, - ) - } - - pub(crate) fn paint_layout_snapshot_with_capture( - &mut self, - viewport: moli_layout::PaintViewport, - reason: moli_layout::LayoutFlushReason, - capture: moli_layout::PaintCaptureRequest, - publication: LayoutPublicationRequest, - ) -> anyhow::Result> { - self.paint_layout_snapshot_with_capture_in_environment( - viewport, - reason, - capture, - publication, None, ) } - pub(crate) fn paint_layout_snapshot_with_capture_in_environment( + pub(crate) fn paint_layout_snapshot_with_capture( &mut self, viewport: moli_layout::PaintViewport, reason: moli_layout::LayoutFlushReason, @@ -235,7 +220,7 @@ impl ScriptVm { self.with_fresh_layout_pass_in_environment(request, publication, None, consume) } - fn with_fresh_layout_pass_in_environment( + pub(super) fn with_fresh_layout_pass_in_environment( &mut self, request: moli_layout::LayoutPassRequest, publication: LayoutPublicationRequest, @@ -244,6 +229,12 @@ impl ScriptVm { &mut moli_layout::LayoutPassResult, ) -> Result, ) -> Result, moli_layout::LayoutError> { + if environment_override.is_some() && publication != LayoutPublicationRequest::Transient { + return Err(moli_layout::LayoutError::source_contract( + "capture environment", + "temporary style environments require transient layout publication", + )); + } // Font resources are reconciled by the document resource lifecycle. // Layout consumes registered data and performs no font loading work. let requests_paint = request.requests_paint(); diff --git a/moli-renderer-v8/src/script_vm/tests/realtime_publication.rs b/moli-renderer-v8/src/script_vm/tests/realtime_publication.rs index e3126d7ec1..e73ef5c79b 100644 --- a/moli-renderer-v8/src/script_vm/tests/realtime_publication.rs +++ b/moli-renderer-v8/src/script_vm/tests/realtime_publication.rs @@ -113,3 +113,52 @@ fn realtime_publication_rejects_changed_inputs_without_overwriting_the_screen_tr assert_eq!(vm.eval("String(target.offsetWidth)").unwrap(), "100"); assert_eq!(vm.layout_pass_observability_for_test().1, before + 2); } + +#[test] +fn realtime_publication_temporary_media_failure_preserves_screen_geometry() { + let (mut vm, viewport) = fixture( + "
", + ); + assert_eq!(vm.eval("String(target.offsetWidth)").unwrap(), "40"); + let builds = vm.layout_pass_observability_for_test().1; + let publishes = vm.layout_snapshot_cache_observability_for_test().2; + let environment = { + let host = vm._context_host.borrow(); + let mut media = host.emulated_media().clone(); + media.media = Some("print".to_owned()); + crate::style_engine::StyloStyleEnvironment::from_emulated_media(&media) + }; + let error = vm + .with_fresh_layout_pass_in_environment::<()>( + LayoutPassRequest::new(viewport, LayoutFlushReason::Print), + LayoutPublicationRequest::Transient, + Some(environment), + |_| { + Err(LayoutError::source_contract( + "test capture", + "output consumption failed", + )) + }, + ) + .unwrap_err(); + assert!(matches!(error, LayoutError::SourceContract { .. })); + assert_eq!(vm.eval("JSON.stringify([target.offsetWidth,getComputedStyle(target).width,matchMedia('print').matches])").unwrap(), "[40,\"40px\",false]"); + assert_eq!(vm.layout_pass_observability_for_test().1, builds + 1); + assert_eq!( + vm.layout_snapshot_cache_observability_for_test().2, + publishes + ); + + // A temporary environment must never receive a canonical screen stamp, + // even when a caller provides an ordinary screenshot reason. + let error = vm + .with_fresh_layout_pass_in_environment( + LayoutPassRequest::new(viewport, LayoutFlushReason::Screenshot), + LayoutPublicationRequest::Screen, + Some(environment), + |_| Ok(()), + ) + .unwrap_err(); + assert!(matches!(error, LayoutError::SourceContract { .. })); + assert_eq!(vm.layout_pass_observability_for_test().1, builds + 1); +}