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)