fix(wpt): route testdriver input and permissions through CDP

This commit is contained in:
ldm0
2026-10-04 01:04:37 +08:00
parent 82540f8d5c
commit d03b9ea738
8 changed files with 1137 additions and 286 deletions
+9
View File
@@ -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
@@ -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",
@@ -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}")
@@ -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()
@@ -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]+)\]\}\}")
@@ -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);
};
})();
@@ -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)
@@ -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('<script src="/resources/testdriver.js"></script>')
(root / "keys.any.js").write_text('// META: script=/resources/testdriver.js\n')
(root / "plain.html").write_text('<script src="/resources/testharness.js"></script>')
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))