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))