From 222b7adbd9edad1d5ab1ee5398b0e00a7811ac8a Mon Sep 17 00:00:00 2001 From: Maksim Sadym Date: Fri, 19 Jul 2024 14:59:35 +0200 Subject: [PATCH 1/2] squash --- .../webdriver/bidi/subscription.html.ini | 2 + .../webdriver/bidi/subscription.html | 26 ++ lint.ignore | 3 + resources/testdriver.js | 67 ++++ tools/webdriver/webdriver/bidi/protocol.py | 106 ++++++ tools/webdriver/webdriver/protocol.py | 5 + .../wptrunner/executors/asyncactions.py | 30 ++ tools/wptrunner/wptrunner/executors/base.py | 58 ++++ .../wptrunner/executors/executorchrome.py | 6 +- .../wptrunner/executors/executorwebdriver.py | 320 ++++++++++++++++-- .../wptrunner/wptrunner/executors/protocol.py | 83 ++++- tools/wptrunner/wptrunner/testdriver-extra.js | 142 +++++--- 12 files changed, 764 insertions(+), 84 deletions(-) create mode 100644 infrastructure/metadata/infrastructure/webdriver/bidi/subscription.html.ini create mode 100644 infrastructure/webdriver/bidi/subscription.html create mode 100644 tools/webdriver/webdriver/bidi/protocol.py create mode 100644 tools/wptrunner/wptrunner/executors/asyncactions.py diff --git a/infrastructure/metadata/infrastructure/webdriver/bidi/subscription.html.ini b/infrastructure/metadata/infrastructure/webdriver/bidi/subscription.html.ini new file mode 100644 index 00000000000000..c357f765ab9c54 --- /dev/null +++ b/infrastructure/metadata/infrastructure/webdriver/bidi/subscription.html.ini @@ -0,0 +1,2 @@ +disabled: + if product != "chrome": @True diff --git a/infrastructure/webdriver/bidi/subscription.html b/infrastructure/webdriver/bidi/subscription.html new file mode 100644 index 00000000000000..056c2e5f77fb95 --- /dev/null +++ b/infrastructure/webdriver/bidi/subscription.html @@ -0,0 +1,26 @@ + + +Test console log are present + + + + + diff --git a/lint.ignore b/lint.ignore index ca22175f33cf19..62ad931cb83b0f 100644 --- a/lint.ignore +++ b/lint.ignore @@ -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 diff --git a/resources/testdriver.js b/resources/testdriver.js index d079efba8a4a90..985dbb0e4030aa 100644 --- a/resources/testdriver.js +++ b/resources/testdriver.js @@ -49,6 +49,58 @@ * @namespace {test_driver} */ window.test_driver = { + /** + Represents `WebDriver BiDi `_ protocol. + */ + bidi: { + /** + * `log `_ module. + */ + log: { + /** + * `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} + */ + subscribe: async function (params = {}) { + return window.test_driver_internal.bidi.log.entry_added.subscribe(params); + }, + /** + * Add an event listener for the `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 * @@ -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"); diff --git a/tools/webdriver/webdriver/bidi/protocol.py b/tools/webdriver/webdriver/bidi/protocol.py new file mode 100644 index 00000000000000..25c34b91bb23ce --- /dev/null +++ b/tools/webdriver/webdriver/bidi/protocol.py @@ -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) diff --git a/tools/webdriver/webdriver/protocol.py b/tools/webdriver/webdriver/protocol.py index d6c89af22be2eb..5d07047d5c3ad9 100644 --- a/tools/webdriver/webdriver/protocol.py +++ b/tools/webdriver/webdriver/protocol.py @@ -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) diff --git a/tools/wptrunner/wptrunner/executors/asyncactions.py b/tools/wptrunner/wptrunner/executors/asyncactions.py new file mode 100644 index 00000000000000..6b4ee6fe70f4c9 --- /dev/null +++ b/tools/wptrunner/wptrunner/executors/asyncactions.py @@ -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] diff --git a/tools/wptrunner/wptrunner/executors/base.py b/tools/wptrunner/wptrunner/executors/base.py index 2c88f10e0edfc5..7d5ed5cabcae5a 100644 --- a/tools/wptrunner/wptrunner/executors/base.py +++ b/tools/wptrunner/wptrunner/executors/base.py @@ -15,6 +15,7 @@ from . import pytestrunner from .actions import actions +from .asyncactions import async_actions from .protocol import Protocol, WdspecProtocol @@ -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 diff --git a/tools/wptrunner/wptrunner/executors/executorchrome.py b/tools/wptrunner/wptrunner/executors/executorchrome.py index d972a0eea5f3ff..153e2cb72c14c0 100644 --- a/tools/wptrunner/wptrunner/executors/executorchrome.py +++ b/tools/wptrunner/wptrunner/executors/executorchrome.py @@ -16,7 +16,7 @@ from .executorwebdriver import ( WebDriverCrashtestExecutor, WebDriverFedCMProtocolPart, - WebDriverProtocol, + WebDriverBidiProtocol, WebDriverRefTestExecutor, WebDriverRun, WebDriverTestharnessExecutor, @@ -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) ] diff --git a/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/tools/wptrunner/wptrunner/executors/executorwebdriver.py index 4639f404115342..137244cfa69f19 100644 --- a/tools/wptrunner/wptrunner/executors/executorwebdriver.py +++ b/tools/wptrunner/wptrunner/executors/executorwebdriver.py @@ -1,5 +1,6 @@ # mypy: allow-untyped-defs +import asyncio import json import os import socket @@ -9,7 +10,8 @@ import uuid from urllib.parse import urljoin -from .base import (CallbackHandler, +from .base import (AsyncCallbackHandler, + CallbackHandler, CrashtestExecutor, RefTestExecutor, RefTestImplementation, @@ -35,19 +37,30 @@ RPHRegistrationsProtocolPart, FedCMProtocolPart, VirtualSensorProtocolPart, + BidiBrowsingContextProtocolPart, + BidiEventsProtocolPart, + BidiScriptProtocolPart, DevicePostureProtocolPart, StorageProtocolPart, merge_dicts) +from typing import List, Optional, Tuple from webdriver.client import Session -from webdriver import error +from webdriver import error as webdriver_error +from webdriver.bidi import error as webdriver_bidi_error +from webdriver.bidi.protocol import bidi_deserialize here = os.path.dirname(__file__) class WebDriverCallbackHandler(CallbackHandler): - unimplemented_exc = (NotImplementedError, error.UnknownCommandException) - expected_exc = (error.WebDriverException,) + unimplemented_exc = (NotImplementedError, webdriver_error.UnknownCommandException) + expected_exc = (webdriver_error.WebDriverException,) + + +class WebDriverAsyncCallbackHandler(AsyncCallbackHandler): + unimplemented_exc = (NotImplementedError, webdriver_bidi_error.UnknownCommandException) + expected_exc = (webdriver_bidi_error.BidiException,) class WebDriverBaseProtocolPart(BaseProtocolPart): @@ -61,7 +74,7 @@ def execute_script(self, script, asynchronous=False, args=None): def set_timeout(self, timeout): try: self.webdriver.timeouts.script = timeout - except error.WebDriverException: + except webdriver_error.WebDriverException: # workaround https://bugs.chromium.org/p/chromedriver/issues/detail?id=2057 body = {"type": "script", "ms": timeout * 1000} self.webdriver.send_session_command("POST", "timeouts", body) @@ -85,14 +98,14 @@ def wait(self): self.webdriver.execute_async_script("""let callback = arguments[arguments.length - 1]; addEventListener("__test_restart", e => {e.preventDefault(); callback(true)})""") self.webdriver.execute_async_script("") - except (error.TimeoutException, - error.ScriptTimeoutException, - error.JavascriptErrorException): + except (webdriver_error.TimeoutException, + webdriver_error.ScriptTimeoutException, + webdriver_error.JavascriptErrorException): # A JavascriptErrorException will happen when we navigate; # by ignoring it it's possible to reload the test whilst the # harness remains paused pass - except (socket.timeout, error.NoSuchWindowException, error.UnknownErrorException, OSError): + except (socket.timeout, webdriver_error.NoSuchWindowException, webdriver_error.UnknownErrorException, OSError): break except Exception: message = "Uncaught exception in WebDriverBaseProtocolPart.wait:\n" @@ -102,6 +115,110 @@ def wait(self): return False +class WebDriverBidiBrowsingContextProtocolPart(BidiBrowsingContextProtocolPart): + def __init__(self, parent): + super().__init__(parent) + self.webdriver = None + + def setup(self): + self.webdriver = self.parent.webdriver + + async def handle_user_prompt(self, + context: str, + accept: Optional[bool] = None, + user_text: Optional[str] = None) -> None: + await self.webdriver.bidi_session.browsing_context.handle_user_prompt( + context=context, accept=accept, user_text=user_text) + + +class WebDriverBidiEventsProtocolPart(BidiEventsProtocolPart): + _subscriptions: List[Tuple[List[str], Optional[List[str]]]] = [] + + def __init__(self, parent): + super().__init__(parent) + self.webdriver = None + + def setup(self): + self.webdriver = self.parent.webdriver + + async def _contexts_to_top_contexts(self, contexts: Optional[List[str]]) -> Optional[List[str]]: + """Gathers the list of top-level contexts for the given list of contexts.""" + if contexts is None: + # Global subscription. + return None + top_contexts = set() + for context in contexts: + maybe_top_context = await self._get_top_context(context) + if maybe_top_context is not None: + # The context is found. Add its top-level context to the result set. + top_contexts.add(maybe_top_context) + return list(top_contexts) + + async def _get_top_context(self, context: str) -> Optional[str]: + """Returns the top context id for the given context id.""" + # It is done in suboptimal way by calling `getTree` for each parent context until reaches the top context. + # TODO: optimise. Construct the tree once and then traverse it. + get_tree_result = await self.webdriver.bidi_session.browsing_context.get_tree(root=context) + if not get_tree_result: + # The context is not found. Nothing to do. + return None + assert len(get_tree_result) == 1, "The context should be unique." + context_info = get_tree_result[0] + if context_info["parent"] is None: + # The context is top-level. Return its ID. + return context + return await self._get_top_context(context_info["parent"]) + + async def subscribe(self, events, contexts): + self.logger.info("Subscribing to events %s in %s" % (events, contexts)) + # The BiDi subscriptions are done for top context even if the sub-context is provided. We need to get the + # top-level contexts list to handle the scenario when subscription is done for a sub-context which is closed + # afterwards. However, the subscription itself is done for the provided contexts in order to throw in case of + # the sub-context is removed. + top_contexts = await self._contexts_to_top_contexts(contexts) + result = await self.webdriver.bidi_session.session.subscribe(events=events, contexts=contexts) + # The `subscribe` method either raises an exception or adds subscription. The command is atomic, meaning in case + # of exception no subscription is added. + self._subscriptions.append((events, top_contexts)) + return result + + async def unsubscribe_all(self): + self.logger.info("Unsubscribing from all the events") + while self._subscriptions: + events, contexts = self._subscriptions.pop() + self.logger.debug("Unsubscribing from events %s in %s" % (events, contexts)) + try: + await self.webdriver.bidi_session.session.unsubscribe(events=events, contexts=contexts) + except webdriver_bidi_error.NoSuchFrameException: + # The browsing context is already removed. Nothing to do. + pass + except Exception as e: + self.logger.error("Failed to unsubscribe from events %s in %s: %s" % (events, contexts, e)) + # Re-raise the exception to identify regressions. + # TODO: consider to continue the loop in case of the exception. + raise e + + def add_event_listener(self, name, fn): + self.logger.info("adding event listener %s" % name) + return self.webdriver.bidi_session.add_event_listener(name=name, fn=fn) + + +class WebDriverBidiScriptProtocolPart(BidiScriptProtocolPart): + def __init__(self, parent): + super().__init__(parent) + self.webdriver = None + + def setup(self): + self.webdriver = self.parent.webdriver + + async def call_function(self, function_declaration, target, arguments=None): + return await self.webdriver.bidi_session.script.call_function( + function_declaration=function_declaration, + arguments=arguments, + target=target, + await_promise=True) + + class WebDriverTestharnessProtocolPart(TestharnessProtocolPart): def setup(self): self.webdriver = self.parent.webdriver @@ -135,7 +252,7 @@ def _close_window(self, window_handle): try: self.webdriver.window_handle = window_handle self.webdriver.window.close() - except error.NoSuchWindowException: + except webdriver_error.NoSuchWindowException: pass def open_test_window(self, window_id): @@ -195,7 +312,7 @@ def test_window_loaded(self): try: self.webdriver.execute_script(self.window_loaded_script, asynchronous=True) break - except error.JavascriptErrorException: + except webdriver_error.JavascriptErrorException: pass @@ -243,7 +360,7 @@ def get_named_cookie(self, name): self.logger.info("Getting cookie named %s" % name) try: return self.webdriver.send_session_command("GET", "cookie/%s" % name) - except error.NoSuchCookieException: + except webdriver_error.NoSuchCookieException: return None @@ -270,7 +387,7 @@ def setup(self): def send_keys(self, element, keys): try: return element.send_keys(keys) - except error.UnknownErrorException as e: + except webdriver_error.UnknownErrorException as e: # workaround https://bugs.chromium.org/p/chromedriver/issues/detail?id=1999 if (e.http_status != 500 or e.status_code != "unknown error"): @@ -306,8 +423,8 @@ def send_message(self, cmd_id, message_type, status, message=None): def _switch_to_frame(self, index_or_elem): try: self.webdriver.switch_frame(index_or_elem) - except (error.StaleElementReferenceException, - error.NoSuchFrameException) as e: + except (webdriver_error.StaleElementReferenceException, + webdriver_error.NoSuchFrameException) as e: raise ValueError from e def _switch_to_parent_frame(self): @@ -452,6 +569,7 @@ def run_bounce_tracking_mitigations(self): return self.webdriver.send_session_command("DELETE", "storage/run_bounce_tracking_mitigations") class WebDriverProtocol(Protocol): + enable_bidi = False implements = [WebDriverBaseProtocolPart, WebDriverTestharnessProtocolPart, WebDriverSelectorProtocolPart, @@ -497,13 +615,13 @@ def __init__(self, executor, browser, capabilities, **kwargs): self.webdriver = None def connect(self): - """Connect to browser via WebDriver.""" + """Connect to browser via WebDriver and crete a WebDriver session.""" self.logger.debug("Connecting to WebDriver on URL: %s" % self.url) host, port = self.url.split(":")[1].strip("/"), self.url.split(':')[-1].strip("/") capabilities = {"alwaysMatch": self.capabilities} - self.webdriver = Session(host, port, capabilities=capabilities) + self.webdriver = Session(host, port, capabilities=capabilities, enable_bidi=self.enable_bidi) self.webdriver.start() def teardown(self): @@ -528,7 +646,9 @@ def is_alive(self) -> bool: # 5 seconds of extra_timeout we have as maximum to end the test before # the external timeout from testrunner triggers. self.webdriver.send_session_command("GET", "window", timeout=2) - except (OSError, error.WebDriverException): + except (OSError, webdriver_error.WebDriverException, socket.timeout, + webdriver_error.UnknownErrorException, + webdriver_error.InvalidSessionIdException): return False return True @@ -536,11 +656,40 @@ def after_connect(self): self.testharness.load_runner(self.executor.last_environment["protocol"]) +class WebDriverBidiProtocol(WebDriverProtocol): + enable_bidi = True + implements = [WebDriverBidiBrowsingContextProtocolPart, + WebDriverBidiEventsProtocolPart, + WebDriverBidiScriptProtocolPart, + *(part for part in WebDriverProtocol.implements) + ] + + def __init__(self, executor, browser, capabilities, **kwargs): + super().__init__(executor, browser, capabilities, **kwargs) + self.loop = asyncio.new_event_loop() + + def connect(self): + super().connect() + self.loop.run_until_complete(self.webdriver.bidi_session.start(self.loop)) + + def teardown(self): + try: + self.loop.run_until_complete(self.webdriver.bidi_session.end()) + except Exception as e: + message = str(getattr(e, "message", "")) + if message: + message += "\n" + message += traceback.format_exc() + self.logger.debug(message) + self.loop.stop() + super().teardown() + + class WebDriverRun(TimedRunner): def set_timeout(self): try: self.protocol.base.set_timeout(self.timeout + self.extra_timeout) - except error.UnknownErrorException: + except webdriver_error.UnknownErrorException: msg = "Lost WebDriver connection" self.logger.error(msg) return ("INTERNAL-ERROR", msg) @@ -548,14 +697,14 @@ def set_timeout(self): def run_func(self): try: self.result = True, self.func(self.protocol, self.url, self.timeout) - except (error.TimeoutException, error.ScriptTimeoutException): + except (webdriver_error.TimeoutException, webdriver_error.ScriptTimeoutException): self.result = False, ("EXTERNAL-TIMEOUT", None) except socket.timeout: # Checking if the browser is alive below is likely to hang, so mark # this case as a CRASH unconditionally. self.result = False, ("CRASH", None) except Exception as e: - if (isinstance(e, error.WebDriverException) and + if (isinstance(e, webdriver_error.WebDriverException) and e.http_status == 408 and e.status_code == "asynchronous script timeout"): # workaround for https://bugs.chromium.org/p/chromedriver/issues/detail?id=2001 @@ -574,6 +723,7 @@ def run_func(self): class WebDriverTestharnessExecutor(TestharnessExecutor): supports_testdriver = True protocol_cls = WebDriverProtocol + _get_next_message = None def __init__(self, logger, browser, server_config, timeout_multiplier=1, close_after_done=True, capabilities=None, debug_info=None, @@ -588,6 +738,12 @@ def __init__(self, logger, browser, server_config, timeout_multiplier=1, with open(os.path.join(here, "window-loaded.js")) as f: self.window_loaded_script = f.read() + if hasattr(self.protocol, 'bidi_script'): + # If `bidi_script` is available, the messages can be handled via BiDi. + self._get_next_message = self._get_next_message_bidi + else: + self._get_next_message = self._get_next_message_classic + self.close_after_done = close_after_done self.window_id = str(uuid.uuid4()) self.cleanup_after_test = cleanup_after_test @@ -619,6 +775,11 @@ def do_testharness(self, protocol, url, timeout): # went wrong or if cleanup_after_test was False), so clean up here. parent_window = protocol.testharness.close_old_windows() + # If protocol implements `bidi_events`, remove all the existing subscriptions. + if hasattr(protocol, 'bidi_events'): + # Use protocol loop to run the async cleanup. + protocol.loop.run_until_complete(protocol.bidi_events.unsubscribe_all()) + # Now start the test harness protocol.testharness.open_test_window(self.window_id) test_window = protocol.testharness.get_test_window(self.window_id, @@ -629,45 +790,136 @@ def do_testharness(self, protocol, url, timeout): # Wait until about:blank has been loaded protocol.base.execute_script(self.window_loaded_script, asynchronous=True) - handler = WebDriverCallbackHandler(self.logger, protocol, test_window) + # Exceptions occurred outside the main loop. + unexpected_exceptions = [] + + if hasattr(protocol, 'bidi_events'): + # If protocol implements `bidi_events`, forward all the events to test_driver. + async def process_bidi_event(method, params): + try: + self.logger.debug(f"Received bidi event: {method}, {params}") + if hasattr(protocol, 'bidi_browsing_context') and method == "browsingContext.userPromptOpened" and \ + params["context"] == test_window: + # User prompts of the test window are handled separately. In classic implementation, this user + # prompt always causes an exception when `_get_next_message` is called. In BiDi it's not a case, + # as the BiDi protocol allows sending commands even with the user prompt opened. However, the + # user prompt can block the testdriver JS execution and cause the dead loop. To overcome this + # issue, the user prompt of the test window is always dismissed and the test is failing. + await protocol.bidi_browsing_context.handle_user_prompt(params["context"]) + raise Exception("Unexpected user prompt in test window: %s" % params) + else: + protocol.testdriver.send_message(-1, "event", method, json.dumps({ + "params": params, + "method": method})) + except Exception as e: + # As the event listener is async, the exceptions should be added to the list to be processed in the + # main loop. + self.logger.error("BiDi event processing failed: %s" % e) + unexpected_exceptions.append(e) + + protocol.bidi_events.add_event_listener(None, process_bidi_event) + protocol.loop.run_until_complete(protocol.bidi_events.subscribe(['browsingContext.userPromptOpened'], None)) + + # If possible, support async actions. + if hasattr(protocol, 'loop'): + handler = WebDriverAsyncCallbackHandler(self.logger, protocol, test_window, protocol.loop) + else: + handler = WebDriverCallbackHandler(self.logger, protocol, test_window) + protocol.webdriver.url = url while True: - result = protocol.base.execute_script( - self.script_resume, asynchronous=True, args=[strip_server(url)]) + if len(unexpected_exceptions) > 0: + # TODO: what to do if there are more then 1 unexpected exceptions? + raise unexpected_exceptions[0] + + test_driver_message = self._get_next_message(protocol, url, test_window) + self.logger.debug("Receive message from testdriver: %s" % test_driver_message) # As of 2019-03-29, WebDriver does not define expected behavior for # cases where the browser crashes during script execution: # # https://github.com/w3c/webdriver/issues/1308 - if not isinstance(result, list) or len(result) != 3: - is_alive = self.is_alive() + if not isinstance(test_driver_message, list) or len(test_driver_message) != 3: + try: + is_alive = self.is_alive() + except webdriver_error.WebDriverException: + is_alive = False if not is_alive: raise Exception("Browser crashed during script execution.") - # A user prompt created after starting execution of the resume - # script will resolve the script with `null` [1, 2]. In that case, - # cycle this event loop and handle the prompt the next time the - # resume script executes. + # In case of WebDriver Classic, a user prompt created after starting execution of the resume script will + # resolve the script with `null` [1, 2]. In that case, cycle this event loop and handle the prompt the next + # time the resume script executes. # # [1]: Step 5.3 of https://www.w3.org/TR/webdriver/#execute-async-script # [2]: https://www.w3.org/TR/webdriver/#dfn-execute-a-function-body - if result is None: + if test_driver_message is None: continue - done, rv = handler(result) + done, rv = handler(test_driver_message) if done: break - # Attempt to cleanup any leftover windows, if allowed. This is + # If protocol implements `bidi_events`, remove all the existing subscriptions. + if hasattr(protocol, 'bidi_events'): + # Use protocol loop to run the async cleanup. + protocol.loop.run_until_complete(protocol.bidi_events.unsubscribe_all()) + + # Attempt to clean up any leftover windows, if allowed. This is # preferable as it will blame the correct test if something goes wrong # closing windows, but if the user wants to see the test results we # have to leave the window(s) open. if self.cleanup_after_test: protocol.testharness.close_old_windows() + if len(unexpected_exceptions) > 0: + # TODO: what to do if there are more then 1 unexpected exceptions? + raise unexpected_exceptions[0] + return rv + def _get_next_message_classic(self, protocol, url, _): + """ + Get the next message from the test_driver using the classic WebDriver async script execution. This will block + the event loop until the test_driver send a message. + :param window: + """ + return protocol.base.execute_script(self.script_resume, asynchronous=True, args=[strip_server(url)]) + + def _get_next_message_bidi(self, protocol, url, test_window): + """ + Get the next message from the test_driver using async call. This will not block the event loop, which allows for + processing the events from the test_runner to test_driver while waiting for the next test_driver commands. + """ + # As long as we want to be able to use scripts both in bidi and in classic mode, the script should + # be wrapped to some harness to emulate the WebDriver Classic async script execution. The script + # will be provided with the `resolve` delegate, which finishes the execution. After that the + # coroutine is finished as well. + wrapped_script = """async function(...args){ + return new Promise((resolve, reject) => { + args.push(resolve); + (async function(){ + %s + }).apply(null, args); + }) + }""" % self.script_resume + + bidi_url_argument = { + "type": "string", + "value": strip_server(url) + } + + # `run_until_complete` allows processing BiDi events in the same loop while waiting for the next message. + message = protocol.loop.run_until_complete(protocol.bidi_script.call_function( + wrapped_script, target={ + "context": test_window + }, + arguments=[bidi_url_argument])) + # The message is in WebDriver BiDi format. Deserialize it. + deserialized_message = bidi_deserialize(message) + return deserialized_message + class WebDriverRefTestExecutor(RefTestExecutor): protocol_cls = WebDriverProtocol @@ -712,7 +964,7 @@ def do_test(self, test): height_offset = max(height_offset, 0) try: self.protocol.webdriver.window.position = (0, 0) - except error.InvalidArgumentException: + except webdriver_error.InvalidArgumentException: # Safari 12 throws with 0 or 1, treating them as bools; fixed in STP self.protocol.webdriver.window.position = (2, 2) self.protocol.webdriver.window.size = (800 + width_offset, 600 + height_offset) diff --git a/tools/wptrunner/wptrunner/executors/protocol.py b/tools/wptrunner/wptrunner/executors/protocol.py index e74aea2fbac4a8..76408aafc0df3a 100644 --- a/tools/wptrunner/wptrunner/executors/protocol.py +++ b/tools/wptrunner/wptrunner/executors/protocol.py @@ -4,7 +4,8 @@ from http.client import HTTPConnection from abc import ABCMeta, abstractmethod -from typing import ClassVar, List, Type +from typing import Any, Awaitable, Callable, ClassVar, List, Mapping, Optional, Type +from webdriver.bidi.modules.script import Target def merge_dicts(target, source): @@ -203,7 +204,7 @@ def close_old_windows(self, url_protocol): pass @abstractmethod - def get_test_window(self, window_id, parent): + def get_test_window(self, window_id: str, parent: str) -> str: """Get the window handle dorresponding to the window containing the currently active test. @@ -306,7 +307,6 @@ def element(self, element): pass - class AccessibilityProtocolPart(ProtocolPart): """Protocol part for accessibility introspection""" __metaclass__ = ABCMeta @@ -327,6 +327,83 @@ def get_computed_role(self, element): pass +class BidiBrowsingContextProtocolPart(ProtocolPart): + """Protocol part for managing BiDi events""" + __metaclass__ = ABCMeta + name = "bidi_browsing_context" + + @abstractmethod + async def handle_user_prompt(self, + context: str, + accept: Optional[bool] = None, + user_text: Optional[str] = None) -> None: + """ + Allows closing an open prompt. + :param context: The context of the prompt. + :param accept: Whether to accept or dismiss the prompt. + :param user_text: The text to input in the prompt. + """ + pass + + +class BidiEventsProtocolPart(ProtocolPart): + """Protocol part for managing BiDi events""" + __metaclass__ = ABCMeta + name = "bidi_events" + + @abstractmethod + async def subscribe(self, + events: List[str], + contexts: Optional[List[str]]) -> Mapping[str, Any]: + """ + Subscribes to the given events in the given contexts. + :param events: The events to subscribe to. + :param contexts: The contexts to subscribe to. If None, the function will subscribe to all contexts. + """ + pass + + @abstractmethod + async def unsubscribe_all(self): + """Cleans up the subscription state. Removes all the previously added subscriptions.""" + pass + + @abstractmethod + def add_event_listener( + self, + name: Optional[str], + fn: Callable[[str, Mapping[str, Any]], Awaitable[Any]] + ) -> Callable[[], None]: + """Add an event listener. The callback will be called with the event name and the event data. + + :param name: The name of the event to listen for. If None, the function will be called for all events. + :param fn: The function to call when the event is received. + :return: Function to remove the added listener.""" + pass + + +class BidiScriptProtocolPart(ProtocolPart): + """Protocol part for executing BiDi scripts""" + __metaclass__ = ABCMeta + + name = "bidi_script" + + @abstractmethod + async def call_function( + self, + function_declaration: str, + target: Target, + arguments: Optional[List[Mapping[str, Any]]] = None + ) -> Mapping[str, Any]: + """ + Executes the provided script in the given target in asynchronous mode. + + :param str function_declaration: The js source of the function to execute. + :param script.Target target: The target in which to execute the script. + :param list[script.LocalValue] arguments: The arguments to pass to the script. + """ + pass + + class CookiesProtocolPart(ProtocolPart): """Protocol part for managing cookies""" __metaclass__ = ABCMeta diff --git a/tools/wptrunner/wptrunner/testdriver-extra.js b/tools/wptrunner/wptrunner/testdriver-extra.js index 537c3ca9283479..f7b9ae5fdbfc27 100644 --- a/tools/wptrunner/wptrunner/testdriver-extra.js +++ b/tools/wptrunner/wptrunner/testdriver-extra.js @@ -2,6 +2,7 @@ (function() { const pending = new Map(); + const event_target = new EventTarget(); let result = null; let ctx_cmd_id = 0; @@ -39,6 +40,12 @@ if (is_test_context()) { window.__wptrunner_process_next_event(); } + } else if (data.type === "testdriver-event") { + const event_data = JSON.parse(data.message); + const event_name = event_data.method; + const event = new Event(event_name); + event.payload = event_data.params; + event_target.dispatchEvent(event); } }); @@ -125,23 +132,23 @@ return selector; }; - const create_action = function(name, props) { + /** + * Create an action and return a promise that resolves when the action is complete. + * @param name: The name of the action to create. + * @param params: The properties to pass to the action. + * @return {Promise}: A promise that resolves with the action result when the action is complete. + */ + const create_action = function(name, params) { let cmd_id; const action_msg = {type: "action", action: name, - ...props}; - if (action_msg.context) { - action_msg.context = get_window_id(action_msg.context); - } + ...params}; if (is_test_context()) { cmd_id = window.__wptrunner_message_queue.push(action_msg); } else { if (testharness_context === null) { throw new Error("Tried to run in a non-testharness window without a call to set_test_context"); } - if (action_msg.context === null) { - action_msg.context = get_window_id(window); - } cmd_id = ctx_cmd_id++; action_msg.cmd_id = cmd_id; window.test_driver.message_test({type: "testdriver-command", @@ -160,8 +167,55 @@ return pending_promise; }; + /** + * Create an action in a specific context and return a promise that + * resolves when the action is complete. This is required for WebDriver + * Classic actions, as they require a specific context. + * @param name: The name of the action to create. + * @param context: The context in which to run the action. `null` for the + * current window. + * @param params: The properties to pass to the action. + * @return {Promise}: A promise that resolves with the action result + * when the action is complete. + */ + const create_context_action = function (name, context, params) { + const context_params = {...params}; + if (context) { + context_params.context = get_window_id(context); + } + if (context === null && !is_test_context()) { + context_params.context = get_window_id(window); + } + return create_action(name, context_params); + }; + + const subscribe = function (params) { + return create_action("bidi.session.subscribe", { + // Default to subscribing to the window's events. + contexts: [window], + ...params + }); + }; + window.test_driver_internal.in_automation = true; + window.test_driver_internal.bidi.log.entry_added.subscribe = + function (params) { + return subscribe({ + params, + events: ["log.entryAdded"] + }) + }; + + window.test_driver_internal.bidi.log.entry_added.on = function (callback) { + const on_event = (event) => { + callback(event.payload); + }; + event_target.addEventListener("log.entryAdded", on_event); + return () => event_target.removeEventListener("log.entryAdded", + on_event); + }; + window.test_driver_internal.set_test_context = function(context) { if (window.__wptrunner_message_queue) { throw new Error("Tried to set testharness context in a window containing testharness.js"); @@ -172,49 +226,49 @@ window.test_driver_internal.click = function(element) { const selector = get_selector(element); const context = get_context(element); - return create_action("click", {selector, context}); + return create_context_action("click", context, {selector}); }; window.test_driver_internal.delete_all_cookies = function(context=null) { - return create_action("delete_all_cookies", {context}); + return create_context_action("delete_all_cookies", context, {}); }; window.test_driver_internal.get_all_cookies = function(context=null) { - return create_action("get_all_cookies", {context}); + return create_context_action("get_all_cookies", context, {}); }; window.test_driver_internal.get_computed_label = function(element) { const selector = get_selector(element); const context = get_context(element); - return create_action("get_computed_label", {selector, context}); + return create_context_action("get_computed_label", context, {selector}); }; window.test_driver_internal.get_computed_role = function(element) { const selector = get_selector(element); const context = get_context(element); - return create_action("get_computed_role", {selector, context}); + return create_context_action("get_computed_role", context, {selector}); }; window.test_driver_internal.get_named_cookie = function(name, context=null) { - return create_action("get_named_cookie", {name, context}); + return create_context_action("get_named_cookie", context, {name}); }; window.test_driver_internal.minimize_window = function(context=null) { - return create_action("minimize_window", {context}); + return create_context_action("minimize_window", context, {}); }; window.test_driver_internal.set_window_rect = function(rect, context=null) { - return create_action("set_window_rect", {rect, context}); + return create_context_action("set_window_rect", context, {rect}); }; window.test_driver_internal.get_window_rect = function(context=null) { - return create_action("get_window_rect", {context}); + return create_context_action("get_window_rect", context, {}); }; window.test_driver_internal.send_keys = function(element, keys) { const selector = get_selector(element); const context = get_context(element); - return create_action("send_keys", {selector, keys, context}); + return create_context_action("send_keys", context, {selector, keys}); }; window.test_driver_internal.action_sequence = function(actions, context=null) { @@ -233,107 +287,107 @@ } } } - return create_action("action_sequence", {actions, context}); + return create_context_action("action_sequence", context, {actions}); }; window.test_driver_internal.generate_test_report = function(message, context=null) { - return create_action("generate_test_report", {message, context}); + return create_context_action("generate_test_report", context, {message}); }; window.test_driver_internal.set_permission = function(permission_params, context=null) { - return create_action("set_permission", {permission_params, context}); + return create_context_action("set_permission", context, {permission_params}); }; window.test_driver_internal.add_virtual_authenticator = function(config, context=null) { - return create_action("add_virtual_authenticator", {config, context}); + return create_context_action("add_virtual_authenticator", context, {config}); }; window.test_driver_internal.remove_virtual_authenticator = function(authenticator_id, context=null) { - return create_action("remove_virtual_authenticator", {authenticator_id, context}); + return create_context_action("remove_virtual_authenticator", context, {authenticator_id}); }; window.test_driver_internal.add_credential = function(authenticator_id, credential, context=null) { - return create_action("add_credential", {authenticator_id, credential, context}); + return create_context_action("add_credential", context, {authenticator_id, credential}); }; window.test_driver_internal.get_credentials = function(authenticator_id, context=null) { - return create_action("get_credentials", {authenticator_id, context}); + return create_context_action("get_credentials", context, {authenticator_id}); }; window.test_driver_internal.remove_credential = function(authenticator_id, credential_id, context=null) { - return create_action("remove_credential", {authenticator_id, credential_id, context}); + return create_context_action("remove_credential", context, {authenticator_id, credential_id}); }; window.test_driver_internal.remove_all_credentials = function(authenticator_id, context=null) { - return create_action("remove_all_credentials", {authenticator_id, context}); + return create_context_action("remove_all_credentials", context, {authenticator_id}); }; window.test_driver_internal.set_user_verified = function(authenticator_id, uv, context=null) { - return create_action("set_user_verified", {authenticator_id, uv, context}); + return create_context_action("set_user_verified", context, {authenticator_id, uv}); }; window.test_driver_internal.set_spc_transaction_mode = function(mode, context = null) { - return create_action("set_spc_transaction_mode", {mode, context}); + return create_context_action("set_spc_transaction_mode", context, {mode}); }; window.test_driver_internal.set_rph_registration_mode = function(mode, context = null) { - return create_action("set_rph_registration_mode", {mode, context}); + return create_context_action("set_rph_registration_mode", context, {mode}); }; window.test_driver_internal.cancel_fedcm_dialog = function(context = null) { - return create_action("cancel_fedcm_dialog", {context}); + return create_context_action("cancel_fedcm_dialog", context, {}); }; window.test_driver_internal.click_fedcm_dialog_button = function(dialog_button, context = null) { - return create_action("click_fedcm_dialog_button", {dialog_button, context}); + return create_context_action("click_fedcm_dialog_button", context, {dialog_button}); }; window.test_driver_internal.select_fedcm_account = function(account_index, context = null) { - return create_action("select_fedcm_account", {account_index, context}); + return create_context_action("select_fedcm_account", context, {account_index}); }; window.test_driver_internal.get_fedcm_account_list = function(context = null) { - return create_action("get_fedcm_account_list", {context}); + return create_context_action("get_fedcm_account_list", context, {}); }; window.test_driver_internal.get_fedcm_dialog_title = function(context = null) { - return create_action("get_fedcm_dialog_title", {context}); + return create_context_action("get_fedcm_dialog_title", context, {}); }; window.test_driver_internal.get_fedcm_dialog_type = function(context = null) { - return create_action("get_fedcm_dialog_type", {context}); + return create_context_action("get_fedcm_dialog_type", context, {}); }; window.test_driver_internal.set_fedcm_delay_enabled = function(enabled, context = null) { - return create_action("set_fedcm_delay_enabled", {enabled, context}); + return create_context_action("set_fedcm_delay_enabled", context, {enabled}); }; window.test_driver_internal.reset_fedcm_cooldown = function(context = null) { - return create_action("reset_fedcm_cooldown", {context}); + return create_context_action("reset_fedcm_cooldown", context, {}); }; window.test_driver_internal.create_virtual_sensor = function(sensor_type, sensor_params={}, context=null) { - return create_action("create_virtual_sensor", {sensor_type, sensor_params, context}); + return create_context_action("create_virtual_sensor", context, {sensor_type, sensor_params}); }; window.test_driver_internal.update_virtual_sensor = function(sensor_type, reading, context=null) { - return create_action("update_virtual_sensor", {sensor_type, reading, context}); + return create_context_action("update_virtual_sensor", context, {sensor_type, reading}); }; window.test_driver_internal.remove_virtual_sensor = function(sensor_type, context=null) { - return create_action("remove_virtual_sensor", {sensor_type, context}); + return create_context_action("remove_virtual_sensor", context, {sensor_type}); }; window.test_driver_internal.get_virtual_sensor_information = function(sensor_type, context=null) { - return create_action("get_virtual_sensor_information", {sensor_type, context}); + return create_context_action("get_virtual_sensor_information", context, {sensor_type}); }; window.test_driver_internal.set_device_posture = function(posture, context=null) { - return create_action("set_device_posture", {posture, context}); + return create_context_action("set_device_posture", {posture, context}); }; window.test_driver_internal.clear_device_posture = function(context=null) { - return create_action("clear_device_posture", {context}); + return create_context_action("clear_device_posture", {context}); }; window.test_driver_internal.run_bounce_tracking_mitigations = function (context = null) { From b94fcdc846a0abc62f68fd2eb8fa7ade15bc2051 Mon Sep 17 00:00:00 2001 From: Maksim Sadym Date: Mon, 5 Aug 2024 12:31:25 +0200 Subject: [PATCH 2/2] bidi server can handle user prompt --- .../wptrunner/wptrunner/executors/executorwebdriver.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/tools/wptrunner/wptrunner/executors/executorwebdriver.py index 137244cfa69f19..3ecf04df9a1062 100644 --- a/tools/wptrunner/wptrunner/executors/executorwebdriver.py +++ b/tools/wptrunner/wptrunner/executors/executorwebdriver.py @@ -805,7 +805,15 @@ async def process_bidi_event(method, params): # as the BiDi protocol allows sending commands even with the user prompt opened. However, the # user prompt can block the testdriver JS execution and cause the dead loop. To overcome this # issue, the user prompt of the test window is always dismissed and the test is failing. - await protocol.bidi_browsing_context.handle_user_prompt(params["context"]) + try: + await protocol.bidi_browsing_context.handle_user_prompt(params["context"]) + except Exception as e: + if "no such alert" in str(e): + # The user prompt is already dismissed by WebDriver BiDi server. Ignore the exception. + pass + else: + # The exception is unexpected. Re-raising it to handle it in the main loop. + raise e raise Exception("Unexpected user prompt in test window: %s" % params) else: protocol.testdriver.send_message(-1, "event", method, json.dumps({