diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/scrollbar_visibility.py b/moli-cdp-smoke/moli_cdp_smoke/groups/scrollbar_visibility.py
new file mode 100644
index 0000000000..57c6ae5b63
--- /dev/null
+++ b/moli-cdp-smoke/moli_cdp_smoke/groups/scrollbar_visibility.py
@@ -0,0 +1,292 @@
+"""Native scrollbar visibility through the CLI and public CDP endpoint."""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+from urllib.parse import quote
+
+from ..assertions import SmokeError, assert_equal, record
+from ..config import clear_proxy_env, moli_binary
+from ..png_image import decode_png
+from ..process import run_captured_process
+from ..raw_cdp import RawCdpClient, connect_raw_cdp
+from ..serve import start_moli_serve, stop_moli_serve, wait_for_moli_endpoint
+from .layout_screenshot import _capture, _open_target, _response_for_id, _success, _wait_for_load
+
+
+REPRO = """
"""
+HTML = REPRO + """
+
+
+
+
+"""
+URL = "data:text/html," + quote(HTML)
+REPRO_URL = "data:text/html," + quote(REPRO)
+ROOT_METRICS = "[innerWidth,document.documentElement.clientWidth,document.documentElement.scrollWidth]"
+METRICS = """(() => {
+ const scroller = document.getElementById('scroller');
+ const stable = document.getElementById('stable');
+ const frame = document.getElementById('frame');
+ const child = frame.contentDocument.getElementById('child');
+ return {
+ root: [innerWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth],
+ element: [scroller.clientWidth, scroller.clientHeight, scroller.scrollWidth, scroller.scrollHeight],
+ stable: [stable.clientWidth, stable.clientHeight, stable.clientLeft],
+ css: [getComputedStyle(scroller).scrollbarWidth, getComputedStyle(stable).scrollbarGutter],
+ frame: [frame.contentWindow.innerWidth, frame.contentDocument.documentElement.clientWidth,
+ child.clientWidth, child.clientHeight, child.scrollWidth, child.scrollHeight],
+ };
+})()"""
+CASES = (
+ ("default", (), None, False),
+ ("flag", ("--scrollbars",), None, True),
+ ("env-true", (), "true", True),
+ ("env-one", (), "1", True),
+ ("env-false", (), "false", False),
+ ("env-zero", (), "0", False),
+ ("flag-overrides-false", ("--scrollbars",), "false", True),
+ ("flag-overrides-zero", ("--scrollbars",), "0", True),
+)
+
+
+async def _evaluate(client: RawCdpClient, session: str, expression: str) -> Any:
+ result, _, _ = await _success(client, "Runtime.evaluate", {
+ "expression": expression, "returnByValue": True,
+ }, session_id=session)
+ if "exceptionDetails" in result:
+ raise SmokeError(f"scrollbar evaluation failed: {result!r}")
+ return result["result"].get("value")
+
+
+async def _assert_metrics(
+ client: RawCdpClient, session: str, visible: bool, width: int = 800,
+) -> dict[str, Any]:
+ metrics = await _evaluate(client, session, METRICS)
+ assert_equal(metrics, {
+ "root": [width, width - (15 if visible else 0), width],
+ "element": [185, 85, 400, 300] if visible else [200, 100, 400, 300],
+ "stable": [170, 85 if visible else 100, 15],
+ "css": ["auto", "stable both-edges"],
+ "frame": [240, 225, 85, 45, 200, 180] if visible else [240, 240, 100, 60, 200, 180],
+ }, "scrollbar visibility, authored gutters and embedded frame geometry")
+ return metrics
+
+
+async def _assert_snapshot(
+ client: RawCdpClient, session: str, visible: bool,
+ width: int = 800, height: int = 600,
+) -> dict[str, Any]:
+ png, _ = await _capture(client, session)
+ image = decode_png(png)
+ assert_equal((image.width, image.height), (width, height), "scrollbar viewport PNG")
+ color = (0, 0, 255, 255) if visible else (0, 255, 0, 255)
+ for label, point in [
+ ("viewport", (width - 8, height // 2)),
+ ("element", (212, 80)),
+ ("iframe", (532, 120)),
+ ]:
+ assert_equal(image.pixel(*point), color, f"{label} scrollbar paint or underlying content")
+ return await _assert_metrics(client, session, visible, width)
+
+
+async def _assert_input(client: RawCdpClient, session: str, visible: bool) -> None:
+ offsets = await _evaluate(client, session, """(() => {
+ const scroller = document.getElementById('scroller');
+ const frame = document.getElementById('frame');
+ const child = frame.contentDocument.getElementById('child');
+ scroller.scrollLeft = 20; scroller.scrollTop = 60;
+ child.scrollLeft = 10; child.scrollTop = 15;
+ frame.contentWindow.scrollTo(0, 20); window.scrollTo(0, 100);
+ return [scroller.scrollLeft, scroller.scrollTop, scrollY,
+ child.scrollLeft, child.scrollTop, frame.contentWindow.scrollY];
+ })()""")
+ assert_equal(offsets, [20, 60, 100, 10, 15, 20], "hidden and visible script scrolling")
+ await _evaluate(client, session, """(() => {
+ window.scrollTo(0, 0); clicks.length = 0;
+ document.getElementById('scroller').scrollLeft = 0;
+ })()""")
+ await _capture(client, session)
+ # This is the classic vertical up button, or normal content when hidden.
+ for event, buttons in [("mousePressed", 1), ("mouseReleased", 0)]:
+ await _success(client, "Input.dispatchMouseEvent", {
+ "type": event, "x": 212, "y": 25, "button": "left",
+ "buttons": buttons, "clickCount": 1,
+ }, session_id=session)
+ await _capture(client, session)
+ assert_equal(await _evaluate(client, session,
+ "[document.getElementById('scroller').scrollTop, clicks]"),
+ [20, []] if visible else [60, ["content"]],
+ "native scrollbar hit or DOM content hit")
+ await _evaluate(client, session, "document.getElementById('scroller').scrollTop = 0")
+ await _capture(client, session)
+ await _success(client, "Input.dispatchMouseEvent", {
+ "type": "mouseWheel", "x": 80, "y": 50, "deltaX": 0, "deltaY": 30,
+ }, session_id=session)
+ await _capture(client, session)
+ assert_equal(await _evaluate(client, session,
+ "document.getElementById('scroller').scrollTop"), 30, "hidden and visible wheel scrolling")
+
+
+async def _assert_emulation(
+ client: RawCdpClient, target: str, session: str, startup_visible: bool,
+) -> None:
+ await _assert_snapshot(client, session, startup_visible)
+ for params in [{}, {"hidden": "true"}]:
+ message_id = await client.send("Emulation.setScrollbarsHidden", params, session_id=session)
+ response, _ = await _response_for_id(client, message_id)
+ assert_equal(response.get("error", {}).get("code"), -32602, "invalid scrollbar emulation parameters")
+ await _assert_metrics(client, session, startup_visible)
+ await _success(client, "Emulation.setScrollbarsHidden", {"hidden": True}, session_id=session)
+ # Geometry is current at the command reply, before another output demand.
+ await _assert_metrics(client, session, False)
+ await _assert_snapshot(client, session, False)
+ other, peer = await _open_target(client, URL)
+ try:
+ await _assert_snapshot(client, peer, startup_visible)
+ finally:
+ await _success(client, "Target.closeTarget", {"targetId": other})
+ _, _, seen = await _success(client, "Page.navigate", {"url": URL}, session_id=session)
+ await _wait_for_load(client, session, seen)
+ assert_equal(await _evaluate(client, session, "firstClient"), 800, "hidden emulation before navigation scripts")
+ await _assert_snapshot(client, session, False)
+ await _success(client, "Emulation.setScrollbarsHidden", {"hidden": False}, session_id=session)
+ await _assert_metrics(client, session, startup_visible)
+ await _assert_snapshot(client, session, startup_visible)
+ await _success(client, "Emulation.setScrollbarsHidden", {"hidden": True}, session_id=session)
+ await _success(client, "Target.detachFromTarget", {"sessionId": session})
+ attached, _, _ = await _success(client, "Target.attachToTarget", {"targetId": target, "flatten": True})
+ session = attached["sessionId"]
+ # Detach also clears the old session's device-metrics override.
+ await _success(client, "Emulation.setDeviceMetricsOverride", {
+ "width": 800, "height": 600, "deviceScaleFactor": 1, "mobile": False,
+ }, session_id=session)
+ await _assert_snapshot(client, session, startup_visible)
+ await _success(client, "Emulation.setDeviceMetricsOverride", {
+ "width": 640, "height": 480, "deviceScaleFactor": 1, "mobile": False,
+ }, session_id=session)
+ await _assert_snapshot(client, session, startup_visible, 640, 480)
+
+
+async def _serve_case(
+ name: str, args: tuple[str, ...], env_value: str | None,
+ visible: bool, results: list[dict[str, Any]],
+) -> None:
+ serve = await start_moli_serve(extra_args=args, env_overrides={"MOLI_SCROLLBARS": env_value})
+ client: RawCdpClient | None = None
+ try:
+ endpoint = await wait_for_moli_endpoint(serve)
+ client = await connect_raw_cdp(endpoint)
+ target, session = await _open_target(client, URL)
+ metrics = await _assert_snapshot(client, session, visible)
+ record(results, f"scrollbar_serve_{name}", {"visible": visible, "metrics": metrics})
+ if name in {"default", "flag"}:
+ await _assert_input(client, session, visible)
+ record(results, f"scrollbar_input_{name}")
+ # Reload clears the scroll offsets and DOM event probe.
+ _, _, seen = await _success(client, "Page.navigate", {"url": URL}, session_id=session)
+ await _wait_for_load(client, session, seen)
+ await _assert_emulation(client, target, session, visible)
+ record(results, f"scrollbar_emulation_{name}")
+ finally:
+ try:
+ if client is not None:
+ await client.websocket.close()
+ finally:
+ await stop_moli_serve(serve)
+
+
+async def _fetch_case(
+ name: str, args: tuple[str, ...], env_value: str | None,
+ visible: bool, results: list[dict[str, Any]],
+) -> None:
+ env = clear_proxy_env(os.environ)
+ env.pop("MOLI_LAYOUT", None)
+ env.pop("MOLI_SCROLLBARS", None)
+ if env_value is not None:
+ env["MOLI_SCROLLBARS"] = env_value
+ command = [str(moli_binary()), "fetch", "--layout", *args]
+ for output_args in [("--eval", ROOT_METRICS), ("--dump", "screenshot")]:
+ result = await run_captured_process([*command, *output_args, REPRO_URL], env=env, timeout_seconds=15)
+ if result.returncode != 0:
+ raise SmokeError(f"fetch {name} failed: {result.stderr.decode(errors='replace')}")
+ if output_args[0] == "--eval":
+ metrics = json.loads(result.stdout)
+ width = metrics[0]
+ assert_equal(metrics, [width, width - (15 if visible else 0), width], f"fetch {name} scrollbar geometry")
+ else:
+ image = decode_png(result.stdout)
+ pixel = image.pixel(image.width - 8, image.height // 2)
+ if visible:
+ assert_equal(pixel in {(255, 0, 0, 255), (0, 0, 255, 255)}, True, f"fetch {name} paints a scrollbar")
+ else:
+ assert_equal(pixel, (0, 255, 0, 255), f"fetch {name} paints page content to the edge")
+ record(results, f"scrollbar_fetch_{name}", {"visible": visible, "metrics": metrics})
+
+
+async def _layout_boundaries(results: list[dict[str, Any]]) -> None:
+ serve = await start_moli_serve(
+ layout=False, env_overrides={"MOLI_LAYOUT": "0", "MOLI_SCROLLBARS": "0"},
+ )
+ client: RawCdpClient | None = None
+ try:
+ client = await connect_raw_cdp(await wait_for_moli_endpoint(serve))
+ target, session = await _open_target(client, REPRO_URL)
+ message_id = await client.send("Emulation.setScrollbarsHidden", {"hidden": True}, session_id=session)
+ response, _ = await _response_for_id(client, message_id)
+ assert_equal(response.get("error", {}).get("code"), -32000, "scrollbar emulation requires layout")
+ assert_equal(response["error"]["message"], "Emulation.setScrollbarsHidden requires --layout", "scrollbar layout boundary")
+ await _success(client, "Target.closeTarget", {"targetId": target})
+ record(results, "scrollbar_mock_layout_boundary")
+ finally:
+ try:
+ if client is not None:
+ await client.websocket.close()
+ finally:
+ await stop_moli_serve(serve)
+
+ env = clear_proxy_env(os.environ)
+ env.pop("MOLI_LAYOUT", None)
+ env.pop("MOLI_SCROLLBARS", None)
+ for args, value in [(("--scrollbars",), None), ((), "true"), ((), "1")]:
+ child_env = dict(env)
+ if value is not None:
+ child_env["MOLI_SCROLLBARS"] = value
+ result = await run_captured_process(
+ [str(moli_binary()), "fetch", *args, "--eval", ROOT_METRICS, REPRO_URL],
+ env=child_env, timeout_seconds=15,
+ )
+ assert_equal(result.returncode != 0 and b"--layout" in result.stderr, True,
+ "enabling scrollbars requires layout")
+ record(results, "scrollbar_cli_layout_dependency")
+
+
+async def run_scrollbar_visibility_group(
+ endpoint: str, fixture: str, results: list[dict[str, Any]],
+) -> None:
+ # Every startup case owns its process/environment; the caller's endpoint
+ # cannot exercise CLI defaults or environment precedence.
+ for name, args, env_value, visible in CASES:
+ await _serve_case(name, args, env_value, visible, results)
+ await _fetch_case(name, args, env_value, visible, results)
+ await _layout_boundaries(results)
diff --git a/moli-cdp-smoke/moli_cdp_smoke/runner.py b/moli-cdp-smoke/moli_cdp_smoke/runner.py
index 553d4fa102..d02673f556 100644
--- a/moli-cdp-smoke/moli_cdp_smoke/runner.py
+++ b/moli-cdp-smoke/moli_cdp_smoke/runner.py
@@ -44,6 +44,7 @@ from .groups.layout_policy import run_layout_policy_group
from .groups.layout_screenshot import run_layout_screenshot_group
from .groups.locale_timezone_inputs import run_locale_timezone_inputs_group
from .groups.process_environment import run_process_environment_group
+from .groups.scrollbar_visibility import run_scrollbar_visibility_group
from .groups.multi_client import run_multi_client_group
from .groups.multi_context import run_multi_context_group
from .groups.multi_page import run_multi_page_group
@@ -112,6 +113,12 @@ async def _await_group(group: SmokeGroup, awaitable: Awaitable[None]) -> None:
RAW_GROUPS: tuple[SmokeGroup, ...] = (
+ SmokeGroup(
+ "scrollbar-visibility",
+ "Moli fetch/serve scrollbar defaults, CLI/environment precedence, CDP emulation, frames, paint, and input.",
+ "raw",
+ run_scrollbar_visibility_group,
+ ),
SmokeGroup(
"debugger-breakpoints",
"Raw Debugger breakpoint commands dispatched while the page is normally running.",
@@ -714,7 +721,7 @@ async def async_main(argv: list[str] | None = None) -> int:
serve = await start_moli_serve(
port,
extra_args=("--scrollbars",)
- if selected_group.name == "classic-scrollbar"
+ if selected_group.name in {"classic-scrollbar", "iframe-input"}
else (),
)
endpoint = await wait_for_moli_endpoint(serve)
diff --git a/moli-cdp-smoke/moli_cdp_smoke/serve.py b/moli-cdp-smoke/moli_cdp_smoke/serve.py
index d744a7c12d..1691b19928 100644
--- a/moli-cdp-smoke/moli_cdp_smoke/serve.py
+++ b/moli-cdp-smoke/moli_cdp_smoke/serve.py
@@ -79,6 +79,7 @@ async def start_moli_serve(
*,
layout: bool = True,
extra_args: tuple[str, ...] = (),
+ env_overrides: dict[str, str | None] | None = None,
) -> MoliServe:
binary = moli_binary()
http_cache_dir = tempfile.mkdtemp(prefix="moli-cdp-smoke-cache-")
@@ -96,11 +97,17 @@ async def start_moli_serve(
if layout:
command.append("--layout")
command.extend(("--http-cache-dir", http_cache_dir, *extra_args))
+ env = clear_proxy_env(os.environ)
+ for key, value in (env_overrides or {}).items():
+ if value is None:
+ env.pop(key, None)
+ else:
+ env[key] = value
try:
process = await asyncio.create_subprocess_exec(
*command,
cwd=str(REPO_ROOT),
- env=clear_proxy_env(os.environ),
+ env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=subprocess_starts_new_session(),
diff --git a/moli-cdp-smoke/tests/test_group_selection.py b/moli-cdp-smoke/tests/test_group_selection.py
index 24ee069b16..6a31ad4099 100644
--- a/moli-cdp-smoke/tests/test_group_selection.py
+++ b/moli-cdp-smoke/tests/test_group_selection.py
@@ -37,6 +37,7 @@ class GroupSelectionTests(unittest.TestCase):
self.assertIn("webgl-viewport", DEFAULT_GROUP_NAMES)
self.assertIn("svg-rect", DEFAULT_GROUP_NAMES)
self.assertIn("layout-policy", DEFAULT_GROUP_NAMES)
+ self.assertIn("scrollbar-visibility", DEFAULT_GROUP_NAMES)
self.assertIn("target-lifecycle", DEFAULT_GROUP_NAMES)
self.assertIn("multi-page", DEFAULT_GROUP_NAMES)
self.assertIn("puppeteer", DEFAULT_GROUP_NAMES)