diff --git a/py/selenium/webdriver/common/bidi/input.py b/py/selenium/webdriver/common/bidi/input.py new file mode 100644 index 0000000000000..111a4878c5f2a --- /dev/null +++ b/py/selenium/webdriver/common/bidi/input.py @@ -0,0 +1,474 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import math +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union + +from selenium.webdriver.common.bidi.common import command_builder +from selenium.webdriver.common.bidi.session import Session + + +class PointerType: + """Represents the possible pointer types.""" + + MOUSE = "mouse" + PEN = "pen" + TOUCH = "touch" + + VALID_TYPES = {MOUSE, PEN, TOUCH} + + +class Origin: + """Represents the possible origin types.""" + + VIEWPORT = "viewport" + POINTER = "pointer" + + +@dataclass +class ElementOrigin: + """Represents an element origin for input actions.""" + + type: str + element: dict + + def __init__(self, element_reference: dict): + self.type = "element" + self.element = element_reference + + def to_dict(self) -> dict: + """Convert the ElementOrigin to a dictionary.""" + return {"type": self.type, "element": self.element} + + +@dataclass +class PointerParameters: + """Represents pointer parameters for pointer actions.""" + + pointer_type: str = PointerType.MOUSE + + def __post_init__(self): + if self.pointer_type not in PointerType.VALID_TYPES: + raise ValueError(f"Invalid pointer type: {self.pointer_type}. Must be one of {PointerType.VALID_TYPES}") + + def to_dict(self) -> dict: + """Convert the PointerParameters to a dictionary.""" + return {"pointerType": self.pointer_type} + + +@dataclass +class PointerCommonProperties: + """Common properties for pointer actions.""" + + width: int = 1 + height: int = 1 + pressure: float = 0.0 + tangential_pressure: float = 0.0 + twist: int = 0 + altitude_angle: float = 0.0 + azimuth_angle: float = 0.0 + + def __post_init__(self): + if self.width < 1: + raise ValueError("width must be at least 1") + if self.height < 1: + raise ValueError("height must be at least 1") + if not (0.0 <= self.pressure <= 1.0): + raise ValueError("pressure must be between 0.0 and 1.0") + if not (0.0 <= self.tangential_pressure <= 1.0): + raise ValueError("tangential_pressure must be between 0.0 and 1.0") + if not (0 <= self.twist <= 359): + raise ValueError("twist must be between 0 and 359") + if not (0.0 <= self.altitude_angle <= math.pi / 2): + raise ValueError("altitude_angle must be between 0.0 and π/2") + if not (0.0 <= self.azimuth_angle <= 2 * math.pi): + raise ValueError("azimuth_angle must be between 0.0 and 2π") + + def to_dict(self) -> dict: + """Convert the PointerCommonProperties to a dictionary.""" + result: Dict[str, Any] = {} + if self.width != 1: + result["width"] = self.width + if self.height != 1: + result["height"] = self.height + if self.pressure != 0.0: + result["pressure"] = self.pressure + if self.tangential_pressure != 0.0: + result["tangentialPressure"] = self.tangential_pressure + if self.twist != 0: + result["twist"] = self.twist + if self.altitude_angle != 0.0: + result["altitudeAngle"] = self.altitude_angle + if self.azimuth_angle != 0.0: + result["azimuthAngle"] = self.azimuth_angle + return result + + +# Action classes +@dataclass +class PauseAction: + """Represents a pause action.""" + + duration: Optional[int] = None + + @property + def type(self) -> str: + return "pause" + + def to_dict(self) -> dict: + """Convert the PauseAction to a dictionary.""" + result: Dict[str, Any] = {"type": self.type} + if self.duration is not None: + result["duration"] = self.duration + return result + + +@dataclass +class KeyDownAction: + """Represents a key down action.""" + + value: str = "" + + @property + def type(self) -> str: + return "keyDown" + + def to_dict(self) -> dict: + """Convert the KeyDownAction to a dictionary.""" + return {"type": self.type, "value": self.value} + + +@dataclass +class KeyUpAction: + """Represents a key up action.""" + + value: str = "" + + @property + def type(self) -> str: + return "keyUp" + + def to_dict(self) -> dict: + """Convert the KeyUpAction to a dictionary.""" + return {"type": self.type, "value": self.value} + + +@dataclass +class PointerDownAction: + """Represents a pointer down action.""" + + button: int = 0 + properties: Optional[PointerCommonProperties] = None + + @property + def type(self) -> str: + return "pointerDown" + + def to_dict(self) -> dict: + """Convert the PointerDownAction to a dictionary.""" + result: Dict[str, Any] = {"type": self.type, "button": self.button} + if self.properties: + result.update(self.properties.to_dict()) + return result + + +@dataclass +class PointerUpAction: + """Represents a pointer up action.""" + + button: int = 0 + + @property + def type(self) -> str: + return "pointerUp" + + def to_dict(self) -> dict: + """Convert the PointerUpAction to a dictionary.""" + return {"type": self.type, "button": self.button} + + +@dataclass +class PointerMoveAction: + """Represents a pointer move action.""" + + x: float = 0 + y: float = 0 + duration: Optional[int] = None + origin: Optional[Union[str, ElementOrigin]] = None + properties: Optional[PointerCommonProperties] = None + + @property + def type(self) -> str: + return "pointerMove" + + def to_dict(self) -> dict: + """Convert the PointerMoveAction to a dictionary.""" + result: Dict[str, Any] = {"type": self.type, "x": self.x, "y": self.y} + if self.duration is not None: + result["duration"] = self.duration + if self.origin is not None: + if isinstance(self.origin, ElementOrigin): + result["origin"] = self.origin.to_dict() + else: + result["origin"] = self.origin + if self.properties: + result.update(self.properties.to_dict()) + return result + + +@dataclass +class WheelScrollAction: + """Represents a wheel scroll action.""" + + x: int = 0 + y: int = 0 + delta_x: int = 0 + delta_y: int = 0 + duration: Optional[int] = None + origin: Optional[Union[str, ElementOrigin]] = Origin.VIEWPORT + + @property + def type(self) -> str: + return "scroll" + + def to_dict(self) -> dict: + """Convert the WheelScrollAction to a dictionary.""" + result: Dict[str, Any] = { + "type": self.type, + "x": self.x, + "y": self.y, + "deltaX": self.delta_x, + "deltaY": self.delta_y, + } + if self.duration is not None: + result["duration"] = self.duration + if self.origin is not None: + if isinstance(self.origin, ElementOrigin): + result["origin"] = self.origin.to_dict() + else: + result["origin"] = self.origin + return result + + +# Source Actions +@dataclass +class NoneSourceActions: + """Represents a sequence of none actions.""" + + id: str = "" + actions: List[PauseAction] = field(default_factory=list) + + @property + def type(self) -> str: + return "none" + + def to_dict(self) -> dict: + """Convert the NoneSourceActions to a dictionary.""" + return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]} + + +@dataclass +class KeySourceActions: + """Represents a sequence of key actions.""" + + id: str = "" + actions: List[Union[PauseAction, KeyDownAction, KeyUpAction]] = field(default_factory=list) + + @property + def type(self) -> str: + return "key" + + def to_dict(self) -> dict: + """Convert the KeySourceActions to a dictionary.""" + return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]} + + +@dataclass +class PointerSourceActions: + """Represents a sequence of pointer actions.""" + + id: str = "" + parameters: Optional[PointerParameters] = None + actions: List[Union[PauseAction, PointerDownAction, PointerUpAction, PointerMoveAction]] = field( + default_factory=list + ) + + def __post_init__(self): + if self.parameters is None: + self.parameters = PointerParameters() + + @property + def type(self) -> str: + return "pointer" + + def to_dict(self) -> dict: + """Convert the PointerSourceActions to a dictionary.""" + result: Dict[str, Any] = { + "type": self.type, + "id": self.id, + "actions": [action.to_dict() for action in self.actions], + } + if self.parameters: + result["parameters"] = self.parameters.to_dict() + return result + + +@dataclass +class WheelSourceActions: + """Represents a sequence of wheel actions.""" + + id: str = "" + actions: List[Union[PauseAction, WheelScrollAction]] = field(default_factory=list) + + @property + def type(self) -> str: + return "wheel" + + def to_dict(self) -> dict: + """Convert the WheelSourceActions to a dictionary.""" + return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]} + + +@dataclass +class FileDialogInfo: + """Represents file dialog information from input.fileDialogOpened event.""" + + context: str + multiple: bool + element: Optional[dict] = None + + @classmethod + def from_dict(cls, data: dict) -> "FileDialogInfo": + """Creates a FileDialogInfo instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the file dialog information. + + Returns: + ------- + FileDialogInfo: A new instance of FileDialogInfo. + """ + return cls(context=data["context"], multiple=data["multiple"], element=data.get("element")) + + +# Event Class +class FileDialogOpened: + """Event class for input.fileDialogOpened event.""" + + event_class = "input.fileDialogOpened" + + @classmethod + def from_json(cls, json): + """Create FileDialogInfo from JSON data.""" + return FileDialogInfo.from_dict(json) + + +class Input: + """ + BiDi implementation of the input module. + """ + + def __init__(self, conn): + self.conn = conn + self.subscriptions = {} + self.callbacks = {} + + def perform_actions( + self, + context: str, + actions: List[Union[NoneSourceActions, KeySourceActions, PointerSourceActions, WheelSourceActions]], + ) -> None: + """Performs a sequence of user input actions. + + Parameters: + ----------- + context: The browsing context ID where actions should be performed. + actions: A list of source actions to perform. + """ + params = {"context": context, "actions": [action.to_dict() for action in actions]} + self.conn.execute(command_builder("input.performActions", params)) + + def release_actions(self, context: str) -> None: + """Releases all input state for the given context. + + Parameters: + ----------- + context: The browsing context ID to release actions for. + """ + params = {"context": context} + self.conn.execute(command_builder("input.releaseActions", params)) + + def set_files(self, context: str, element: dict, files: List[str]) -> None: + """Sets files for a file input element. + + Parameters: + ----------- + context: The browsing context ID. + element: The element reference (script.SharedReference). + files: A list of file paths to set. + """ + params = {"context": context, "element": element, "files": files} + self.conn.execute(command_builder("input.setFiles", params)) + + def add_file_dialog_handler(self, handler): + """Add a handler for file dialog opened events. + + Parameters: + ----------- + handler: Callback function that takes a FileDialogInfo object. + + Returns: + -------- + int: Callback ID for removing the handler later. + """ + # Subscribe to the event if not already subscribed + if FileDialogOpened.event_class not in self.subscriptions: + session = Session(self.conn) + self.conn.execute(session.subscribe(FileDialogOpened.event_class)) + self.subscriptions[FileDialogOpened.event_class] = [] + + # Add callback - the callback receives the parsed FileDialogInfo directly + callback_id = self.conn.add_callback(FileDialogOpened, handler) + + self.subscriptions[FileDialogOpened.event_class].append(callback_id) + self.callbacks[callback_id] = handler + + return callback_id + + def remove_file_dialog_handler(self, callback_id: int) -> None: + """Remove a file dialog handler. + + Parameters: + ----------- + callback_id: The callback ID returned by add_file_dialog_handler. + """ + if callback_id in self.callbacks: + del self.callbacks[callback_id] + + if FileDialogOpened.event_class in self.subscriptions: + if callback_id in self.subscriptions[FileDialogOpened.event_class]: + self.subscriptions[FileDialogOpened.event_class].remove(callback_id) + + # If no more callbacks for this event, unsubscribe + if not self.subscriptions[FileDialogOpened.event_class]: + session = Session(self.conn) + self.conn.execute(session.unsubscribe(FileDialogOpened.event_class)) + del self.subscriptions[FileDialogOpened.event_class] + + self.conn.remove_callback(FileDialogOpened, callback_id) diff --git a/py/selenium/webdriver/common/bidi/script.py b/py/selenium/webdriver/common/bidi/script.py index 74b8a3568ac3a..50e93e18288a7 100644 --- a/py/selenium/webdriver/common/bidi/script.py +++ b/py/selenium/webdriver/common/bidi/script.py @@ -319,7 +319,7 @@ def execute(self, script: str, *args) -> dict: ) if result.type == "success": - return result.result + return result.result if result.result is not None else {} else: error_message = "Error while executing script" if result.exception_details: diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 518c929811815..0fbecb4d14251 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -41,6 +41,7 @@ from selenium.webdriver.common.bidi.browser import Browser from selenium.webdriver.common.bidi.browsing_context import BrowsingContext from selenium.webdriver.common.bidi.emulation import Emulation +from selenium.webdriver.common.bidi.input import Input from selenium.webdriver.common.bidi.network import Network from selenium.webdriver.common.bidi.permissions import Permissions from selenium.webdriver.common.bidi.script import Script @@ -272,6 +273,7 @@ def __init__( self._webextension = None self._permissions = None self._emulation = None + self._input = None self._devtools = None def __repr__(self): @@ -1414,6 +1416,29 @@ def emulation(self): return self._emulation + @property + def input(self): + """Returns an input module object for BiDi input commands. + + Returns: + -------- + Input: an object containing access to BiDi input commands. + + Examples: + --------- + >>> from selenium.webdriver.common.bidi.input import KeySourceActions, KeyDownAction, KeyUpAction + >>> key_actions = KeySourceActions(id="keyboard", actions=[KeyDownAction(value="a"), KeyUpAction(value="a")]) + >>> driver.input.perform_actions(driver.current_window_handle, [key_actions]) + >>> driver.input.release_actions(driver.current_window_handle) + """ + if not self._websocket_connection: + self._start_bidi() + + if self._input is None: + self._input = Input(self._websocket_connection) + + return self._input + def _get_cdp_details(self): import json diff --git a/py/test/selenium/webdriver/common/bidi_input_tests.py b/py/test/selenium/webdriver/common/bidi_input_tests.py new file mode 100644 index 0000000000000..ecbe0bddd4f73 --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi_input_tests.py @@ -0,0 +1,415 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import os +import tempfile +import time + +import pytest + +from selenium.webdriver.common.bidi.input import ( + ElementOrigin, + FileDialogInfo, + KeyDownAction, + KeySourceActions, + KeyUpAction, + Origin, + PauseAction, + PointerCommonProperties, + PointerDownAction, + PointerMoveAction, + PointerParameters, + PointerSourceActions, + PointerType, + PointerUpAction, + WheelScrollAction, + WheelSourceActions, +) +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + + +def test_input_initialized(driver): + """Test that the input module is initialized properly.""" + assert driver.input is not None + + +def test_basic_key_input(driver, pages): + """Test basic keyboard input using BiDi.""" + pages.load("single_text_input.html") + + input_element = driver.find_element(By.ID, "textInput") + + # Create keyboard actions to type "hello" + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="h"), + KeyUpAction(value="h"), + KeyDownAction(value="e"), + KeyUpAction(value="e"), + KeyDownAction(value="l"), + KeyUpAction(value="l"), + KeyDownAction(value="l"), + KeyUpAction(value="l"), + KeyDownAction(value="o"), + KeyUpAction(value="o"), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "hello") + assert input_element.get_attribute("value") == "hello" + + +def test_key_input_with_pause(driver, pages): + """Test keyboard input with pause actions.""" + pages.load("single_text_input.html") + + input_element = driver.find_element(By.ID, "textInput") + + # Create keyboard actions with pauses + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="a"), + KeyUpAction(value="a"), + PauseAction(duration=100), + KeyDownAction(value="b"), + KeyUpAction(value="b"), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "ab") + assert input_element.get_attribute("value") == "ab" + + +def test_pointer_click(driver, pages): + """Test basic pointer click using BiDi.""" + pages.load("javascriptPage.html") + + button = driver.find_element(By.ID, "clickField") + + # Get button location + location = button.location + size = button.size + x = location["x"] + size["width"] // 2 + y = location["y"] + size["height"] // 2 + + # Create pointer actions for a click + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=x, y=y), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + assert button.get_attribute("value") == "Clicked" + + +def test_pointer_move_with_element_origin(driver, pages): + """Test pointer move with element origin.""" + pages.load("javascriptPage.html") + + button = driver.find_element(By.ID, "clickField") + + # Get element reference for BiDi + element_id = button.id + element_ref = {"sharedId": element_id} + element_origin = ElementOrigin(element_ref) + + # Create pointer actions with element origin + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=0, y=0, origin=element_origin), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + assert button.get_attribute("value") == "Clicked" + + +def test_pointer_with_common_properties(driver, pages): + """Test pointer actions with common properties.""" + pages.load("javascriptPage.html") + + button = driver.find_element(By.ID, "clickField") + location = button.location + size = button.size + x = location["x"] + size["width"] // 2 + y = location["y"] + size["height"] // 2 + + # Create pointer properties + properties = PointerCommonProperties( + width=2, height=2, pressure=0.5, tangential_pressure=0.0, twist=45, altitude_angle=0.5, azimuth_angle=1.0 + ) + + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=x, y=y, properties=properties), + PointerDownAction(button=0, properties=properties), + PointerUpAction(button=0), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + assert button.get_attribute("value") == "Clicked" + + +def test_wheel_scroll(driver, pages): + """Test wheel scroll actions.""" + # page that can be scrolled + pages.load("scroll3.html") + + # Scroll down + wheel_actions = WheelSourceActions( + id="wheel", actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, origin=Origin.VIEWPORT)] + ) + + driver.input.perform_actions(driver.current_window_handle, [wheel_actions]) + + # Verify the page scrolled by checking scroll position + scroll_y = driver.execute_script("return window.pageYOffset;") + assert scroll_y == 100 + + +def test_combined_input_actions(driver, pages): + """Test combining multiple input sources.""" + pages.load("single_text_input.html") + + input_element = driver.find_element(By.ID, "textInput") + + # First click on the input field, then type + location = input_element.location + size = input_element.size + x = location["x"] + size["width"] // 2 + y = location["y"] + size["height"] // 2 + + # Pointer actions to click + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PauseAction(duration=0), # Sync with keyboard + PointerMoveAction(x=x, y=y), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + + # Keyboard actions to type + key_actions = KeySourceActions( + id="keyboard", + actions=[ + PauseAction(duration=0), # Sync with pointer + # write "test" + KeyDownAction(value="t"), + KeyUpAction(value="t"), + KeyDownAction(value="e"), + KeyUpAction(value="e"), + KeyDownAction(value="s"), + KeyUpAction(value="s"), + KeyDownAction(value="t"), + KeyUpAction(value="t"), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [pointer_actions, key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "test") + assert input_element.get_attribute("value") == "test" + + +def test_set_files(driver, pages): + """Test setting files on file input element.""" + pages.load("formPage.html") + + upload_element = driver.find_element(By.ID, "upload") + assert upload_element.get_attribute("value") == "" + + # Create a temporary file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as temp_file: + temp_file.write("test content") + temp_file_path = temp_file.name + + try: + # Get element reference for BiDi + element_id = upload_element.id + element_ref = {"sharedId": element_id} + + # Set files using BiDi + driver.input.set_files(driver.current_window_handle, element_ref, [temp_file_path]) + + # Verify file was set + value = upload_element.get_attribute("value") + assert os.path.basename(temp_file_path) in value + + finally: + # Clean up temp file + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_set_multiple_files(driver): + """Test setting multiple files on a file input element with 'multiple' attribute using BiDi.""" + driver.get("data:text/html,") + + upload_element = driver.find_element(By.ID, "upload") + + # Create temporary files + temp_files = [] + for i in range(2): + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) + temp_file.write(f"test content {i}") + temp_files.append(temp_file.name) + temp_file.close() + + try: + # Get element reference for BiDi + element_id = upload_element.id + element_ref = {"sharedId": element_id} + + driver.input.set_files(driver.current_window_handle, element_ref, temp_files) + + value = upload_element.get_attribute("value") + assert value != "" + + finally: + # Clean up temp files + for temp_file_path in temp_files: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_release_actions(driver, pages): + """Test releasing input actions.""" + pages.load("single_text_input.html") + + input_element = driver.find_element(By.ID, "textInput") + + # Perform some actions first + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="a"), + # Note: not releasing the key + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [key_actions]) + + # Now release all actions + driver.input.release_actions(driver.current_window_handle) + + # The key should be released now, so typing more should work normally + key_actions2 = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="b"), + KeyUpAction(value="b"), + ], + ) + + driver.input.perform_actions(driver.current_window_handle, [key_actions2]) + + # Should be able to type normally + WebDriverWait(driver, 5).until(lambda d: "b" in input_element.get_attribute("value")) + + +@pytest.mark.parametrize("multiple", [True, False]) +@pytest.mark.xfail_firefox(reason="File dialog handling not implemented in Firefox yet") +def test_file_dialog_event_handler_multiple(driver, multiple): + """Test file dialog event handler with multiple as true and false.""" + file_dialog_events = [] + + def file_dialog_handler(file_dialog_info): + file_dialog_events.append(file_dialog_info) + + # Test event handler registration + handler_id = driver.input.add_file_dialog_handler(file_dialog_handler) + assert handler_id is not None + + driver.get(f"data:text/html,") + + # Use script.evaluate to trigger the file dialog with user activation + driver.script._evaluate( + expression="document.getElementById('upload').click()", + target={"context": driver.current_window_handle}, + await_promise=False, + user_activation=True, + ) + + # Wait for the file dialog event to be triggered + WebDriverWait(driver, 5).until(lambda d: len(file_dialog_events) > 0) + + assert len(file_dialog_events) > 0 + file_dialog_info = file_dialog_events[0] + assert isinstance(file_dialog_info, FileDialogInfo) + assert file_dialog_info.context == driver.current_window_handle + # Check if multiple attribute is set correctly (True, False) + assert file_dialog_info.multiple is multiple + + driver.input.remove_file_dialog_handler(handler_id) + + +@pytest.mark.xfail_firefox(reason="File dialog handling not implemented in Firefox yet") +def test_file_dialog_event_handler_unsubscribe(driver): + """Test file dialog event handler unsubscribe.""" + file_dialog_events = [] + + def file_dialog_handler(file_dialog_info): + file_dialog_events.append(file_dialog_info) + + # Register the handler + handler_id = driver.input.add_file_dialog_handler(file_dialog_handler) + assert handler_id is not None + + # Unsubscribe the handler + driver.input.remove_file_dialog_handler(handler_id) + + driver.get("data:text/html,") + + # Trigger the file dialog + driver.script._evaluate( + expression="document.getElementById('upload').click()", + target={"context": driver.current_window_handle}, + await_promise=False, + user_activation=True, + ) + + # Wait to ensure no events are captured + time.sleep(1) + assert len(file_dialog_events) == 0