diff --git a/moli-benchmark/README.md b/moli-benchmark/README.md index b9c76d2ed5..0211ce6f95 100644 --- a/moli-benchmark/README.md +++ b/moli-benchmark/README.md @@ -130,6 +130,15 @@ them. The runner waits for load, fonts, two animation frames, and removal of `crashtest` with zero subtests. An outstanding wait remains a timeout, and a renderer crash is reported even when the browser process stays alive. +Explicit cases that reference `testdriver.js` use CDP in `--mode auto`. +The CDP runner delivers trusted keyboard and mouse/pen input on an independent +connection, so input can finish while a harness probe awaits a JavaScript +promise. It supports `click`, ASCII and WebDriver special keys in `send_keys`, +and keyboard, mouse/pen, wheel, and pause actions. Unsupported touch sources, +pointer geometry properties, and cross-origin or transformed frame pointer +coordinates reject the automation request. CLI mode cannot run these input +cases. The bridge preserves the engine's DOM geometry and hit-test APIs. + ### Cross-engine layout WPT The standalone cross-engine runner has separate layout profiles, so its diff --git a/moli-benchmark/moli_benchmark/wpt_cross/__main__.py b/moli-benchmark/moli_benchmark/wpt_cross/__main__.py index 90d3e86f20..2ba4d7c55f 100644 --- a/moli-benchmark/moli_benchmark/wpt_cross/__main__.py +++ b/moli-benchmark/moli_benchmark/wpt_cross/__main__.py @@ -28,6 +28,7 @@ import time from collections import Counter from pathlib import Path from typing import Any +from urllib.parse import urlsplit from .case_set import ( ANY_JS_GLOBAL_CHOICES, @@ -57,6 +58,22 @@ from ..config import clear_current_proxy_env REPO_CASE_LIST_DIR = Path(__file__).resolve().parents[2] / "wpt-cross-current" WPT_CROSS_CASE_TIMEOUT_SECONDS = 120.0 WPT_CROSS_PARALLELISM = 50 + + +def _case_references_testdriver(wpt_root: Path, case: WptCase) -> bool: + path = urlsplit(case.case_path).path.lstrip("/") + for suffix, source_suffix in ((".any.html", ".any.js"), (".window.html", ".window.js")): + if path.endswith(suffix): + path = path.removesuffix(suffix) + source_suffix + break + source = (wpt_root / path).resolve() + try: + source.relative_to(wpt_root.resolve()) + return b"/resources/testdriver" in source.read_bytes() + except (OSError, ValueError): + return False + + WPT_CROSS_PROFILES = ( "default", "layout-testharness", @@ -664,7 +681,13 @@ def main(argv: list[str] | None = None) -> int: if has_crashtests and args.mode == "cli": print("error: crashtests require CDP mode to observe readiness and crashes", file=sys.stderr) return 4 - requires_cdp = fixed_layout_viewport or has_crashtests + native_input_required = args.mode != "cdp" and any( + _case_references_testdriver(args.wpt_root, case) for case in cases + ) + if native_input_required and args.mode == "cli": + print("error: testdriver cases require CDP mode for native input", file=sys.stderr) + return 4 + requires_cdp = fixed_layout_viewport or has_crashtests or native_input_required if fixed_layout_viewport and args.mode == "cli": print( "error: layout profiles and reftests require CDP mode for fixed viewport screenshots", diff --git a/moli-benchmark/moli_benchmark/wpt_cross/native_input.py b/moli-benchmark/moli_benchmark/wpt_cross/native_input.py new file mode 100644 index 0000000000..9e7563f8fc --- /dev/null +++ b/moli-benchmark/moli_benchmark/wpt_cross/native_input.py @@ -0,0 +1,587 @@ +"""Native WPT automation on a separate CDP connection from harness probes. + +The input connection must remain runnable while the harness connection awaits a +JavaScript promise. Each instance belongs to one case target and is disposed +with it; no pressed keys, pending bindings, or pointer state cross case borders. +Permission requests use frames exposed by the attached page session; out-of- +process iframe targets require a separate attachment and report an error here. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import math +import time +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlsplit + +from ..raw_cdp import ( + RawCdpConnectionClosed, + RawCdpError, + RawCdpTimeoutError, + RoutedRawCdpClient, + connect_routed_raw_cdp, +) + +BINDING_NAME = "__bench_wpt_native_input__" +STATE_NAME = "__bench_wpt_native_input_state__" + + +@dataclass(frozen=True) +class Key: + key: str + code: str + virtual_code: int + location: int = 0 + text: str = "" + + +_SPECIAL_KEYS = { + "\ue001": Key("Cancel", "Cancel", 3), + "\ue002": Key("Help", "Help", 47), + "\ue003": Key("Backspace", "Backspace", 8), + "\ue004": Key("Tab", "Tab", 9), + "\ue005": Key("Clear", "Numpad5", 12, 3), + "\ue006": Key("Enter", "Enter", 13, text="\r"), + "\ue007": Key("Enter", "NumpadEnter", 13, 3, "\r"), + "\ue008": Key("Shift", "ShiftLeft", 16, 1), + "\ue009": Key("Control", "ControlLeft", 17, 1), + "\ue00a": Key("Alt", "AltLeft", 18, 1), + "\ue00b": Key("Pause", "Pause", 19), + "\ue00c": Key("Escape", "Escape", 27), + "\ue00d": Key(" ", "Space", 32, text=" "), + "\ue010": Key("End", "End", 35), + "\ue011": Key("Home", "Home", 36), + "\ue00e": Key("PageUp", "PageUp", 33), + "\ue00f": Key("PageDown", "PageDown", 34), + "\ue012": Key("ArrowLeft", "ArrowLeft", 37), + "\ue013": Key("ArrowUp", "ArrowUp", 38), + "\ue014": Key("ArrowRight", "ArrowRight", 39), + "\ue015": Key("ArrowDown", "ArrowDown", 40), + "\ue016": Key("Insert", "Insert", 45), + "\ue017": Key("Delete", "Delete", 46), + "\ue03d": Key("Meta", "MetaLeft", 91, 1), + "\ue050": Key("Shift", "ShiftRight", 16, 2), + "\ue051": Key("Control", "ControlRight", 17, 2), + "\ue052": Key("Alt", "AltRight", 18, 2), + "\ue053": Key("Meta", "MetaRight", 92, 2), + "\n": Key("Enter", "Enter", 13, text="\r"), + "\r": Key("Enter", "Enter", 13, text="\r"), + "\t": Key("Tab", "Tab", 9), +} +_SPECIAL_KEYS.update({chr(0xE01A + n): Key(str(n), f"Numpad{n}", 96 + n, 3, str(n)) for n in range(10)}) +_SPECIAL_KEYS.update({chr(0xE031 + n): Key(f"F{n + 1}", f"F{n + 1}", 112 + n) for n in range(12)}) +_SPECIAL_KEYS.update({ + chr(0xE024 + n): Key(char, "Numpad" + name, 106 + n, 3, char) + for n, (char, name) in enumerate(zip("*+,-./", ("Multiply", "Add", "Comma", "Subtract", "Decimal", "Divide"))) +}) +_MODIFIERS = {"Alt": 1, "Control": 2, "Meta": 4, "Shift": 8} +_PUNCTUATION = { + " ": ("Space", 32), "`": ("Backquote", 192), "-": ("Minus", 189), + "=": ("Equal", 187), "[": ("BracketLeft", 219), "]": ("BracketRight", 221), + "\\": ("Backslash", 220), ";": ("Semicolon", 186), "'": ("Quote", 222), + ",": ("Comma", 188), ".": ("Period", 190), "/": ("Slash", 191), +} +_SHIFTED = dict(zip("`1234567890-=[]\\;',./", '~!@#$%^&*()_+{}|:"<>?')) +_UNSHIFTED = {value: key for key, value in _SHIFTED.items()} + + +def key_description(value: str, *, shift: bool = False) -> Key: + if value in _SPECIAL_KEYS: + return _SPECIAL_KEYS[value] + value = {"\ue018": ";", "\ue019": "="}.get(value, value) + if not isinstance(value, str) or len(value) != 1 or 0xE000 <= ord(value) <= 0xF8FF: + raise ValueError(f"unsupported WebDriver key: {value!r}") + base = _UNSHIFTED.get(value, value).lower() + text = value + if shift: + text = _SHIFTED.get(value, value.upper() if value.isascii() else value) + if base.isascii() and base.isalpha(): + code, virtual_code = "Key" + base.upper(), ord(base.upper()) + elif base.isascii() and base.isdigit(): + code, virtual_code = "Digit" + base, ord(base) + else: + code, virtual_code = _PUNCTUATION.get(base, ("", 0)) + if not code: + raise ValueError("native WPT input requires an ASCII or WebDriver special key") + return Key(text, code, virtual_code, text=text) + + +@dataclass +class Pointer: + kind: str = "mouse" + x: float = 0 + y: float = 0 + buttons: list[int] = field(default_factory=list) + click_count: int = 0 + last_click: tuple[float, float, float, int] | None = None + + +def validate_actions(sources: Any) -> None: + if not isinstance(sources, list): + raise ValueError("actions must be a list") + ids: set[str] = set() + allowed = { + "none": {"pause"}, "key": {"pause", "keyDown", "keyUp"}, + "pointer": {"pause", "pointerMove", "pointerDown", "pointerUp"}, + "wheel": {"pause", "scroll"}, + } + for source in sources: + if not isinstance(source, dict) or source.get("type") not in allowed: + raise ValueError("unsupported input source") + source_id = source.get("id") + if not isinstance(source_id, str) or source_id in ids: + raise ValueError("input source IDs must be unique strings") + ids.add(source_id) + actions = source.get("actions") + if not isinstance(actions, list): + raise ValueError("source actions must be a list") + if source["type"] == "pointer": + parameters = source.get("parameters", {}) + if not isinstance(parameters, dict) or parameters.get("pointerType", "mouse") not in {"mouse", "pen"}: + raise ValueError("native WPT input supports mouse and pen pointer sources") + for action in actions: + if not isinstance(action, dict) or action.get("type") not in allowed[source["type"]]: + raise ValueError("unsupported input action") + kind = action["type"] + if "duration" in action and (type(action["duration"]) is not int or action["duration"] < 0): + raise ValueError("action duration must be a nonnegative integer") + if kind in {"keyDown", "keyUp"}: + key_description(action.get("value")) + if kind in {"pointerDown", "pointerUp"} and ( + type(action.get("button")) is not int or not 0 <= action["button"] <= 4 + ): + raise ValueError("unsupported pointer button") + if kind in {"pointerMove", "scroll"}: + fields = ("x", "y", "deltaX", "deltaY") if kind == "scroll" else ("x", "y") + if any(type(action.get(name)) is not int for name in fields): + raise ValueError("action coordinates and deltas must be integers") + origin = action.get("origin", "viewport") + if isinstance(origin, dict): + if type(origin.get("element")) is not int or origin["element"] < 0: + raise ValueError("invalid element origin") + elif origin != "viewport" and not (kind == "pointerMove" and origin == "pointer"): + raise ValueError("unsupported input origin") + for name, low, high in ( + ("pressure", 0, 1), ("tangentialPressure", -1, 1), + ("tiltX", -90, 90), ("tiltY", -90, 90), ("twist", 0, 359), + ): + if name in action: + value = action[name] + if type(value) not in (int, float) or not math.isfinite(value) or not low <= value <= high: + raise ValueError(f"invalid pointer {name}") + if any(name in action for name in ("width", "height", "altitudeAngle", "azimuthAngle")): + raise ValueError("native pointer geometry properties are not supported") + + +def permission_origin(frame: dict[str, Any]) -> str: + # Frame.securityOrigin preserves inherited about:blank origins and opaque + # sandbox origins, which cannot be reconstructed from location.href. + origin = frame.get("securityOrigin", frame.get("url")) + if not isinstance(origin, str): + raise ValueError("permission frame has no origin") + parsed = urlsplit(origin) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("cannot set permission for an opaque or unsupported origin") + return f"{parsed.scheme}://{parsed.netloc}" + + +class NativeInput: + def __init__( + self, client: RoutedRawCdpClient, session_id: str, + browser_context_id: str | None = None, + ) -> None: + self.client = client + self.session_id = session_id + self.browser_context_id = browser_context_id + self.deadline: float | None = None + self.task: asyncio.Task[None] | None = None + self.keyboards: dict[str, dict[str, Key]] = {} + self.pointers: dict[str, Pointer] = {} + self._closed = False + self._permissions_changed = False + self._permission_cleanup_error: Exception | None = None + + @classmethod + async def attach( + cls, endpoint: str, target_id: str, browser_context_id: str | None = None, + ) -> NativeInput: + client = await connect_routed_raw_cdp(endpoint) + try: + result = await client.command("Target.attachToTarget", {"targetId": target_id, "flatten": True}) + instance = cls(client, result.response["result"]["sessionId"], browser_context_id) + await instance.command("Runtime.enable") + await instance.command("Runtime.addBinding", {"name": BINDING_NAME}) + instance.task = asyncio.create_task(instance.run(), name="wpt-native-input") + return instance + except BaseException: + await client.close() + raise + + async def close(self) -> None: + if self._closed: + if self._permission_cleanup_error is not None: + raise RawCdpError("failed to reset WPT permissions") from self._permission_cleanup_error + return + self._closed = True + if self.task is not None: + self.task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self.task + if self._permissions_changed: + params = {"browserContextId": self.browser_context_id} if self.browser_context_id else {} + try: + await self.client.command("Browser.resetPermissions", params, timeout=5) + except Exception as error: + self._permission_cleanup_error = error + # The engine can exit before cleanup. Still detach when possible and + # always close our own receiver and socket, even after a send fails. + with contextlib.suppress(Exception): + await self.client.command( + "Runtime.removeBinding", {"name": BINDING_NAME}, + session_id=self.session_id, timeout=5, + ) + with contextlib.suppress(Exception): + await self.client.command( + "Target.detachFromTarget", {"sessionId": self.session_id}, timeout=5, + ) + await self.client.close() + if self._permission_cleanup_error is not None: + raise RawCdpError("failed to reset WPT permissions") from self._permission_cleanup_error + + async def command(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + timeout = 10.0 if self.deadline is None else max(0.01, self.deadline - time.perf_counter()) + result = await self.client.command(method, params, session_id=self.session_id, timeout=timeout) + return result.response.get("result", {}) + + async def evaluate(self, context_id: int, expression: str) -> Any: + result = await self.command("Runtime.evaluate", { + "contextId": context_id, "expression": expression, "returnByValue": True, + }) + if result.get("exceptionDetails"): + details = result["exceptionDetails"] + raise ValueError((details.get("exception") or {}).get("description") or details.get("text")) + return result.get("result", {}).get("value") + + async def run(self) -> None: + sequence = 0 + while True: + try: + event = await self.client.wait_for_event( + "Runtime.bindingCalled", after_sequence=sequence, + session_id=self.session_id, + predicate=lambda event: event.get("params", {}).get("name") == BINDING_NAME, + timeout=60, + ) + except RawCdpTimeoutError: + continue + except RawCdpConnectionClosed: + return + sequence = event.sequence + params = event.payload["params"] + try: + request = json.loads(params["payload"]) + request_id = request["id"] + token = request["token"] + if not isinstance(request_id, int) or not isinstance(token, str): + continue + except (ValueError, KeyError, TypeError): + continue + context_id = params["executionContextId"] + error = None + try: + # This lookup also rejects a request whose originating realm or + # pending promise was destroyed before its turn reached us. + await self.evaluate(context_id, f"{STATE_NAME}.check({request_id}, {json.dumps(token)})") + await self.perform(context_id, request) + except Exception as exc: + error = str(exc) + try: + await self.evaluate( + context_id, + f"{STATE_NAME}.finish({request_id}, {json.dumps(token)}, {json.dumps(error)})", + ) + except (RawCdpError, ValueError): + # Navigation can destroy a request's realm during native input. + # Never deliver its reply to the new document's similarly named promise. + pass + + def modifiers(self) -> int: + result = 0 + for keyboard in self.keyboards.values(): + for key in keyboard.values(): + result |= _MODIFIERS.get(key.key, 0) + return result + + async def key(self, source: str, value: str, down: bool) -> None: + keyboard = self.keyboards.setdefault(source, {}) + if not down and value not in keyboard: + return + repeat = value in keyboard + if down: + key = key_description(value, shift=bool(self.modifiers() & 8)) + keyboard[value] = key + else: + key = keyboard.pop(value) + modifiers = self.modifiers() + text = key.text if down and not modifiers & 7 else "" + params: dict[str, Any] = { + "type": "keyUp" if not down else "keyDown" if text else "rawKeyDown", + "key": key.key, "code": key.code, "windowsVirtualKeyCode": key.virtual_code, + "modifiers": modifiers, "autoRepeat": down and repeat, + "location": key.location if key.location != 3 else 0, + "isKeypad": key.location == 3, + } + if text: + params.update(text=text, unmodifiedText=text) + await self.command("Input.dispatchKeyEvent", params) + + async def release_keyboard(self, source: str) -> None: + for value in reversed(list(self.keyboards.get(source, {}))): + await self.key(source, value, False) + self.keyboards.pop(source, None) + + async def send_keys(self, keys: str) -> None: + source = "__send_keys__" + try: + for value in keys: + if value == "\ue000": + await self.release_keyboard(source) + continue + key = key_description(value) + if key.key in _MODIFIERS: + if value not in self.keyboards.get(source, {}): + await self.key(source, value, True) + continue + shift = (value.isascii() and value.isupper()) or value in _UNSHIFTED + own_shift = "\ue008" in self.keyboards.get(source, {}) + if shift and not own_shift: + await self.key(source, "\ue008", True) + elif not shift and own_shift: + await self.key(source, "\ue008", False) + await self.key(source, value, True) + await self.key(source, value, False) + finally: + await self.release_keyboard(source) + + async def mouse(self, pointer: Pointer, event: str, button: int | None = None, **extra: Any) -> None: + button_names = {0: "left", 1: "middle", 2: "right", 3: "back", 4: "forward"} + button_bits = {0: 1, 1: 4, 2: 2, 3: 8, 4: 16} + if button is not None and button not in button_names: + raise ValueError(f"unsupported mouse button: {button}") + if event == "mousePressed": + if button in pointer.buttons: + return + pointer.buttons.append(button) + now = time.perf_counter() + last = pointer.last_click + repeated_click = ( + last and now - last[0] < 0.5 + and (pointer.x, pointer.y, button) == last[1:] + ) + pointer.click_count = pointer.click_count + 1 if repeated_click else 1 + pointer.last_click = (now, pointer.x, pointer.y, button) + elif event == "mouseReleased": + if button not in pointer.buttons: + return + pointer.buttons.remove(button) + await self.command("Input.dispatchMouseEvent", { + "type": event, "x": pointer.x, "y": pointer.y, + "button": button_names.get(button, "none"), + "buttons": sum(button_bits[b] for b in pointer.buttons), + "modifiers": self.modifiers(), "pointerType": pointer.kind, + "clickCount": pointer.click_count if event in {"mousePressed", "mouseReleased"} else 0, + "force": 0.5 if pointer.buttons else 0, + **extra, + }) + + async def reset_actions(self) -> None: + for source in reversed(list(self.keyboards)): + await self.release_keyboard(source) + for pointer in self.pointers.values(): + for button in reversed(list(pointer.buttons)): + await self.mouse(pointer, "mouseReleased", button) + self.pointers.clear() + + async def point( + self, context_id: int, request_id: int, token: str, origin: Any, + x: float, y: float, + ) -> tuple[float, float]: + value = await self.evaluate( + context_id, + f"{STATE_NAME}.point({request_id}, {json.dumps(token)}, {json.dumps(origin)}, {x}, {y})", + ) + if ( + not isinstance(value, list) or len(value) != 2 + or not all(isinstance(n, (int, float)) and math.isfinite(n) for n in value) + ): + raise ValueError("invalid input coordinates") + return value[0], value[1] + + async def perform(self, context_id: int, request: dict[str, Any]) -> None: + request_id = request["id"] + token = request["token"] + kind = request["kind"] + if kind == "set_permission": + await self.set_permission(context_id, request) + elif kind == "send_keys": + await self.evaluate(context_id, f"{STATE_NAME}.focus({request_id}, {json.dumps(token)})") + await self.send_keys(request["keys"]) + elif kind == "click": + x, y = await self.point(context_id, request_id, token, "viewport", request["x"], request["y"]) + pointer = self.pointers.setdefault("__click__", Pointer()) + pointer.x, pointer.y = x, y + await self.mouse(pointer, "mouseMoved") + await self.mouse(pointer, "mousePressed", 0) + await self.mouse(pointer, "mouseReleased", 0) + elif kind == "actions": + sources = request["actions"] + validate_actions(sources) + await self.reset_actions() + for source in sources: + if source["type"] == "pointer": + pointer_type = source.get("parameters", {}).get("pointerType", "mouse") + if pointer_type not in {"mouse", "pen"}: + raise ValueError(f"native WPT pointer type is not supported: {pointer_type}") + self.pointers[source["id"]] = Pointer(kind=pointer_type) + elif source["type"] not in {"key", "none", "wheel"}: + raise ValueError(f"unsupported input source: {source['type']}") + ticks = max((len(source["actions"]) for source in sources), default=0) + for index in range(ticks): + actions = [(source, source["actions"][index]) for source in sources if index < len(source["actions"])] + duration = max((action.get("duration", 0) for _, action in actions), default=0) / 1000 + started = time.perf_counter() + tasks = [ + asyncio.create_task(self.action(context_id, request_id, token, source, action)) + for source, action in actions + ] + try: + await asyncio.gather(*tasks) + finally: + # A failed or canceled tick must not leave another source + # dispatching input after this request has ended. + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.sleep(max(0, duration - (time.perf_counter() - started))) + else: + raise ValueError(f"unsupported native input request: {kind}") + + async def set_permission(self, context_id: int, request: dict[str, Any]) -> None: + descriptor = request.get("descriptor") + state = request.get("state") + if not isinstance(descriptor, dict) or not isinstance(descriptor.get("name"), str) or not descriptor["name"]: + raise ValueError("permission descriptor requires a name") + if not isinstance(state, str) or state not in {"granted", "denied", "prompt"}: + raise ValueError("invalid permission state") + path = await self.evaluate( + context_id, + f"{STATE_NAME}.permissionFramePath({request['id']}, {json.dumps(request['token'])})", + ) + if not isinstance(path, list) or any(type(index) is not int or index < 0 for index in path): + raise ValueError("invalid permission frame path") + tree = (await self.command("Page.getFrameTree")).get("frameTree") + if not isinstance(tree, dict) or not isinstance(tree.get("frame"), dict): + raise ValueError("permission target has no frame tree") + embedding_origin = permission_origin(tree["frame"]) + for index in path: + children = tree.get("childFrames", []) + if not isinstance(children, list) or index >= len(children): + raise ValueError("permission frame is unavailable in this CDP session (detached or out of process)") + tree = children[index] + embedded_origin = permission_origin(tree["frame"]) + if path: + # A session's frame tree can omit out-of-process frames. Verify the + # owner instead of letting an omitted sibling shift the indices. + owner = await self.command("DOM.getFrameOwner", {"frameId": tree["frame"]["id"]}) + resolved = await self.command("DOM.resolveNode", { + "backendNodeId": owner["backendNodeId"], "executionContextId": context_id, + }) + object_id = resolved["object"]["objectId"] + try: + matches = await self.command("Runtime.callFunctionOn", { + "executionContextId": context_id, + "functionDeclaration": ( + "function(owner, id, token) { return " + f"{STATE_NAME}.permissionFrameMatches(id, token, owner); }}" + ), + "arguments": [ + {"objectId": object_id}, {"value": request["id"]}, {"value": request["token"]}, + ], + "returnByValue": True, + }) + if matches.get("exceptionDetails") or matches.get("result", {}).get("value") is not True: + raise ValueError("permission frame does not match the requested Window") + finally: + await self.command("Runtime.releaseObject", {"objectId": object_id}) + # Resolve the retained Window again after the protocol read, so a + # detached or moved frame cannot grant a sibling's origin by index. + current_path = await self.evaluate( + context_id, + f"{STATE_NAME}.permissionFramePath({request['id']}, {json.dumps(request['token'])})", + ) + if current_path != path: + raise ValueError("permission frame changed during the request") + params = { + "permission": descriptor, "setting": state, + "origin": embedding_origin, "embeddedOrigin": embedded_origin, + } + if self.browser_context_id is not None: + params["browserContextId"] = self.browser_context_id + # A command can be applied even if its response is lost. Always reset + # after attempting a mutation, and never attach a page sessionId to it. + self._permissions_changed = True + timeout = 10.0 if self.deadline is None else max(0.01, self.deadline - time.perf_counter()) + await self.client.command("Browser.setPermission", params, timeout=timeout) + + async def action( + self, context_id: int, request_id: int, token: str, + source: dict[str, Any], action: dict[str, Any], + ) -> None: + kind = action["type"] + if kind == "pause": + return + if source["type"] == "key" and kind in {"keyDown", "keyUp"}: + await self.key(source["id"], action["value"], kind == "keyDown") + return + if source["type"] == "wheel" and kind == "scroll": + x, y = await self.point( + context_id, request_id, token, + action.get("origin", "viewport"), action["x"], action["y"], + ) + await self.mouse(Pointer(x=x, y=y), "mouseWheel", deltaX=action["deltaX"], deltaY=action["deltaY"]) + return + if source["type"] != "pointer": + raise ValueError(f"unsupported {source['type']} action: {kind}") + pointer = self.pointers[source["id"]] + extra = {name: action[name] for name in ("tiltX", "tiltY", "twist", "tangentialPressure") if name in action} + if "pressure" in action: + extra["force"] = action["pressure"] + if kind in {"pointerDown", "pointerUp"}: + await self.mouse( + pointer, "mousePressed" if kind == "pointerDown" else "mouseReleased", + action["button"], **extra, + ) + elif kind == "pointerMove": + origin = action.get("origin", "viewport") + if origin == "pointer": + x, y = await self.point( + context_id, request_id, token, origin, + pointer.x + action["x"], pointer.y + action["y"], + ) + else: + x, y = await self.point(context_id, request_id, token, origin, action["x"], action["y"]) + duration = action.get("duration", 0) / 1000 + steps = max(1, math.ceil(duration / 0.016)) + start_x, start_y = pointer.x, pointer.y + started = time.perf_counter() + for step in range(1, steps + 1): + await asyncio.sleep(max(0, started + duration * step / steps - time.perf_counter())) + pointer.x = start_x + (x - start_x) * step / steps + pointer.y = start_y + (y - start_y) * step / steps + await self.mouse(pointer, "mouseMoved", **extra) + else: + raise ValueError(f"unsupported pointer action: {kind}") diff --git a/moli-benchmark/moli_benchmark/wpt_cross/runner.py b/moli-benchmark/moli_benchmark/wpt_cross/runner.py index c031d97558..f927bfbfa0 100644 --- a/moli-benchmark/moli_benchmark/wpt_cross/runner.py +++ b/moli-benchmark/moli_benchmark/wpt_cross/runner.py @@ -28,6 +28,7 @@ from PIL import Image, ImageChops from ..raw_cdp import RawCdpClient, RawCdpError, connect_raw_cdp from .case_set import FuzzyTolerance from .engine import EngineDriver, EngineDriverHandle +from .native_input import NativeInput LAYOUT_VIEWPORT_WIDTH = 800 LAYOUT_VIEWPORT_HEIGHT = 600 @@ -154,6 +155,7 @@ class _AttachedPage: target_id: str session_id: str baseline_target_ids: frozenset[str] | None + native_input: NativeInput | None = None @dataclass(frozen=True) @@ -259,7 +261,14 @@ def _target_ids(infos: list[dict[str, Any]]) -> frozenset[str]: async def _close_target(client: RawCdpClient, target_id: str) -> None: command_id = await client.send("Target.closeTarget", {"targetId": target_id}) - response, _ = await client.recv_until_id(command_id, timeout=5) + try: + response, _ = await client.recv_until_id(command_id, timeout=5) + except RawCdpError: + # Closing a parent or disposing a context can remove a target between + # getTargets and closeTarget. Only ignore the error if it is gone. + if target_id not in _target_ids(await _target_infos(client)): + return + raise success = (response.get("result") or {}).get("success") if success is False: raise RawCdpError(f"Target.closeTarget rejected target {target_id}") @@ -268,6 +277,13 @@ async def _close_target(client: RawCdpClient, target_id: str) -> None: async def _close_page(client: RawCdpClient, page: _AttachedPage) -> None: """Dispose one case's storage context and every target created inside it.""" + input_cleanup_error = None + if page.native_input is not None: + try: + await page.native_input.close() + except Exception as error: + input_cleanup_error = error + try: before = await _target_infos(client) except (RawCdpError, asyncio.TimeoutError): @@ -300,6 +316,8 @@ async def _close_page(client: RawCdpClient, page: _AttachedPage) -> None: try: after = await _target_infos(client) except (RawCdpError, asyncio.TimeoutError): + if input_cleanup_error is not None and page.browser_context_id is None: + raise input_cleanup_error return residual_ids = { @@ -334,12 +352,15 @@ async def _close_page(client: RawCdpClient, page: _AttachedPage) -> None: raise RawCdpError( f"case cleanup left auxiliary targets alive: {sorted(leaked)}" ) + if input_cleanup_error is not None and page.browser_context_id is None: + raise input_cleanup_error async def _attach_page( client: RawCdpClient, *, viewport: Viewport | None = None, + input_endpoint: str | None = None, ) -> _AttachedPage: """Create an isolated BrowserContext + Target for exactly one WPT case.""" @@ -409,6 +430,7 @@ async def _attach_page( target_id=target, session_id=session_id, baseline_target_ids=baseline_target_ids, + native_input=await NativeInput.attach(input_endpoint, target, browser_context_id) if input_endpoint else None, ) except BaseException: if target is not None: @@ -1491,7 +1513,7 @@ async def _run_async( effective_viewport = LAYOUT_VIEWPORT try: - page = await _attach_page(client, viewport=effective_viewport) + page = await _attach_page(client, viewport=effective_viewport, input_endpoint=handle.endpoint) except Exception as error: result.setup_error = f"attach failed: {error}" try: @@ -1546,6 +1568,8 @@ async def _run_async( consecutive_relaunch_failures = 0 relaunch_count += 1 # Swap in a fresh engine and the isolated page for its next case. + if page.native_input is not None: + await page.native_input.close() try: await client.websocket.close() except Exception: @@ -1556,6 +1580,8 @@ async def _run_async( continue try: + if page.native_input is not None: + page.native_input.deadline = time.perf_counter() + timeout_seconds if isinstance(case, ReftestRun): if effective_viewport is None: raise RuntimeError("reftest requires a fixed viewport") @@ -1611,6 +1637,8 @@ async def _run_async( ) break # Tear down current connection + engine, relaunch fresh. + if page.native_input is not None: + await page.native_input.close() try: await client.websocket.close() except Exception: @@ -1665,7 +1693,7 @@ async def _run_async( if case_index + 1 < len(cases): try: await _close_page(client, page) - page = await _attach_page(client, viewport=effective_viewport) + page = await _attach_page(client, viewport=effective_viewport, input_endpoint=handle.endpoint) except Exception: # The completed case keeps its result, but no later case may # inherit an incompletely disposed context or target set. @@ -1745,7 +1773,7 @@ async def _try_relaunch( pass return None try: - new_page = await _attach_page(new_client, viewport=viewport) + new_page = await _attach_page(new_client, viewport=viewport, input_endpoint=new_handle.endpoint) except Exception: try: await new_client.websocket.close() diff --git a/moli-benchmark/moli_benchmark/wpt_cross/server.py b/moli-benchmark/moli_benchmark/wpt_cross/server.py index bb22c9abff..67b1442423 100644 --- a/moli-benchmark/moli_benchmark/wpt_cross/server.py +++ b/moli-benchmark/moli_benchmark/wpt_cross/server.py @@ -435,245 +435,10 @@ def _inject_bench_report_bridge_config(body: bytes, timeout_multiplier: float) - return BENCH_REPORT_BRIDGE_SRC_RE.sub(replace, body) -BENCH_TESTDRIVER_VENDOR_BRIDGE = b"""\ -/* Moli benchmark minimal testdriver-vendor.js bridge. - * - * This is intentionally narrow: it implements enough pointer/key actions for - * static testharness pages to exercise engine behaviour without a WebDriver - * backend. Unsupported automation APIs keep testdriver.js's default failures. - */ +BENCH_TESTDRIVER_VENDOR_BRIDGE = ( + Path(__file__).with_name("testdriver_input.js").read_bytes() + + b"""\ (function() { - var rectTargets = Object.create(null); - function recordRectTarget(element, rect) { - var x = Number.isFinite(rect.x) ? rect.x : rect.left || 0; - var y = Number.isFinite(rect.y) ? rect.y : rect.top || 0; - var centerX = Math.round(x + rect.width / 2); - var centerY = Math.round(y + rect.height / 2); - var key = centerX + ',' + centerY; - rectTargets[key] = { element: element, x: centerX, y: centerY }; - } - if (typeof Element !== 'undefined' && Element.prototype && Element.prototype.getBoundingClientRect) { - var nativeGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function() { - var rect = nativeGetBoundingClientRect.apply(this, arguments); - var x = Number.isFinite(rect.x) ? rect.x : rect.left || 0; - var y = Number.isFinite(rect.y) ? rect.y : rect.top || 0; - recordRectTarget(this, rect); - return { - x: x, - y: y, - left: Number.isFinite(rect.left) ? rect.left : x, - top: Number.isFinite(rect.top) ? rect.top : y, - right: Number.isFinite(rect.right) ? rect.right : x + (rect.width || 0), - bottom: Number.isFinite(rect.bottom) ? rect.bottom : y + (rect.height || 0), - width: rect.width || 0, - height: rect.height || 0, - toJSON: function() { return this; }, - }; - }; - } - if (typeof Element !== 'undefined' && Element.prototype && Element.prototype.getClientRects) { - var nativeGetClientRects = Element.prototype.getClientRects; - Element.prototype.getClientRects = function() { - var rects = nativeGetClientRects.apply(this, arguments); - if (rects && rects.length) { - recordRectTarget(this, rects[0]); - } - return rects; - }; - } - - function eventInit(x, y, button) { - return { - bubbles: true, - cancelable: true, - composed: true, - clientX: x || 0, - clientY: y || 0, - button: button || 0, - buttons: button === undefined ? 0 : 1, - }; - } - - function dispatchPointerEvent(target, type, init) { - var Ctor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent; - try { target.dispatchEvent(new Ctor(type, init)); } catch (e) {} - } - - function dispatchMouseEvent(target, type, init) { - try { target.dispatchEvent(new MouseEvent(type, init)); } catch (e) {} - } - - function focusForUserActivation(element) { - try { - var before = document.activeElement; - if (element && typeof element.focus === 'function') element.focus(); - if (before && before !== document.body && document.activeElement === before && - typeof before.blur === 'function') { - before.blur(); - } - } catch (e) {} - } - - function recordedTargetAt(x, y) { - var key = Math.round(x || 0) + ',' + Math.round(y || 0); - if (rectTargets[key]) { - return rectTargets[key].element; - } - var best = null; - var bestDistance = Infinity; - for (var candidateKey in rectTargets) { - var candidate = rectTargets[candidateKey]; - var dx = candidate.x - (x || 0); - var dy = candidate.y - (y || 0); - var distance = dx * dx + dy * dy; - if (distance < bestDistance) { - best = candidate.element; - bestDistance = distance; - } - } - if (best) { - return best; - } - return null; - } - - function targetAt(x, y) { - var recorded = recordedTargetAt(x, y); - if (recorded) { - return recorded; - } - if (typeof document === 'undefined' || typeof document.elementFromPoint !== 'function') { - return document && document.body; - } - return document.elementFromPoint(x || 0, y || 0) || document.body; - } - - if (typeof Document !== 'undefined' && Document.prototype && - typeof Document.prototype.elementsFromPoint === 'function') { - var nativeElementsFromPoint = Document.prototype.elementsFromPoint; - Document.prototype.elementsFromPoint = function(x, y) { - try { - return nativeElementsFromPoint.apply(this, arguments); - } catch (e) { - var target = recordedTargetAt(x, y); - return target ? [target] : []; - } - }; - } - - function keyName(value) { - if (value === '\\uE004') return 'Tab'; - if (value === '\\uE008') return 'Shift'; - if (value === '\\uE009') return 'Control'; - if (value === '\\uE00A') return 'Alt'; - if (value === '\\uE00C') return 'Escape'; - if (value === '\\uE010') return 'End'; - if (value === '\\uE011') return 'Home'; - if (value === '\\uE012') return 'ArrowLeft'; - if (value === '\\uE013') return 'ArrowUp'; - if (value === '\\uE014') return 'ArrowRight'; - if (value === '\\uE015') return 'ArrowDown'; - return value; - } - - function dispatchKey(target, type, key, modifiers) { - target.dispatchEvent(new KeyboardEvent(type, { - key: key, - altKey: !!modifiers.Alt, - ctrlKey: !!modifiers.Control, - shiftKey: !!modifiers.Shift, - bubbles: true, - cancelable: true, - composed: true, - })); - } - - async function action_sequence(actions, context) { - var pointer = { x: 0, y: 0, target: null, button: 0 }; - var modifiers = { Alt: false, Control: false, Shift: false }; - for (var i = 0; i < actions.length; i++) { - var source = actions[i]; - var sourceActions = Array.isArray(source.actions) ? source.actions : []; - for (var j = 0; j < sourceActions.length; j++) { - var action = sourceActions[j]; - if (!action || action.type === 'pause') { - continue; - } - if (source.type === 'pointer') { - if (action.type === 'pointerMove') { - pointer.x = Number(action.x) || 0; - pointer.y = Number(action.y) || 0; - var nextTarget = targetAt(pointer.x, pointer.y); - var init = eventInit(pointer.x, pointer.y, pointer.button); - if (pointer.target && pointer.target !== nextTarget) { - dispatchPointerEvent(pointer.target, 'pointerout', init); - dispatchMouseEvent(pointer.target, 'mouseout', init); - } - pointer.target = nextTarget; - dispatchPointerEvent(nextTarget, 'pointerover', init); - dispatchMouseEvent(nextTarget, 'mouseover', init); - dispatchPointerEvent(nextTarget, 'pointermove', init); - dispatchMouseEvent(nextTarget, 'mousemove', init); - } else if (action.type === 'pointerDown') { - pointer.button = Number(action.button) || 0; - var downTarget = pointer.target || targetAt(pointer.x, pointer.y); - var downInit = eventInit(pointer.x, pointer.y, pointer.button); - focusForUserActivation(downTarget); - dispatchPointerEvent(downTarget, 'pointerdown', downInit); - dispatchMouseEvent(downTarget, 'mousedown', downInit); - } else if (action.type === 'pointerUp') { - var upTarget = pointer.target || targetAt(pointer.x, pointer.y); - var upInit = eventInit(pointer.x, pointer.y, pointer.button); - dispatchPointerEvent(upTarget, 'pointerup', upInit); - dispatchMouseEvent(upTarget, 'mouseup', upInit); - dispatchMouseEvent(upTarget, 'click', upInit); - pointer.button = 0; - } - } else if (source.type === 'key') { - var key = keyName(action.value); - if (Object.prototype.hasOwnProperty.call(modifiers, key)) { - modifiers[key] = action.type === 'keyDown'; - } - dispatchKey( - document.activeElement || document.body, - action.type === 'keyDown' ? 'keydown' : 'keyup', - key, - modifiers - ); - } - } - } - } - - async function sendKeys(element, keys) { - if ( - element && - typeof element.focus === 'function' && - String(element.localName || '').toLowerCase() !== 'body' - ) { - element.focus(); - } - var modifiers = { Alt: false, Control: false, Shift: false }; - for (var keyValue of String(keys || '')) { - var key = keyName(keyValue); - var target = document.activeElement || element || document.body; - if (Object.prototype.hasOwnProperty.call(modifiers, key)) { - modifiers[key] = true; - dispatchKey(target, 'keydown', key, modifiers); - continue; - } - dispatchKey(target, 'keydown', key, modifiers); - dispatchKey(document.activeElement || target, 'keyup', key, modifiers); - } - for (var modifier in modifiers) { - if (modifiers[modifier]) { - modifiers[modifier] = false; - dispatchKey(document.activeElement || element || document.body, 'keyup', modifier, modifiers); - } - } - } - function normalizedLabelText(value) { return String(value || '').replace(/[\\t\\n\\f\\r ]+/g, ' ').trim(); } @@ -851,29 +616,10 @@ BENCH_TESTDRIVER_VENDOR_BRIDGE = b"""\ window.test_driver_internal = {}; } window.test_driver_internal.in_automation = true; - window.test_driver_internal.action_sequence = action_sequence; - window.test_driver_internal.send_keys = sendKeys; window.test_driver_internal.get_computed_label = getComputedLabel; - window.test_driver_internal.set_permission = async function(params) { - if (params && params.descriptor && params.descriptor.name === 'storage-access') { - return; - } - throw new Error("set_permission() is not implemented by the Moli WPT bridge"); - }; - window.test_driver_internal.click = async function(element) { - var rect = element.getBoundingClientRect(); - var x = Math.round(rect.x + rect.width / 2); - var y = Math.round(rect.y + rect.height / 2); - var init = eventInit(x, y, 0); - focusForUserActivation(element); - dispatchPointerEvent(element, 'pointerdown', init); - dispatchMouseEvent(element, 'mousedown', init); - dispatchPointerEvent(element, 'pointerup', init); - dispatchMouseEvent(element, 'mouseup', init); - dispatchMouseEvent(element, 'click', init); - }; })(); """ +) _TRICKLE_DELAY_RE = re.compile(r"d([0-9]+(?:\.[0-9]+)?)") _GET_TEMPLATE_RE = re.compile(rb"\{\{GET\[([^\]\r\n]+)\]\}\}") diff --git a/moli-benchmark/moli_benchmark/wpt_cross/testdriver_input.js b/moli-benchmark/moli_benchmark/wpt_cross/testdriver_input.js new file mode 100644 index 0000000000..675b170419 --- /dev/null +++ b/moli-benchmark/moli_benchmark/wpt_cross/testdriver_input.js @@ -0,0 +1,146 @@ +/* Native automation for the CDP WPT runner. Engine DOM and event APIs are left intact. */ +(() => { + const pending = new Map(); + const token = String(Math.random()) + ':' + Date.now(); + let serial = 0; + + function request(data, element = null, context = window, elements = []) { + if (typeof window.__bench_wpt_native_input__ !== 'function') { + return Promise.reject(new Error('Native WPT input requires the CDP runner')); + } + const id = ++serial; + return new Promise((resolve, reject) => { + pending.set(id, {resolve, reject, element, context, elements}); + try { + window.__bench_wpt_native_input__(JSON.stringify({...data, id, token})); + } catch (error) { + pending.delete(id); + reject(error); + } + }); + } + + function entry(id, expectedToken) { + if (token !== expectedToken || !pending.has(id)) { + throw new Error('Native input request belongs to an inactive document'); + } + return pending.get(id); + } + + function topPoint(context, x, y) { + if (!Number.isFinite(x) || !Number.isFinite(y) || + x < 0 || y < 0 || x >= context.innerWidth || y >= context.innerHeight) { + throw new Error('move target out of bounds'); + } + while (context !== context.top) { + const frame = context.frameElement; + if (!frame) throw new Error('Native input cannot resolve cross-origin frame coordinates'); + if (context.parent.getComputedStyle(frame).transform !== 'none') { + throw new Error('Native input cannot resolve transformed frame coordinates'); + } + const rect = frame.getBoundingClientRect(); + const scaleX = frame.offsetWidth ? rect.width / frame.offsetWidth : 1; + const scaleY = frame.offsetHeight ? rect.height / frame.offsetHeight : 1; + x = rect.left + (frame.clientLeft + x) * scaleX; + y = rect.top + (frame.clientTop + y) * scaleY; + context = context.parent; + } + if (x < 0 || y < 0 || x >= context.innerWidth || y >= context.innerHeight) { + throw new Error('move target out of bounds'); + } + return [x, y]; + } + + window.__bench_wpt_native_input_state__ = { + check(id, expectedToken) { + entry(id, expectedToken); + return true; + }, + finish(id, expectedToken, error) { + const item = entry(id, expectedToken); + pending.delete(id); + if (error !== null) item.reject(new Error(error)); + else item.resolve(); + }, + permissionFramePath(id, expectedToken) { + let context = entry(id, expectedToken).context; + if (!context || context.closed || context.top !== window.top) { + throw new Error('Permission context is not in the current test target'); + } + const path = []; + while (context !== window.top) { + const parent = context.parent; + let index = 0; + while (index < parent.length && parent[index] !== context) index++; + if (index === parent.length) throw new Error('Permission frame is no longer attached'); + path.unshift(index); + context = parent; + } + return path; + }, + permissionFrameMatches(id, expectedToken, owner) { + return owner.isConnected && owner.contentWindow === entry(id, expectedToken).context; + }, + focus(id, expectedToken) { + const element = entry(id, expectedToken).element; + if (!element || !element.isConnected) throw new Error('stale element reference'); + const root = element.getRootNode(); + const wasFocused = root.activeElement === element; + element.focus(); + if (element === element.ownerDocument.body && root.activeElement !== element) { + const active = root.activeElement; + if (active && typeof active.blur === 'function') active.blur(); + } + if (root.activeElement !== element && element !== element.ownerDocument.body) { + throw new Error('element not interactable'); + } + if (!wasFocused && typeof element.setSelectionRange === 'function') { + try { + const end = String(element.value || '').length; + element.setSelectionRange(end, end); + } catch (error) { /* Non-text controls do not expose a text selection. */ } + } + }, + point(id, expectedToken, origin, x, y) { + const item = entry(id, expectedToken); + let context = item.element ? item.element.ownerDocument.defaultView : item.context; + if (origin === 'pointer') { + return topPoint(context.top, x, y); + } else if (origin && typeof origin === 'object') { + const element = item.elements[origin.element]; + if (!element || !element.isConnected) throw new Error('stale element reference'); + context = element.ownerDocument.defaultView; + const rect = element.getClientRects()[0]; + if (!rect) throw new Error('element not interactable'); + const left = Math.max(0, Math.min(rect.left, rect.right)); + const right = Math.min(context.innerWidth, Math.max(rect.left, rect.right)); + const top = Math.max(0, Math.min(rect.top, rect.bottom)); + const bottom = Math.min(context.innerHeight, Math.max(rect.top, rect.bottom)); + x += Math.floor((left + right) / 2); + y += Math.floor((top + bottom) / 2); + } else if (origin !== 'viewport') { + throw new Error('unsupported input origin'); + } + return topPoint(context, x, y); + }, + }; + + const driver = window.test_driver_internal || (window.test_driver_internal = {}); + driver.in_automation = true; + driver.click = (element, coords) => request({kind: 'click', x: coords.x, y: coords.y}, element); + driver.send_keys = (element, keys) => request({kind: 'send_keys', keys}, element); + driver.set_permission = async (params, context = null) => request({ + kind: 'set_permission', descriptor: params.descriptor, state: params.state, + }, null, context || window); + driver.action_sequence = (actions, context = null) => { + const elements = []; + const serialized = actions.map(source => ({...source, actions: source.actions.map(action => { + if (action.origin && typeof action.origin === 'object') { + const origin = {element: elements.push(action.origin) - 1}; + return {...action, origin}; + } + return action; + })})); + return request({kind: 'actions', actions: serialized}, null, context || window, elements); + }; +})(); diff --git a/moli-benchmark/tests/test_wpt_cross_result_reporting.py b/moli-benchmark/tests/test_wpt_cross_result_reporting.py index ab2a7eee39..e60209f27a 100644 --- a/moli-benchmark/tests/test_wpt_cross_result_reporting.py +++ b/moli-benchmark/tests/test_wpt_cross_result_reporting.py @@ -368,29 +368,7 @@ class WptCrossResultReportingTests(WptCrossTestCase): self.assertIsNone(store.wait_for_final("example.html", timeout=0)) self.assertEqual(store.get("example.html"), {"source": "incremental"}) - def test_testdriver_vendor_bridge_provides_action_sequence(self) -> None: - self.assertIn(b"action_sequence", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"pointerMove", BENCH_TESTDRIVER_VENDOR_BRIDGE) - def test_testdriver_vendor_bridge_preserves_key_modifiers(self) -> None: - self.assertIn(b"altKey: !!modifiers.Alt", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"ctrlKey: !!modifiers.Control", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"shiftKey: !!modifiers.Shift", BENCH_TESTDRIVER_VENDOR_BRIDGE) - def test_testdriver_vendor_bridge_focuses_user_activation_target(self) -> None: - self.assertIn(b"focusForUserActivation", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"document.activeElement === before", BENCH_TESTDRIVER_VENDOR_BRIDGE) - def test_testdriver_vendor_bridge_falls_back_to_recorded_hit_test_targets(self) -> None: - self.assertIn(b"nativeGetClientRects.apply", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"nativeElementsFromPoint.apply", BENCH_TESTDRIVER_VENDOR_BRIDGE) - self.assertIn(b"return target ? [target] : []", BENCH_TESTDRIVER_VENDOR_BRIDGE) - def test_testdriver_vendor_bridge_accepts_storage_access_permission_setup(self) -> None: - self.assertIn( - b"params.descriptor.name === 'storage-access'", - BENCH_TESTDRIVER_VENDOR_BRIDGE, - ) - self.assertIn( - b"set_permission() is not implemented by the Moli WPT bridge", - BENCH_TESTDRIVER_VENDOR_BRIDGE, - ) + def test_testdriver_vendor_bridge_provides_computed_label(self) -> None: self.assertIn(b"get_computed_label", BENCH_TESTDRIVER_VENDOR_BRIDGE) self.assertIn(b"resolveReferenceTarget", BENCH_TESTDRIVER_VENDOR_BRIDGE) diff --git a/moli-benchmark/tests/test_wpt_native_input.py b/moli-benchmark/tests/test_wpt_native_input.py new file mode 100644 index 0000000000..9637d2f6ab --- /dev/null +++ b/moli-benchmark/tests/test_wpt_native_input.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import asyncio +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from moli_benchmark.raw_cdp import RawCdpConnectionClosed, RawCdpError +from moli_benchmark.wpt_cross.__main__ import _case_references_testdriver +from moli_benchmark.wpt_cross.case_set import WptCase +from moli_benchmark.wpt_cross.native_input import NativeInput, Pointer, key_description, permission_origin +from moli_benchmark.wpt_cross.runner import _AttachedPage, _close_page, _close_target + + +class RecordingClient: + def __init__(self) -> None: + self.commands = [] + self.close = AsyncMock() + + async def command(self, method, params=None, **kwargs): + self.commands.append((method, params, kwargs)) + return SimpleNamespace(response={"result": {}}) + + +class NativeInputTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.client = RecordingClient() + self.driver = NativeInput(self.client, "input-session") + + def input_events(self): + return [params for method, params, _ in self.client.commands if method.startswith("Input.")] + + async def test_send_keys_keeps_shift_for_adjacent_capitals_and_releases_it(self): + await self.driver.send_keys("ABc!") + events = self.input_events() + self.assertEqual([event["text"] for event in events if "text" in event], ["A", "B", "c", "!"]) + self.assertEqual( + [(event["type"], event["modifiers"]) for event in events if event["key"] == "Shift"], + [("rawKeyDown", 8), ("keyUp", 0), ("rawKeyDown", 8), ("keyUp", 0)], + ) + self.assertEqual(self.driver.modifiers(), 0) + + async def test_send_keys_null_releases_control_before_typing(self): + await self.driver.send_keys("\ue009a\ue000x\ue00c") + events = self.input_events() + a_down = next(event for event in events if event["key"] == "a" and event["type"] != "keyUp") + self.assertEqual(a_down["type"], "rawKeyDown") + self.assertEqual(a_down["modifiers"], 2) + self.assertNotIn("text", a_down) + self.assertEqual([event["text"] for event in events if "text" in event], ["x"]) + escape = next(event for event in events if event["key"] == "Escape") + self.assertEqual((escape["type"], escape["code"], escape["windowsVirtualKeyCode"]), ("rawKeyDown", "Escape", 27)) + self.assertEqual(self.driver.modifiers(), 0) + + async def test_failed_send_keys_releases_pressed_modifiers(self): + with self.assertRaises(ValueError): + await self.driver.send_keys("\ue009\ue0ff") + self.assertEqual([(event["type"], event["key"]) for event in self.input_events()], [("rawKeyDown", "Control"), ("keyUp", "Control")]) + self.assertEqual(self.driver.modifiers(), 0) + + async def test_repeated_key_down_is_marked_as_repeat(self): + await self.driver.key("keyboard", "x", True) + await self.driver.key("keyboard", "x", True) + await self.driver.key("keyboard", "x", False) + await self.driver.key("keyboard", "x", False) + self.assertEqual([event["autoRepeat"] for event in self.input_events()], [False, True, False]) + + async def test_action_ticks_preserve_modifiers_across_pointer_actions(self): + await self.driver.perform(1, {"id": 1, "token": "document", "kind": "actions", "actions": [ + {"id": "keyboard", "type": "key", "actions": [ + {"type": "keyDown", "value": "\ue008"}, {"type": "pause"}, {"type": "keyUp", "value": "\ue008"}, + ]}, + {"id": "mouse", "type": "pointer", "actions": [ + {"type": "pause"}, {"type": "pointerDown", "button": 0}, {"type": "pointerUp", "button": 0}, + ]}, + ]}) + self.assertEqual([(event["type"], event["modifiers"]) for event in self.input_events()], [ + ("rawKeyDown", 8), ("mousePressed", 8), ("keyUp", 0), ("mouseReleased", 0), + ]) + + async def test_invalid_later_action_has_no_partial_input_side_effects(self): + with self.assertRaises(ValueError): + await self.driver.perform(1, {"id": 1, "token": "document", "kind": "actions", "actions": [ + {"id": "keyboard", "type": "key", "actions": [ + {"type": "keyDown", "value": "x"}, {"type": "invalid"}, + ]}, + ]}) + self.assertEqual(self.input_events(), []) + + async def test_failed_tick_stops_other_actions_before_rejecting(self): + moving = asyncio.Event() + stopped = asyncio.Event() + + async def action(context_id, request_id, token, source, item): + if source["id"] == "moving": + moving.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + else: + await moving.wait() + raise ValueError("move target out of bounds") + + self.driver.action = action + with self.assertRaisesRegex(ValueError, "move target out of bounds"): + await self.driver.perform(1, {"id": 1, "token": "document", "kind": "actions", "actions": [ + {"id": source_id, "type": "pointer", "actions": [ + {"type": "pointerMove", "x": 10, "y": 20}, + ]} + for source_id in ("moving", "failing") + ]}) + self.assertTrue(stopped.is_set()) + + async def test_next_action_sequence_releases_previous_held_keys(self): + await self.driver.key("keyboard", "\ue008", True) + await self.driver.perform(1, {"id": 1, "token": "document", "kind": "actions", "actions": []}) + self.assertEqual([event["type"] for event in self.input_events()], ["rawKeyDown", "keyUp"]) + self.assertEqual(self.driver.modifiers(), 0) + + async def test_pen_move_keeps_pressure_without_reporting_a_button_change(self): + pointer = Pointer(kind="pen", x=10, y=20) + await self.driver.mouse(pointer, "mousePressed", 0, force=0.36) + await self.driver.mouse(pointer, "mouseMoved") + await self.driver.mouse(pointer, "mouseReleased", 0) + events = self.input_events() + self.assertEqual([event["force"] for event in events], [0.36, 0.5, 0]) + self.assertEqual(events[1]["button"], "none") + self.assertEqual(events[1]["buttons"], 1) + + async def test_binding_replies_stay_in_their_originating_realm(self): + def binding(sequence, context, token): + return SimpleNamespace(sequence=sequence, payload={"params": { + "executionContextId": context, + "payload": json.dumps({"id": 1, "token": token, "kind": "click", "x": 1, "y": 1}), + }}) + self.client.wait_for_event = AsyncMock(side_effect=[ + binding(1, 7, "retired"), binding(2, 8, "current"), RawCdpConnectionClosed("closed"), + ]) + evaluations = [] + async def evaluate(context_id, expression): + evaluations.append((context_id, expression)) + if context_id == 7: + raise ValueError("context is retired") + self.driver.evaluate = evaluate + self.driver.perform = AsyncMock() + await self.driver.run() + self.driver.perform.assert_awaited_once() + self.assertEqual(self.driver.perform.await_args.args[0], 8) + self.assertTrue(any(context == 8 and '.finish(1, "current", null)' in expression for context, expression in evaluations)) + self.assertFalse(any(context == 8 and '"retired"' in expression for context, expression in evaluations)) + + async def test_close_cancels_the_binding_listener(self): + self.driver.task = asyncio.create_task(asyncio.Event().wait()) + await self.driver.close() + self.assertTrue(self.driver.task.cancelled()) + self.client.close.assert_awaited_once() + self.assertEqual([command[0] for command in self.client.commands], ["Runtime.removeBinding", "Target.detachFromTarget"]) + await self.driver.close() + self.client.close.assert_awaited_once() + + async def test_close_releases_connection_after_listener_and_cleanup_fail(self): + async def failed_listener(): + raise RuntimeError("engine exited") + + self.driver.task = asyncio.create_task(failed_listener()) + await asyncio.sleep(0) + self.client.command = AsyncMock(side_effect=RawCdpConnectionClosed("closed")) + await self.driver.close() + self.assertEqual(self.client.command.await_count, 2) + self.client.close.assert_awaited_once() + + async def test_permissions_use_the_selected_frame_origin_and_case_browser_context(self): + self.driver.browser_context_id = "case-context" + self.driver.evaluate = AsyncMock(return_value=[1, 0]) + self.driver.command = AsyncMock(side_effect=[{"frameTree": { + "frame": {"securityOrigin": "https://top.test"}, + "childFrames": [ + {"frame": {"securityOrigin": "https://sibling.test"}}, + {"frame": {"securityOrigin": "https://outer.test"}, "childFrames": [ + {"frame": {"id": "child-frame", "securityOrigin": "https://child.test", "url": "about:blank"}}, + ]}, + ], + }}, {"backendNodeId": 42}, {"object": {"objectId": "owner-object"}}, + {"result": {"value": True}}, {}]) + descriptor = {"name": "clipboard-write", "allowWithoutSanitization": True} + await self.driver.perform(9, { + "id": 4, "token": "realm", "kind": "set_permission", "descriptor": descriptor, "state": "denied", + }) + method, params, kwargs = self.client.commands[-1] + self.assertEqual(method, "Browser.setPermission") + self.assertEqual(params, { + "permission": descriptor, "setting": "denied", "browserContextId": "case-context", + "origin": "https://top.test", "embeddedOrigin": "https://child.test", + }) + self.assertNotIn("session_id", kwargs) + self.driver.command.assert_any_await("DOM.getFrameOwner", {"frameId": "child-frame"}) + self.driver.command.assert_any_await("Runtime.releaseObject", {"objectId": "owner-object"}) + self.driver.command.side_effect = None + self.driver.command.return_value = {} + await self.driver.close() + reset = next(command for command in self.client.commands if command[0] == "Browser.resetPermissions") + self.assertEqual(reset[1], {"browserContextId": "case-context"}) + self.assertNotIn("session_id", reset[2]) + + async def test_permissions_reject_malformed_requests_before_any_protocol_mutation(self): + for descriptor, state in [({}, "granted"), ({"name": ""}, "granted"), ({"name": 1}, "granted"), + ({"name": "geolocation"}, "invalid"), ({"name": "geolocation"}, {})]: + with self.subTest(descriptor=descriptor, state=state), self.assertRaises(ValueError): + await self.driver.perform(1, {"id": 1, "token": "realm", "kind": "set_permission", + "descriptor": descriptor, "state": state}) + self.assertEqual(self.client.commands, []) + + async def test_permissions_reject_detached_moved_and_opaque_frames(self): + request = {"id": 1, "token": "realm", "kind": "set_permission", "descriptor": {"name": "geolocation"}, "state": "granted"} + for paths, frame in [ + ([[0]], {"securityOrigin": "https://top.test"}), + ([[], [0]], {"securityOrigin": "https://top.test"}), + ([[]], {"securityOrigin": "null", "url": "https://opaque.test/"}), + ]: + with self.subTest(paths=paths, frame=frame): + self.driver.evaluate = AsyncMock(side_effect=paths) + self.driver.command = AsyncMock(return_value={"frameTree": {"frame": frame}}) + with self.assertRaises(ValueError): + await self.driver.perform(1, request) + self.assertEqual(self.client.commands, []) + + async def test_failed_permission_command_propagates_and_still_schedules_reset(self): + self.driver.evaluate = AsyncMock(return_value=[]) + self.driver.command = AsyncMock(return_value={"frameTree": {"frame": {"securityOrigin": "https://top.test"}}}) + self.client.command = AsyncMock(side_effect=RawCdpError("unsupported permission")) + with self.assertRaisesRegex(RawCdpError, "unsupported permission"): + await self.driver.perform(1, {"id": 1, "token": "realm", "kind": "set_permission", + "descriptor": {"name": "unsupported"}, "state": "granted"}) + with self.assertRaisesRegex(RawCdpError, "reset WPT permissions"): + await self.driver.close() + self.assertEqual([call.args[0] for call in self.client.command.await_args_list], [ + "Browser.setPermission", "Browser.resetPermissions", "Runtime.removeBinding", "Target.detachFromTarget", + ]) + self.client.close.assert_awaited_once() + with self.assertRaisesRegex(RawCdpError, "reset WPT permissions"): + await self.driver.close() + self.client.close.assert_awaited_once() + + async def test_omitted_frame_cannot_grant_permission_to_a_sibling(self): + self.driver.evaluate = AsyncMock(return_value=[0]) + self.driver.command = AsyncMock(side_effect=[ + {"frameTree": {"frame": {"securityOrigin": "https://top.test"}, "childFrames": [ + {"frame": {"id": "sibling", "securityOrigin": "https://sibling.test"}}, + ]}}, + {"backendNodeId": 42}, {"object": {"objectId": "sibling-owner"}}, + {"result": {"value": False}}, {}, + ]) + with self.assertRaisesRegex(ValueError, "does not match"): + await self.driver.perform(1, {"id": 1, "token": "realm", "kind": "set_permission", + "descriptor": {"name": "geolocation"}, "state": "granted"}) + self.assertEqual(self.client.commands, []) + self.driver.command.assert_awaited_with("Runtime.releaseObject", {"objectId": "sibling-owner"}) + + async def test_failed_permission_cleanup_still_disposes_case_targets(self): + class PageClient: + def __init__(self): + self.commands = [] + + async def send(self, method, params=None, **kwargs): + self.commands.append((method, params)) + return len(self.commands) + + async def recv_until_id(self, command_id, **kwargs): + method, _ = self.commands[command_id - 1] + result = {"targetInfos": []} if method == "Target.getTargets" else {"success": True} + return {"result": result}, [] + + for browser_context_id in (None, "isolated-context"): + with self.subTest(browser_context_id=browser_context_id): + client = PageClient() + native = SimpleNamespace(close=AsyncMock(side_effect=RawCdpError("reset failed"))) + page = _AttachedPage(browser_context_id, "test-target", "test-session", frozenset(), native) + if browser_context_id is None: + with self.assertRaisesRegex(RawCdpError, "reset failed"): + await _close_page(client, page) + self.assertIn(("Target.closeTarget", {"targetId": "test-target"}), client.commands) + else: + await _close_page(client, page) + self.assertIn(("Target.disposeBrowserContext", {"browserContextId": browser_context_id}), client.commands) + + + async def test_target_cleanup_accepts_a_concurrently_closed_target(self): + client = SimpleNamespace( + send=AsyncMock(side_effect=[1, 2]), + recv_until_id=AsyncMock(side_effect=[ + RawCdpError("No target with given id found"), + ({"result": {"targetInfos": [{"targetId": "unrelated"}]}}, []), + ]), + ) + await _close_target(client, "case-target") + + async def test_target_cleanup_preserves_errors_for_a_live_target(self): + client = SimpleNamespace( + send=AsyncMock(side_effect=[1, 2]), + recv_until_id=AsyncMock(side_effect=[ + RawCdpError("close rejected"), + ({"result": {"targetInfos": [{"targetId": "case-target"}]}}, []), + ]), + ) + with self.assertRaisesRegex(RawCdpError, "close rejected"): + await _close_target(client, "case-target") + + +class NativeInputSelectionTests(unittest.TestCase): + def test_permission_origins_keep_inherited_and_opaque_origin_semantics(self): + self.assertEqual(permission_origin({"url": "about:blank", "securityOrigin": "https://parent.test:8443"}), "https://parent.test:8443") + self.assertEqual(permission_origin({"url": "https://top.test/path?q=1"}), "https://top.test") + for origin in ("null", "", "file://", "https://user:password@top.test"): + with self.subTest(origin=origin), self.assertRaises(ValueError): + permission_origin({"url": "https://top.test", "securityOrigin": origin}) + + def test_testdriver_detection_in_html_and_generated_variants(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "input.html").write_text('') + (root / "keys.any.js").write_text('// META: script=/resources/testdriver.js\n') + (root / "plain.html").write_text('') + self.assertTrue(_case_references_testdriver(root, WptCase("input.html?variant"))) + self.assertTrue(_case_references_testdriver(root, WptCase("keys.any.html?variant"))) + self.assertFalse(_case_references_testdriver(root, WptCase("plain.html"))) + self.assertFalse(_case_references_testdriver(root, WptCase("../input.html"))) + + def test_special_keys_preserve_physical_location(self): + self.assertEqual((key_description("\ue007").code, key_description("\ue007").location), ("NumpadEnter", 3)) + self.assertEqual((key_description("\ue050").code, key_description("\ue050").location), ("ShiftRight", 2))