mirror of
https://github.com/lexmount/moli.git
synced 2026-10-05 16:00:54 +00:00
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:
@@ -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,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"})
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user