Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add testdriver support for WebDriver BiDi #45823

Merged
merged 2 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
disabled:
if product != "chrome": @True
26 changes: 26 additions & 0 deletions infrastructure/webdriver/bidi/subscription.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>Test console log are present</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script>
promise_test(async () => {
const some_message = "SOME MESSAGE";
// Subscribe to `log.entryAdded` BiDi events. This will not add a listener to the page.
await test_driver.bidi.log.entry_added.subscribe();
// Add a listener for the log.entryAdded event. This will not subscribe to the event, so the subscription is
// required before. The cleanup is done automatically after the test is finished.
sadym-chromium marked this conversation as resolved.
Show resolved Hide resolved
const log_entry_promise = test_driver.bidi.log.entry_added.once();
// Emit a console.log message.
// Note: Lint rule is disabled in `lint.ignore` file.
console.log(some_message);
// Wait for the log.entryAdded event to be received.
const event = await log_entry_promise;
// Assert the log.entryAdded event has the expected message.
assert_equals(event.args.length, 1);
const event_message = event.args[0];
assert_equals(event_message.value, some_message);
sadym-chromium marked this conversation as resolved.
Show resolved Hide resolved
}, "Assert testdriver can subscribe and receive events");
</script>
3 changes: 3 additions & 0 deletions lint.ignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ CONSOLE: service-workers/service-worker/resources/clients-get-other-origin.html
CONSOLE: webrtc/tools/*
CONSOLE: webaudio/resources/audit.js:41

# Intentional use of console.*
CONSOLE: infrastructure/webdriver/bidi/subscription.html

# use of console in a public library - annotation-model ensures
# it is not actually used
CONSOLE: annotation-model/scripts/ajv.min.js
Expand Down
67 changes: 67 additions & 0 deletions resources/testdriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,58 @@
* @namespace {test_driver}
*/
window.test_driver = {
/**
Represents `WebDriver BiDi <https://w3c.github.io/webdriver-bidi>`_ protocol.
*/
bidi: {
/**
* `log <https://w3c.github.io/webdriver-bidi/#module-log>`_ module.
*/
log: {
/**
* `log.entryAdded <https://w3c.github.io/webdriver-bidi/#event-log-entryAdded>`_ event.
*/
entry_added: {
/**
* Subscribe to the `log.entryAdded` event. This does not
* add actual listeners. To listen to the event, use the
* `on` or `once` methods.
* @param {{contexts?: null | (string | Window)[]}} params - Parameters for the subscription.
* * `contexts`: an array of window proxies or browsing
* context ids to listen to the event. If not provided, the
* event subscription is done for the current window's
* browsing context. `null` for the global subscription.
* @return {Promise<void>}
*/
subscribe: async function (params = {}) {
return window.test_driver_internal.bidi.log.entry_added.subscribe(params);
},
/**
* Add an event listener for the `log.entryAdded
* <https://w3c.github.io/webdriver-bidi/#event-log-entryAdded>`_ event. Make sure `subscribe` is
* called before using this method.
*
* @param callback {function(event): void} - The callback
* to be called when the event is fired.
* @returns {function(): void} - A function to call to
* remove the event listener.
*/
on: function (callback) {
return window.test_driver_internal.bidi.log.entry_added.on(callback);
},
once: function () {
return new Promise(resolve => {
const remove_handler = window.test_driver_internal.bidi.log.entry_added.on(
data => {
resolve(data);
remove_handler();
});
});
},
}
}
},

/**
* Set the context in which testharness.js is loaded
*
Expand Down Expand Up @@ -1101,6 +1153,21 @@
*/
in_automation: false,

bidi: {
log: {
entry_added: {
async subscribe() {
throw new Error(
"bidi.log.entry_added.subscribe is not implemented by testdriver-vendor.js");
},
on() {
throw new Error(
"bidi.log.entry_added.on is not implemented by testdriver-vendor.js");
}
}
}
},

async click(element, coords) {
if (this.in_automation) {
throw new Error("click() is not implemented by testdriver-vendor.js");
Expand Down
106 changes: 106 additions & 0 deletions tools/webdriver/webdriver/bidi/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from abc import ABC
import math
from typing import Any, Dict, Union
from .undefined import UNDEFINED
from ..client import WebElement


class BidiValue(ABC):
"""Represents the non-primitive values received via BiDi."""
protocol_value: Dict[str, Any]
type: str

def __init__(self, protocol_value: Dict[str, Any]):
assert isinstance(protocol_value, dict)
assert isinstance(protocol_value["type"], str)
self.type = protocol_value["type"]
self.protocol_value = protocol_value

def to_classic_protocol_value(self) -> Dict[str, Any]:
"""
Convert the BiDi value to the classic protocol value. Required for
compatibility of the values sent over BiDi transport with the classic
actions.
"""
raise NotImplementedError(
"No conversion to the classic protocol value is implemented.")


class BidiNode(BidiValue):
shared_id: str

def __init__(self, protocol_value: Dict[str, Any]):
super().__init__(protocol_value)
assert self.type == "node"
self.shared_id = self.protocol_value["sharedId"]

def to_classic_protocol_value(self) -> Dict[str, Any]:
return {WebElement.identifier: self.shared_id}


class BidiWindow(BidiValue):
browsing_context: str

def __init__(self, protocol_value: Dict[str, Any]):
super().__init__(protocol_value)
assert self.type == "window"
self.browsing_context = self.protocol_value["value"]["context"]


def bidi_deserialize(bidi_value: Union[str, int, Dict[str, Any]]) -> Any:
"""
Deserialize the BiDi primitive values, lists and objects to the Python
value, keeping non-common data types in BiDi format.
Note: there can be some ambiguity in the deserialized value.
Eg `{window: {context: "abc"}}` can represent a window proxy, or the JS
object `{window: {context: "abc"}}`.
"""
# script.PrimitiveProtocolValue https://w3c.github.io/webdriver-bidi/#type-script-PrimitiveProtocolValue
if isinstance(bidi_value, str):
return bidi_value
if isinstance(bidi_value, int):
return bidi_value
if not isinstance(bidi_value, dict):
raise ValueError("Unexpected bidi value: %s" % bidi_value)
if bidi_value["type"] == "undefined":
return UNDEFINED
if bidi_value["type"] == "null":
return None
if bidi_value["type"] == "string":
return bidi_value["value"]
if bidi_value["type"] == "number":
if bidi_value["value"] == "NaN":
return math.nan
if bidi_value["value"] == "-0":
return -0.0
if bidi_value["value"] == "Infinity":
return math.inf
if bidi_value["value"] == "-Infinity":
return -math.inf
if isinstance(bidi_value["value"], int) or isinstance(bidi_value["value"], float):
return bidi_value["value"]
raise ValueError("Unexpected bidi value: %s" % bidi_value)
if bidi_value["type"] == "boolean":
return bool(bidi_value["value"])
if bidi_value["type"] == "bigint":
# Python handles big integers natively.
return int(bidi_value["value"])
# script.RemoteValue https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue
if bidi_value["type"] == "array":
list_result = []
for item in bidi_value["value"]:
list_result.append(bidi_deserialize(item))
return list_result
if bidi_value["type"] == "object":
dict_result = {}
for item in bidi_value["value"]:
dict_result[bidi_deserialize(item[0])] = bidi_deserialize(item[1])
return dict_result
if bidi_value["type"] == "node":
return BidiNode(bidi_value)
if bidi_value["type"] == "window":
return BidiWindow(bidi_value)
# TODO: do not raise after verified no regressions in the tests.
raise ValueError("Unexpected bidi value: %s" % bidi_value)
# All other types are not deserialized as a generic BidiValue.
# return BidiValue(bidi_value)
5 changes: 5 additions & 0 deletions tools/webdriver/webdriver/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ def default(self, obj):
return {webdriver.ShadowRoot.identifier: obj.id}
elif isinstance(obj, webdriver.WebWindow):
return {webdriver.WebWindow.identifier: obj.id}
# Support for arguments received via BiDi.
# https://github.com/web-platform-tests/rfcs/blob/master/rfcs/testdriver_bidi.md
elif isinstance(obj, webdriver.bidi.protocol.BidiValue):
return obj.to_classic_protocol_value()

return super().default(obj)


Expand Down
30 changes: 30 additions & 0 deletions tools/wptrunner/wptrunner/executors/asyncactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# mypy: allow-untyped-defs

from webdriver.bidi.protocol import BidiWindow


class BidiSessionSubscribeAction:
name = "bidi.session.subscribe"

def __init__(self, logger, protocol):
self.logger = logger
self.protocol = protocol

async def __call__(self, payload):
events = payload["events"]
contexts = None
if payload["contexts"] is not None:
contexts = []
for context in payload["contexts"]:
# Context can be either a browsing context id, or a BiDi serialized window. In the latter case, the
# value is extracted from the serialized object.
if isinstance(context, str):
contexts.append(context)
elif isinstance(context, BidiWindow):
contexts.append(context.browsing_context)
else:
raise ValueError("Unexpected context type: %s" % context)
return await self.protocol.bidi_events.subscribe(events, contexts)


async_actions = [BidiSessionSubscribeAction]
58 changes: 58 additions & 0 deletions tools/wptrunner/wptrunner/executors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from . import pytestrunner
from .actions import actions
from .asyncactions import async_actions
from .protocol import Protocol, WdspecProtocol


Expand Down Expand Up @@ -799,6 +800,63 @@ def _send_message(self, cmd_id, message_type, status, message=None):
self.protocol.testdriver.send_message(cmd_id, message_type, status, message=message)


class AsyncCallbackHandler(CallbackHandler):
"""
Handle synchronous and asynchronous actions. Extends `CallbackHandler` with support of async actions.
"""

def __init__(self, logger, protocol, test_window, loop):
super().__init__(logger, protocol, test_window)
self.loop = loop
self.async_actions = {cls.name: cls(self.logger, self.protocol) for cls in async_actions}

def process_action(self, url, payload):
action = payload["action"]
if action in self.async_actions:
# Schedule async action to be processed in the event loop and return immediately.
self.logger.debug(f"Scheduling async action processing: {action}, {payload}")
self.loop.create_task(self._process_async_action(action, payload))
return False, None
else:
# Fallback to the default action processing, which will fail if the action is not implemented.
self.logger.debug(f"Processing synchronous action: {action}, {payload}")
return super().process_action(url, payload)

async def _process_async_action(self, action, payload):
"""
Process async action and send the result back to the test driver.

This method is analogous to `process_action` but is intended to be used with async actions in a task, so it does
not raise unexpected exceptions. However, the unexpected exceptions are logged and the error message is sent
back to the test driver.
"""
async_action_handler = self.async_actions[action]
cmd_id = payload["id"]
try:
result = await async_action_handler(payload)
except AttributeError as e:
# If we fail to get an attribute from the protocol presumably that's a
# ProtocolPart we don't implement
# AttributeError got an obj property in Python 3.10, for older versions we
# fall back to looking at the error message.
if ((hasattr(e, "obj") and getattr(e, "obj") == self.protocol) or
f"'{self.protocol.__class__.__name__}' object has no attribute" in str(e)):
raise NotImplementedError from e
except self.unimplemented_exc:
self.logger.warning("Action %s not implemented" % action)
self._send_message(cmd_id, "complete", "error", f"Action {action} not implemented")
except self.expected_exc as e:
self.logger.debug(f"Action {action} failed with an expected exception: {e}")
self._send_message(cmd_id, "complete", "error", f"Action {action} failed: {e}")
except Exception as e:
self.logger.warning(f"Action {action} failed with an unexpected exception: {e}")
self._send_message(cmd_id, "complete", "error", f"Unexpected exception: {e}")
else:
self.logger.debug(f"Action {action} completed with result {result}")
return_message = {"result": result}
self._send_message(cmd_id, "complete", "success", json.dumps(return_message))


class ActionContext:
def __init__(self, logger, protocol, context):
self.logger = logger
Expand Down
6 changes: 3 additions & 3 deletions tools/wptrunner/wptrunner/executors/executorchrome.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .executorwebdriver import (
WebDriverCrashtestExecutor,
WebDriverFedCMProtocolPart,
WebDriverProtocol,
WebDriverBidiProtocol,
WebDriverRefTestExecutor,
WebDriverRun,
WebDriverTestharnessExecutor,
Expand Down Expand Up @@ -200,13 +200,13 @@ def execute_cdp_command(self, command, params=None):
body=body)


class ChromeDriverProtocol(WebDriverProtocol):
class ChromeDriverProtocol(WebDriverBidiProtocol):
implements = [
ChromeDriverDevToolsProtocolPart,
ChromeDriverFedCMProtocolPart,
ChromeDriverPrintProtocolPart,
ChromeDriverTestharnessProtocolPart,
*(part for part in WebDriverProtocol.implements
*(part for part in WebDriverBidiProtocol.implements
if part.name != ChromeDriverTestharnessProtocolPart.name and
part.name != ChromeDriverFedCMProtocolPart.name)
]
Expand Down
Loading