diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index e8ab5de03e..66464fb7fe 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -249,6 +249,14 @@ Covered well: asynchronous request cancellation, and suppression of asynchronous events and timers while a synchronous request owns the main thread. - The focused `layout-screenshot` group drives a real raw WebSocket session through target creation/attachment, fixed viewport, lifecycle-gated navigation, DevTools-style PNG capture (`quality: 100`), paint/layout mutations, page clips, `captureBeyondViewport`, and the Chromium DevTools node-screenshot chain (`DOM.getBoxModel` + `Page.getLayoutMetrics` + page clip). Its Moli-only TreeScope fixture also captures 104 open/closed Shadow Roots, nested roots, and 24 roots in an iframe twice, requiring stable pixels and completion within the protocol timeout. The group covers Moli's generation-gated 1 FPS JPEG screencast as well: initial-frame delivery, clean-state frame suppression without ACK backpressure, 400x300 scaling, metadata/session routing, mutation freshness, stop cleanup, and a separately restarted default-Mock boundary without `--layout`. The screenshot surface sequence can also run against Chromium as a coarse reference; the TreeScope, fixed-1-FPS, and default-Mock branches are Moli-only. +- The default `playwright-compat` group takes viewport and clipped screenshots + through `page.screenshot()` on fresh pages, without a raw CDP capture first. + This covers Playwright's `Page.getLayoutMetrics` preflight before layout has + been published. DPR/resize and concurrent-page screenshot cases likewise use + Playwright directly, so a fixture cannot hide a broken first screenshot. + A first `fullPage: true` screenshot still requires published DOM geometry: + Playwright reads the document's scroll/offset/client dimensions before its + metrics request, and those dimensions remain zero until layout is published. - The default raw `action-window` group holds Moli's on-demand input policy at the public CDP boundary. Three acknowledged `Input.dispatchMouseEvent` wheel commands remain delayed until one fixed one-second deadline, preserve diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/emulation_storage.py b/moli-cdp-smoke/moli_cdp_smoke/groups/emulation_storage.py index a7f5d26f5f..32a2182768 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/emulation_storage.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/emulation_storage.py @@ -6,7 +6,6 @@ import urllib.parse import urllib.request from typing import Any, Awaitable -from ..helpers import capture_layout from ..assertions import SmokeError, assert_equal, record, record_contract from ..png_image import decode_png @@ -29,7 +28,6 @@ async def run_playwright_screenshot_clip_surface( try: page = await context.new_page() await page.goto(f"{fixture}/plain", wait_until="load", timeout=10_000) - await capture_layout(page) initial = decode_png(await page.screenshot()) assert_equal( (initial.width, initial.height), @@ -38,7 +36,6 @@ async def run_playwright_screenshot_clip_surface( ) await page.set_viewport_size({"width": 320, "height": 240}) - await capture_layout(page) resized = decode_png(await page.screenshot()) assert_equal( (resized.width, resized.height), diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/multi_page.py b/moli-cdp-smoke/moli_cdp_smoke/groups/multi_page.py index 82b327e6dc..ef804046b5 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/multi_page.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/multi_page.py @@ -9,7 +9,6 @@ from typing import Any from playwright.async_api import Error as PlaywrightError from playwright.async_api import TimeoutError as PlaywrightTimeoutError -from ..helpers import capture_layout from ..assertions import SmokeError, assert_equal, record, wait_until from ..helpers import attach_cdp_event_collector, run_worker_command from .multi_page_chromium_contracts import multi_page_chromium_contract_cases @@ -919,7 +918,6 @@ async def _background_tasks_workers_and_screenshots( ) ) ) - await asyncio.gather(*(capture_layout(page) for page in pages)) screenshots = await asyncio.gather(*(page.screenshot() for page in pages)) if any(not screenshot.startswith(b"\x89PNG\r\n\x1a\n") for screenshot in screenshots): raise SmokeError("concurrent page screenshot returned a non-PNG payload") diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/playwright_compat.py b/moli-cdp-smoke/moli_cdp_smoke/groups/playwright_compat.py index ed62394ccd..eccfc73eba 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/playwright_compat.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/playwright_compat.py @@ -6,9 +6,11 @@ from typing import Any, Callable, Awaitable from . import SmokeState from ..assertions import SmokeError, assert_equal, wait_until from ..pdf_document import assert_pdf_envelope +from ..png_image import decode_png async def run_playwright_compat_group(state: SmokeState) -> None: + await _verify_playwright_first_screenshot(state) await _verify_playwright_context_route_metadata_sample(state) await _verify_playwright_page_route_precedence_sample(state) await _verify_playwright_context_route_fallback_sample(state) @@ -27,6 +29,30 @@ async def run_playwright_compat_group(state: SmokeState) -> None: await _verify_playwright_browser_cdp_session_sample(state) +async def _verify_playwright_first_screenshot(state: SmokeState) -> None: + # Each case starts on a new page. A raw CDP capture here would hide a broken + # getLayoutMetrics preflight in Playwright's own screenshot implementation. + for name, options, size in [ + ("viewport", {}, (320, 240)), + ("clip", {"clip": {"x": 10, "y": 15, "width": 60, "height": 50}}, (60, 50)), + ]: + page = await state.context.new_page() + try: + await page.set_viewport_size({"width": 320, "height": 240}) + await page.goto( + "data:text/html," + "
", + wait_until="load", + timeout=10_000, + ) + image = decode_png(await page.screenshot(timeout=10_000, **options)) + assert_equal((image.width, image.height), size, f"first Playwright {name} screenshot size") + assert_equal(image.pixel(5, 5), (20, 30, 40, 255), f"first Playwright {name} screenshot pixel") + finally: + await page.close() + state.record("playwright_first_screenshot_without_published_layout", {"modes": ["viewport", "clip"]}) + + async def _with_fresh_page(state: SmokeState, body: Callable[[Any, Any], Awaitable[None]]) -> None: context = state.context try: diff --git a/moli-cdp-smoke/moli_cdp_smoke/helpers.py b/moli-cdp-smoke/moli_cdp_smoke/helpers.py index de06ae2ce6..0c99abbe91 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/helpers.py +++ b/moli-cdp-smoke/moli_cdp_smoke/helpers.py @@ -5,7 +5,11 @@ from typing import Any async def capture_layout(page: Any) -> bytes: - """Publish fixture layout with CDP, without Playwright's geometry preflight.""" + """Publish fixture layout for geometry and input assertions. + + Screenshot tests must call page.screenshot() directly on a fresh page; + prewarming here would bypass Playwright's getLayoutMetrics preflight. + """ client = await page.context.new_cdp_session(page) try: result = await client.send("Page.captureScreenshot", {"format": "png"}) diff --git a/moli-cdp-smoke/moli_cdp_smoke/runner.py b/moli-cdp-smoke/moli_cdp_smoke/runner.py index 6ac55c0ee8..f970def943 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/runner.py +++ b/moli-cdp-smoke/moli_cdp_smoke/runner.py @@ -308,7 +308,7 @@ PAGE_GROUPS: tuple[SmokeGroup, ...] = ( ), SmokeGroup( "playwright-compat", - "Playwright upstream derived route and CDPSession compatibility samples.", + "Playwright first screenshots without layout prewarming, routes, and CDPSession compatibility samples.", "page", run_playwright_compat_group, ), diff --git a/moli-protocol/src/domains/input/tests/mouse_snapshot.rs b/moli-protocol/src/domains/input/tests/mouse_snapshot.rs index dab9a75103..cd1c51c647 100644 --- a/moli-protocol/src/domains/input/tests/mouse_snapshot.rs +++ b/moli-protocol/src/domains/input/tests/mouse_snapshot.rs @@ -37,26 +37,24 @@ async fn cdp_geometry_and_mouse_require_explicit_layout_publication() { ); let object_id = resolve_selector_object_id(&mut ctx, "#target", 710).await; - for (method, params) in [ - ("DOM.getBoxModel", json!({"objectId": object_id})), - ("Page.getLayoutMetrics", json!({})), + ctx.process_async(json!({"id":719,"method":"Page.getLayoutMetrics"})) + .await; + assert!(ctx.take_response_by_id(719)["result"]["visualViewport"].is_object()); + ctx.process_async(json!({"id":720,"method":"DOM.getBoxModel","params":{"objectId":object_id}})) + .await; + let cold = ctx.take_response_by_id(720); + assert_eq!(cold["error"]["code"], -32000, "{cold}"); + let message = cold["error"]["message"] + .as_str() + .expect("missing-layout error"); + for command in [ + "Page.captureScreenshot", + "Page.startScreencast", + "wait for a frame", ] { - ctx.process_async(json!({"id":720,"method":method,"params":params})) - .await; - let cold = ctx.take_response_by_id(720); - assert_eq!(cold["error"]["code"], -32000, "{cold}"); - let message = cold["error"]["message"] - .as_str() - .expect("missing-layout error"); - for command in [ - "Page.captureScreenshot", - "Page.startScreencast", - "wait for a frame", - ] { - assert!(message.contains(command), "{cold}"); - } - assert!(!message.contains("Page.printToPDF"), "{cold}"); + assert!(message.contains(command), "{cold}"); } + assert!(!message.contains("Page.printToPDF"), "{cold}"); ctx.process_async(json!({"id":719,"method":"Page.captureScreenshot"})) .await; assert!(ctx.take_response_by_id(719)["result"]["data"].is_string()); diff --git a/moli-protocol/src/domains/page/tests/capture.rs b/moli-protocol/src/domains/page/tests/capture.rs index cdde9324fc..bbe8e62455 100644 --- a/moli-protocol/src/domains/page/tests/capture.rs +++ b/moli-protocol/src/domains/page/tests/capture.rs @@ -548,26 +548,21 @@ async fn print_to_pdf_returns_base64_pdf_without_publishing_interactive_geometry take_response_by_id(&mut ctx, 1111)["result"]["result"]["value"], 0 ); - for (method, params) in [ - ("Page.getLayoutMetrics", json!({})), - ( - "Input.dispatchMouseEvent", - json!({"type":"mousePressed","x":20,"y":20,"button":"left"}), - ), - ] { - ctx.process_async(json!({ - "id": 1113, - "method": method, - "sessionId": "SID-PDF-BASE64", - "params": params - })) - .await; - let response = take_response_by_id(&mut ctx, 1113); - assert_eq!(response["error"]["code"], -32000, "{response}"); - let message = response["error"]["message"].as_str().unwrap(); - assert!(message.contains("Page.captureScreenshot"), "{response}"); - assert!(!message.contains("Page.printToPDF"), "{response}"); - } + ctx.process_async(json!({ + "id":1113,"method":"Page.getLayoutMetrics","sessionId":"SID-PDF-BASE64" + })) + .await; + assert!(take_response_by_id(&mut ctx, 1113)["result"]["visualViewport"].is_object()); + ctx.process_async(json!({ + "id":1113,"method":"Input.dispatchMouseEvent","sessionId":"SID-PDF-BASE64", + "params":{"type":"mousePressed","x":20,"y":20,"button":"left"} + })) + .await; + let response = take_response_by_id(&mut ctx, 1113); + assert_eq!(response["error"]["code"], -32000, "{response}"); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains("Page.captureScreenshot"), "{response}"); + assert!(!message.contains("Page.printToPDF"), "{response}"); ctx.capture_fixture_layout(Some("SID-PDF-BASE64")).await; ctx.process_async(query).await; assert_eq!( @@ -1039,6 +1034,85 @@ fn screenshot_data_url(html: &str) -> String { percent_encoding::percent_encode(html.as_bytes(), percent_encoding::NON_ALPHANUMERIC) ) } +#[tokio::test(flavor = "multi_thread")] +async fn get_layout_metrics_before_first_screenshot_does_not_publish_layout() { + let mut ctx = TestContext::new(); + let session = "SID-COLD-METRICS"; + install_active_screenshot_page( + &mut ctx, + "BID-COLD-METRICS", + "TID-COLD-METRICS", + session, + &screenshot_data_url("
"), + ).await; + set_screenshot_viewport(&mut ctx, session, 320, 240, 1.0, 120).await; + + ctx.process_async(json!({"id":121,"method":"Page.getLayoutMetrics","sessionId":session})) + .await; + let response = take_response_by_id(&mut ctx, 121); + let result = &response["result"]; + assert_eq!(result["layoutViewport"]["clientWidth"], 320, "{response}"); + assert_eq!(result["layoutViewport"]["clientHeight"], 240); + assert_eq!(result["visualViewport"]["pageX"], 0.0); + assert_eq!(result["visualViewport"]["pageY"], 0.0); + assert_eq!(result["visualViewport"]["scale"], 1.0); + assert_eq!( + result["cssContentSize"], + json!({"x":0,"y":0,"width":320.0,"height":240.0}) + ); + + let geometry = json!({ + "id":122,"method":"Runtime.evaluate","sessionId":session, + "params":{"expression":"document.querySelector('main').getBoundingClientRect().width"} + }); + ctx.process_async(geometry.clone()).await; + assert_eq!( + take_response_by_id(&mut ctx, 122)["result"]["result"]["value"], + 0 + ); + + // Playwright gets these metrics before issuing its first capture request. + ctx.process_async(json!({ + "id":123,"method":"Page.captureScreenshot","sessionId":session, + "params":{"format":"png","clip":{ + "x":result["visualViewport"]["pageX"], + "y":result["visualViewport"]["pageY"], + "width":320,"height":240,"scale":result["visualViewport"]["scale"] + }} + })) + .await; + let png = screenshot_bytes(&take_response_by_id(&mut ctx, 123)); + assert_png_dimensions(&png, 320, 240); + assert_eq!(decode_png_pixel(&png, 20, 20), [20, 30, 40, 255]); + ctx.process_async(geometry).await; + assert_eq!( + take_response_by_id(&mut ctx, 122)["result"]["result"]["value"], + 80 + ); + + ctx.process_async(json!({"id":124,"method":"Page.getLayoutMetrics","sessionId":session})) + .await; + let published = take_response_by_id(&mut ctx, 124); + assert_eq!(published["result"]["contentSize"]["height"], 900.0); + + // The viewport and scroll offset stay live even when the content extent + // still belongs to an older published frame. + ctx.process_async(json!({ + "id":125,"method":"Runtime.evaluate","sessionId":session, + "params":{"expression":"window.scrollTo(0,100);document.querySelector('main').style.height='1600px'"} + })).await; + take_response_by_id(&mut ctx, 125); + set_screenshot_viewport(&mut ctx, session, 400, 300, 2.0, 126).await; + ctx.process_async(json!({"id":127,"method":"Page.getLayoutMetrics","sessionId":session})) + .await; + let live = take_response_by_id(&mut ctx, 127); + assert_eq!(live["result"]["layoutViewport"]["clientWidth"], 400); + assert_eq!(live["result"]["layoutViewport"]["clientHeight"], 300); + assert_eq!(live["result"]["visualViewport"]["pageY"], 100.0); + assert_eq!(live["result"]["visualViewport"]["scale"], 2.0); + assert_eq!(live["result"]["contentSize"]["height"], 900.0); +} + /// cdp.page: getLayoutMetrics – falls back to viewport metrics without a live page #[tokio::test(flavor = "multi_thread")] async fn get_layout_metrics() { diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 34986a81f7..e7146037de 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -853,9 +853,9 @@ impl PageVm { RendererPageCommand::SerializeHtml => { Ok(RendererPageReply::OptionalString(Some(self.serialize_html()))) } - RendererPageCommand::LayoutMetrics => self - .layout_metrics() - .map(RendererPageReply::LayoutMetrics), + RendererPageCommand::LayoutMetrics => { + Ok(RendererPageReply::LayoutMetrics(self.layout_metrics())) + } RendererPageCommand::PublishLayout => { self.vm_mut().publish_layout()?; Ok(RendererPageReply::Unit) diff --git a/moli-renderer-v8/src/runtime/page_geometry.rs b/moli-renderer-v8/src/runtime/page_geometry.rs index 593ed446fe..e2d1f5af2c 100644 --- a/moli-renderer-v8/src/runtime/page_geometry.rs +++ b/moli-renderer-v8/src/runtime/page_geometry.rs @@ -1,22 +1,9 @@ -use anyhow::{Result, anyhow}; -use moli_layout::{LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch}; - use super::{PageVm, RendererLayoutMetrics}; impl PageVm { - pub(crate) fn layout_metrics(&mut self) -> Result { - let answers = self - .vm_mut() - .observable_geometry_batch_for_current_document(&LayoutQueryBatch::new(vec![ - LayoutQuery::DocumentMetrics, - ]))?; - let Some(LayoutQueryAnswer::DocumentMetrics(metrics)) = answers.answers.into_iter().next() - else { - return Err(anyhow!( - "geometry provider returned a mismatched document metrics answer" - )); - }; - Ok(RendererLayoutMetrics { + pub(crate) fn layout_metrics(&self) -> RendererLayoutMetrics { + let metrics = self.vm().document_metrics_for_current_document(); + RendererLayoutMetrics { viewport_width: metrics.viewport.css_width, viewport_height: metrics.viewport.css_height, page_x: f64::from(metrics.viewport_scroll.x), @@ -24,6 +11,6 @@ impl PageVm { content_width: f64::from(metrics.content_size.width), content_height: f64::from(metrics.content_size.height), device_pixel_ratio: f64::from(metrics.viewport.device_pixel_ratio), - }) + } } } diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 3ab0f66fd2..6199d9cd3b 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -2764,12 +2764,38 @@ impl ScriptVm { self.start_stylesheet_subresource_fetches(bound); } - pub(crate) fn observable_geometry_batch_for_current_document( - &mut self, - batch: &moli_layout::LayoutQueryBatch, - ) -> Result, moli_layout::LayoutError> { - let document = self._context_host.borrow().document_handle(); - self.observable_geometry_batch_for_document(document, batch) + /// Viewport and scroll state are live browser state, including before the + /// first capture. Only the content extent comes from published layout; + /// without a snapshot it falls back to the viewport, without running layout. + pub(crate) fn document_metrics_for_current_document( + &self, + ) -> moli_layout::LayoutDocumentMetrics { + let host = self._context_host.borrow(); + let document = host.document_handle(); + let viewport = host.layout_viewport_for_document(document); + let viewport_scroll = host + .dom_host() + .dom() + .document_element_handle_for_document(document) + .and_then(|root| host.dom_host().node(root)) + .and_then(|node| node.as_element()) + .map(|element| { + moli_layout::LayoutPoint::new( + element.scroll_left() as f32, + element.scroll_top() as f32, + ) + }) + .unwrap_or(moli_layout::LayoutPoint::ZERO); + let content_size = host + .with_latest_layout_tree_for_document(document, |tree| tree.content_size) + .unwrap_or_else(|| { + moli_layout::LayoutSize::new(viewport.css_width as f32, viewport.css_height as f32) + }); + moli_layout::LayoutDocumentMetrics { + viewport, + viewport_scroll, + content_size, + } } pub(crate) fn observable_geometry_batch_for_document(