fix(cdp): expose viewport metrics before layout publication

Read live viewport and document scroll state for Page.getLayoutMetrics without forcing layout. Use published content bounds when available and the viewport otherwise.

Cover first Playwright viewport and clipped screenshots without CDP prewarming, and remove screenshot prewarming from DPR/resize and concurrent-page smoke cases. Keep element geometry and input dependent on explicit layout publication.
This commit is contained in:
ldm0
2026-09-26 17:54:37 +08:00
parent 011340a8a1
commit 86ded948d0
11 changed files with 189 additions and 71 deletions
+8
View File
@@ -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
@@ -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),
@@ -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")
@@ -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,<!doctype html><body style='margin:0'>"
"<div style='width:80px;height:900px;background:rgb(20,30,40)'></div>",
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:
+5 -1
View File
@@ -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"})
+1 -1
View File
@@ -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,
),
@@ -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());
+94 -20
View File
@@ -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("<!doctype html><style>html,body{margin:0}main{width:80px;height:900px;background:rgb(20,30,40)}</style><main></main>"),
).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() {
@@ -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)
+4 -17
View File
@@ -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<RendererLayoutMetrics> {
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),
})
}
}
}
+32 -6
View File
@@ -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<DomHandle>,
) -> Result<moli_layout::LayoutAnswers<DomHandle>, 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(