From 3d0e8776fda04a66bb2bb6cd48c323ea09eec27e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 12 Oct 2026 23:35:56 +0800 Subject: [PATCH] fix(renderer): isolate print media from live screen state Pass temporary media through native layout and child style observations, and require transient publication. Printing preserves screen geometry, observer samples, pending frame callbacks and live FontFaceSet identities on errors too. Separate source style generations from computed-context cleanup so temporary print materialization does not dirty screencast tokens. Live media, CSSOM, interaction and resource changes still invalidate output. Keep live font reconciliation screen-owned; print-specific resource integration remains experimental. Scope the diagnostic deadline helper to debug/test builds. Validation: cargo fmt --all; workspace/all-targets/all-features Clippy with -D warnings; cargo nextest run --no-fail-fast (20,101 passed, 17 skipped). Four new regressions cover isolation, pending work, live media and failures. --- README.md | 2 +- moli-core/src/page/mod.rs | 19 +- moli-protocol/src/domains/page.rs | 4 +- moli-protocol/src/domains/page/capture.rs | 1 + moli-renderer-v8/src/lib.rs | 26 +- .../src/native_bridge/context_host/layout.rs | 8 +- .../rendering_update/source.rs | 1 + moli-renderer-v8/src/runtime/mod.rs | 3 +- moli-renderer-v8/src/runtime/page_print.rs | 22 +- .../src/runtime/page_screenshot.rs | 52 +++- .../runtime/page_vm/tests/rendering_update.rs | 1 + .../tests/rendering_update/form_controls.rs | 1 + .../tests/rendering_update/realtime_print.rs | 270 ++++++++++++++++++ .../tests/extracted/runtime_scripts.rs | 2 + .../runtime/tests/extracted/visual_output.rs | 4 + moli-renderer-v8/src/script_vm/layout.rs | 25 +- .../script_vm/tests/realtime_publication.rs | 49 ++++ 17 files changed, 428 insertions(+), 62 deletions(-) create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/realtime_print.rs 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); +}