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