Files
moli/scripts/layout-phase2-chromium-differential.py
T
2026-08-11 00:10:12 +08:00

442 lines
18 KiB
Python

#!/usr/bin/env python3
"""Validate the Phase 2 CSS-pixel corpus against local Chromium.
Run with:
uv run --project moli-benchmark \
python scripts/layout-phase2-chromium-differential.py
The Rust layout contract and renderer tests assert the same coordinates. This
script deliberately measures DOM geometry rather than comparing screenshots so
font and raster differences cannot hide a main-layout regression.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import socket
import subprocess
import tempfile
import time
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from moli_benchmark.raw_cdp import RawCdpClient, connect_raw_cdp
DEFAULT_CHROMIUM = Path(
os.environ.get(
"CHROMIUM_BINARY",
str(Path.home() / "chromium" / "src" / "out" / "Default" / "chrome"),
)
).expanduser()
TOLERANCE = 0.01
@dataclass(frozen=True)
class Case:
name: str
width: int
height: int
html: str
expected_rects: dict[str, list[float]]
expected_document_size: list[float] | None = None
CASES = (
Case(
name="grid-calc-positioned",
width=800,
height=600,
html="""<!doctype html><style>
html,body{display:block;margin:0;padding:0}
#grid{display:grid;width:400px;height:200px;gap:20px 10px;grid-template-columns:100px 1fr;grid-template-rows:50px 1fr;grid-auto-rows:30px}
#first{grid-column:1;grid-row:1}#second{grid-column:2;grid-row:1/3}#implicit{grid-column:1;grid-row:3}
#calc{width:calc(50% - 10px);height:20px}
#positioned{box-sizing:content-box;position:relative;margin:30px;width:400px;height:300px;padding:20px;border:5px solid transparent}
#static{margin:10px;width:200px;height:100px}
#absolute{position:absolute;left:10%;top:25%;width:50%;height:10px}
#fixed{position:fixed;right:10px;bottom:20px;width:30px;height:40px}
</style><div id=grid><div id=first></div><div id=second></div><div id=implicit></div></div><div id=calc></div><div id=positioned><div id=static><div id=absolute></div></div><div id=fixed></div></div>""",
expected_rects={
"first": [0.0, 0.0, 100.0, 50.0],
"second": [110.0, 0.0, 290.0, 150.0],
"implicit": [0.0, 170.0, 100.0, 30.0],
"calc": [0.0, 200.0, 390.0, 20.0],
"positioned": [30.0, 250.0, 450.0, 350.0],
"absolute": [79.0, 340.0, 220.0, 10.0],
"fixed": [760.0, 540.0, 30.0, 40.0],
},
expected_document_size=[800.0, 630.0],
),
Case(
name="static-inset-flex-order",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0;padding:0}
#static{position:static;left:80px;top:40px;width:20px;height:10px}
#flex{display:flex;width:300px;height:40px;align-items:center}
#late{order:2;flex:1 1 100px;height:20px}#early{order:-1;flex:3 1 100px;height:40px}
#cb{position:relative;width:200px;height:100px}#mid{margin:10px;width:80px;height:30px}#auto-static{position:absolute;width:20px;height:10px}
#intrinsic{width:min-content;height:10px}#basis{display:flex}#basis-child{flex:0 0 content;min-width:0;width:10px;height:10px}#basis-content{width:80px;height:10px}
#clamped{width:90%;max-width:240px;height:10px;margin-left:30px}
</style><div id=static></div><div id=flex><div id=late></div><div id=early></div></div><div id=cb><div id=mid><div id=auto-static></div></div></div><div id=intrinsic></div><div id=basis><div id=basis-child><div id=basis-content></div></div></div><div id=clamped></div>""",
expected_rects={
"static": [0.0, 0.0, 20.0, 10.0],
"early": [0.0, 10.0, 175.0, 40.0],
"late": [175.0, 20.0, 125.0, 20.0],
"basis-child": [0.0, 170.0, 80.0, 10.0],
"clamped": [30.0, 180.0, 240.0, 10.0],
},
),
Case(
name="flex-basis-content-aspect-ratio",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0}.row{display:flex;width:300px}.content-row{height:50px;align-items:flex-start}
#content-item{flex:0 0 content;min-width:0;width:10px;height:10px}#content-inner{width:80px;height:10px}
.ratio-row{height:40px}.ratio{flex:0 0 content;min-width:0;width:20px;aspect-ratio:2}
#plain,#explicit{align-self:flex-start}#explicit{height:30px}#stretched{align-self:stretch}
.edge-row{height:50px}.edge{flex:0 0 content;min-width:0;width:20px;aspect-ratio:2;padding:5px 7px;border:3px solid;box-sizing:content-box}
#content-box-start{align-self:flex-start;height:30px}#content-box-stretch{align-self:stretch}
#border-box-start{align-self:flex-start;height:30px;box-sizing:border-box}
</style><div class="row content-row"><div id=content-item><div id=content-inner></div></div></div>
<div class="row ratio-row"><div id=plain class=ratio></div></div>
<div class="row ratio-row"><div id=explicit class=ratio></div></div>
<div class="row ratio-row"><div id=stretched class=ratio></div></div>
<div class="row edge-row"><div id=content-box-start class=edge></div></div>
<div class="row edge-row"><div id=content-box-stretch class=edge></div></div>
<div class="row edge-row"><div id=border-box-start class=edge></div></div>""",
expected_rects={
"content-item": [0.0, 0.0, 80.0, 10.0],
"plain": [0.0, 50.0, 0.0, 0.0],
"explicit": [0.0, 90.0, 60.0, 30.0],
"stretched": [0.0, 130.0, 80.0, 40.0],
"content-box-start": [0.0, 170.0, 80.0, 46.0],
"content-box-stretch": [0.0, 220.0, 88.0, 50.0],
"border-box-start": [0.0, 270.0, 60.0, 30.0],
},
),
Case(
name="block-margin-collapse",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0}#root{width:400px}#first{height:20px;margin:30px 0 40px}#second{height:10px;margin:20px 0 50px}#after{height:5px}
</style><div id=root><div id=first></div><div id=second></div></div><div id=after></div>""",
expected_rects={
"root": [0.0, 30.0, 400.0, 70.0],
"first": [0.0, 30.0, 400.0, 20.0],
"second": [0.0, 90.0, 400.0, 10.0],
"after": [0.0, 150.0, 800.0, 5.0],
},
),
Case(
name="aspect-ratio-absolute-auto-size",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0}#positioned{position:relative;box-sizing:content-box;width:400px;height:300px;padding:20px;border:5px solid}
#ratio{width:120px;aspect-ratio:2}#absolute{position:absolute;left:10px;right:30px;top:20px;bottom:40px}
</style><div id=positioned><div id=ratio></div><div id=absolute></div></div>""",
expected_rects={
"positioned": [0.0, 0.0, 450.0, 350.0],
"ratio": [25.0, 25.0, 120.0, 60.0],
"absolute": [15.0, 25.0, 400.0, 280.0],
},
),
Case(
name="document-extent-excludes-fixed",
width=300,
height=200,
html="""<!doctype html><style>
html,body{margin:0}#root{position:relative}#flow{width:20px;height:500px}#absolute{position:absolute;left:0;top:700px;width:20px;height:50px}#fixed{position:fixed;left:0;top:900px;width:20px;height:100px}#fixed-child{width:600px;height:300px}
</style><div id=root><div id=flow></div><div id=absolute></div><div id=fixed><div id=fixed-child></div></div></div>""",
expected_rects={
"flow": [0.0, 0.0, 20.0, 500.0],
"absolute": [0.0, 700.0, 20.0, 50.0],
"fixed": [0.0, 900.0, 20.0, 100.0],
},
expected_document_size=[300.0, 750.0],
),
Case(
name="taffy-013-inline-alignment",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0;padding:0}
.host{width:200px;height:40px;font-size:0;line-height:0}
.atom{display:inline-block;vertical-align:top;width:20px;height:10px;position:relative}
#ltr-atomic{left:10%;right:40px;top:25%;bottom:20px}
#rtl{direction:rtl;text-align:left}#rtl-atomic{direction:ltr;left:10px;right:10%;top:auto;bottom:5px}
#right-row{display:flex;width:200px;height:20px;justify-content:right}#right-item{width:20px;height:10px}
#column-row{display:flex;flex-direction:column;width:50px;height:100px;justify-content:right}#column-item{width:20px;height:20px}
#self-grid{display:grid;width:200px;height:20px}#self-item{width:20px;height:10px;direction:rtl;justify-self:self-start}
</style><div id=ltr class=host><div id=ltr-atomic class=atom></div></div>
<div id=rtl class=host><div id=rtl-atomic class=atom></div></div>
<div id=right-row><div id=right-item></div></div>
<div id=column-row><div id=column-item></div></div>
<div id=self-grid><div id=self-item></div></div>""",
expected_rects={
"ltr-atomic": [20.0, 10.0, 20.0, 10.0],
"rtl-atomic": [-20.0, 35.0, 20.0, 10.0],
"right-item": [180.0, 80.0, 20.0, 10.0],
"column-item": [0.0, 100.0, 20.0, 20.0],
"self-item": [180.0, 200.0, 20.0, 10.0],
},
),
Case(
name="taffy-013-flow-root-grid-areas",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0;padding:0}
#flow{display:flow-root;width:100px}#float{float:left;width:40px;height:30px}#after{width:10px;height:5px}
#areas{display:grid;width:300px;height:40px;grid-template-areas:'a a b';grid-template-columns:50px 100px 150px;grid-template-rows:40px}
#area-a{grid-area:a}#area-b{grid-area:b}
</style><div id=flow><div id=float></div></div><div id=after></div>
<div id=areas><div id=area-a></div><div id=area-b></div></div>""",
expected_rects={
"flow": [0.0, 0.0, 100.0, 30.0],
"float": [0.0, 0.0, 40.0, 30.0],
"after": [0.0, 30.0, 10.0, 5.0],
"area-a": [0.0, 35.0, 150.0, 40.0],
"area-b": [150.0, 35.0, 150.0, 40.0],
},
),
Case(
name="taffy-013-table-calc-degenerate-ratio",
width=800,
height=600,
html="""<!doctype html><style>
html,body{margin:0;padding:0}table{border-spacing:0;width:300px;table-layout:fixed}td{padding:0;border:0;height:10px}
#calc-cell{width:calc(50% - 20px)}#image{display:block;width:120px;height:auto;aspect-ratio:0/1}
</style><table><tr><td id=calc-cell></td><td id=remaining-cell></td></tr></table>
<svg id=image width=80 height=40 viewBox='0 0 80 40'></svg>""",
expected_rects={
"calc-cell": [0.0, 0.0, 150.0, 10.0],
"remaining-cell": [150.0, 0.0, 150.0, 10.0],
"image": [0.0, 10.0, 120.0, 60.0],
},
),
)
def reserve_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
return int(listener.getsockname()[1])
async def command(
client: RawCdpClient,
method: str,
params: dict[str, Any] | None = None,
*,
session_id: str | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
message_id = await client.send(method, params, session_id=session_id)
response, messages = await client.recv_until_id(message_id, timeout=10.0)
return dict(response.get("result") or {}), messages
async def wait_for_load(
client: RawCdpClient,
session_id: str,
messages: list[dict[str, Any]],
) -> None:
def loaded(message: dict[str, Any]) -> bool:
return (
message.get("sessionId") == session_id
and message.get("method") == "Page.loadEventFired"
)
if any(loaded(message) for message in messages):
return
deadline = asyncio.get_running_loop().time() + 10.0
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise TimeoutError("timed out waiting for Page.loadEventFired")
message = await asyncio.wait_for(client.recv(), timeout=remaining)
if loaded(message):
return
def assert_close(case: str, key: str, actual: list[float], expected: list[float]) -> None:
if len(actual) != len(expected):
raise AssertionError(f"{case}.{key}: expected {expected}, got {actual}")
for index, (actual_value, expected_value) in enumerate(zip(actual, expected)):
if abs(actual_value - expected_value) > TOLERANCE:
raise AssertionError(
f"{case}.{key}[{index}]: expected {expected_value}, got {actual_value}"
)
async def measure_case(
client: RawCdpClient,
session_id: str,
case: Case,
*,
device_scale_factor: float = 1.0,
) -> dict[str, Any]:
await command(
client,
"Emulation.setDeviceMetricsOverride",
{
"width": case.width,
"height": case.height,
"deviceScaleFactor": device_scale_factor,
"mobile": False,
},
session_id=session_id,
)
data_url = "data:text/html," + urllib.parse.quote(case.html)
_, navigation_messages = await command(
client,
"Page.navigate",
{"url": data_url},
session_id=session_id,
)
await wait_for_load(client, session_id, navigation_messages)
expression = """(() => {
const ids = %s;
const rects = Object.fromEntries(ids.map(id => {
const r = document.getElementById(id).getBoundingClientRect();
return [id, [r.x, r.y, r.width, r.height]];
}));
return {rects, viewport: [innerWidth, innerHeight], documentSize: [document.documentElement.scrollWidth, document.documentElement.scrollHeight]};
})()""" % json.dumps(list(case.expected_rects))
result, _ = await command(
client,
"Runtime.evaluate",
{"expression": expression, "returnByValue": True},
session_id=session_id,
)
exception = result.get("exceptionDetails")
if exception is not None:
raise RuntimeError(f"{case.name}: Runtime.evaluate failed: {exception}")
value = result["result"]["value"]
if value["viewport"] != [case.width, case.height]:
raise AssertionError(
f"{case.name}.viewport: expected {[case.width, case.height]}, got {value['viewport']}"
)
for key, expected in case.expected_rects.items():
assert_close(case.name, key, value["rects"][key], expected)
if case.expected_document_size is not None:
assert_close(
case.name,
"documentSize",
value["documentSize"],
case.expected_document_size,
)
return value
async def run(binary: Path) -> dict[str, Any]:
port = reserve_port()
endpoint = f"http://127.0.0.1:{port}"
with tempfile.TemporaryDirectory(prefix="moli-layout-chromium-") as profile:
process = subprocess.Popen(
[
str(binary),
"--headless=new",
"--disable-background-networking",
"--disable-default-apps",
"--disable-gpu",
"--hide-scrollbars",
"--no-first-run",
"--no-sandbox",
"--remote-debugging-address=127.0.0.1",
f"--remote-debugging-port={port}",
f"--user-data-dir={profile}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
client: RawCdpClient | None = None
target_id: str | None = None
try:
deadline = time.monotonic() + 10.0
last_error: BaseException | None = None
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"Chromium exited during startup: {process.returncode}")
try:
client = await connect_raw_cdp(endpoint)
break
except BaseException as error: # startup evidence retains the last error
last_error = error
await asyncio.sleep(0.05)
if client is None:
raise TimeoutError(f"Chromium CDP startup timed out: {last_error!r}")
version, _ = await command(client, "Browser.getVersion")
target, _ = await command(client, "Target.createTarget", {"url": "about:blank"})
target_id = str(target["targetId"])
attached, _ = await command(
client,
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = str(attached["sessionId"])
await command(client, "Page.enable", session_id=session_id)
measured = {
case.name: await measure_case(client, session_id, case) for case in CASES
}
dpr_one = await measure_case(client, session_id, CASES[1], device_scale_factor=1.0)
dpr_two = await measure_case(client, session_id, CASES[1], device_scale_factor=2.0)
if dpr_one["rects"] != dpr_two["rects"]:
raise AssertionError("deviceScaleFactor changed CSS layout coordinates")
return {
"status": "passed",
"product": version.get("product"),
"revision": version.get("revision"),
"tolerance_css_px": TOLERANCE,
"cases": measured,
"dpr_invariant": True,
}
finally:
if client is not None:
if target_id is not None:
try:
await command(client, "Target.closeTarget", {"targetId": target_id})
except BaseException:
pass
await client.websocket.close()
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3.0)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--chromium", type=Path, default=DEFAULT_CHROMIUM)
return parser.parse_args()
def main() -> None:
args = parse_args()
binary = args.chromium.expanduser().resolve()
if not binary.is_file():
raise SystemExit(f"Chromium binary does not exist: {binary}")
print(json.dumps(asyncio.run(run(binary)), indent=2, sort_keys=True))
if __name__ == "__main__":
main()