mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
fix(wpt): route testdriver input and permissions through CDP
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user