From 40077d127c680d3172798b252dfde93b0653c133 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Mon, 23 Jun 2025 15:14:25 +0530 Subject: [PATCH 1/4] add high level API for script module - `pin`, `unpin` and `execute` --- py/selenium/webdriver/common/bidi/script.py | 97 ++++++++++++++++++++- py/selenium/webdriver/remote/webdriver.py | 2 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/py/selenium/webdriver/common/bidi/script.py b/py/selenium/webdriver/common/bidi/script.py index a5bc234eb1536..ebe71b26145ea 100644 --- a/py/selenium/webdriver/common/bidi/script.py +++ b/py/selenium/webdriver/common/bidi/script.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from typing import Any, Optional +from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.bidi.common import command_builder from .log import LogEntryAdded @@ -238,12 +239,15 @@ class Script: "realm_destroyed": "script.realmDestroyed", } - def __init__(self, conn): + def __init__(self, conn, driver=None): self.conn = conn + self.driver = driver self.log_entry_subscribed = False self.subscriptions = {} self.callbacks = {} + # High-level APIs for SCRIPT module + def add_console_message_handler(self, handler): self._subscribe_to_log_entries() return self.conn.add_callback(LogEntryAdded, self._handle_log_entry("console", handler)) @@ -258,6 +262,97 @@ def remove_console_message_handler(self, id): remove_javascript_error_handler = remove_console_message_handler + def pin(self, script: str) -> str: + """Pins a script to the current browsing context. + + Parameters: + ----------- + script: The script to pin. + + Returns: + ------- + str: The ID of the pinned script. + """ + return self._add_preload_script(script) + + def unpin(self, script_id: str) -> None: + """Unpins a script from the current browsing context. + + Parameters: + ----------- + script_id: The ID of the pinned script to unpin. + """ + self._remove_preload_script(script_id) + + def execute(self, script: str, *args) -> dict: + """Executes a script in the current browsing context. + + Parameters: + ----------- + script: The script function to execute. + *args: Arguments to pass to the script function. + + Returns: + ------- + dict: The result value from the script execution. + + Raises: + ------ + WebDriverException: If the script execution fails. + """ + + if self.driver is None: + raise WebDriverException("Driver reference is required for script execution") + browsing_context_id = self.driver.current_window_handle + + # Convert arguments to the format expected by BiDi call_function (LocalValue Type) + arguments = [] + for arg in args: + arguments.append(self.__convert_to_local_value(arg)) + + target = {"context": browsing_context_id} + + result = self._call_function( + function_declaration=script, await_promise=True, target=target, arguments=arguments if arguments else None + ) + + if result.type == "success": + return result.result + else: + error_message = "Error while executing script" + if result.exception_details: + if "text" in result.exception_details: + error_message += f": {result.exception_details['text']}" + elif "message" in result.exception_details: + error_message += f": {result.exception_details['message']}" + + raise WebDriverException(error_message) + + def __convert_to_local_value(self, value) -> dict: + """ + Converts a Python value to BiDi LocalValue format. + """ + if value is None: + return {"type": "undefined"} + elif isinstance(value, bool): + return {"type": "boolean", "value": value} + elif isinstance(value, (int, float)): + return {"type": "number", "value": value} + elif isinstance(value, str): + return {"type": "string", "value": value} + elif isinstance(value, (list, tuple)): + return {"type": "array", "value": [self.__convert_to_local_value(item) for item in value]} + elif isinstance(value, dict): + return { + "type": "object", + "value": [ + [self.__convert_to_local_value(k), self.__convert_to_local_value(v)] for k, v in value.items() + ], + } + else: + # For other types, convert to string + return {"type": "string", "value": str(value)} + # low-level APIs for script module def _add_preload_script( self, diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 62ce619105186..0cf59bb26df7d 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -1240,7 +1240,7 @@ def script(self): self._start_bidi() if not self._script: - self._script = Script(self._websocket_connection) + self._script = Script(self._websocket_connection, self) return self._script From 2de71775d9907c36bae6a4ec7bdf7c3b3f84158d Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Mon, 23 Jun 2025 15:16:01 +0530 Subject: [PATCH 2/4] add tests --- .../webdriver/common/bidi_script_tests.py | 218 +++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/py/test/selenium/webdriver/common/bidi_script_tests.py b/py/test/selenium/webdriver/common/bidi_script_tests.py index 8677d2dbae396..4a11e280f49e4 100644 --- a/py/test/selenium/webdriver/common/bidi_script_tests.py +++ b/py/test/selenium/webdriver/common/bidi_script_tests.py @@ -60,7 +60,7 @@ def test_logs_console_errors(driver, pages): log_entries = [] def log_error(entry): - if entry.level == "error": + if entry.level == LogLevel.ERROR: log_entries.append(entry) driver.script.add_console_message_handler(log_error) @@ -561,3 +561,219 @@ def test_disown_handles(driver, pages): target={"context": driver.current_window_handle}, arguments=[{"handle": handle}], ) + + +# Tests for high-level SCRIPT API commands + + +def test_pin_script(driver, pages): + """Test pinning a script.""" + function_declaration = "() => { window.pinnedScriptExecuted = 'yes'; }" + + script_id = driver.script.pin(function_declaration) + assert script_id is not None + assert isinstance(script_id, str) + + pages.load("blank.html") + + result = driver.script.execute("() => window.pinnedScriptExecuted") + assert result["value"] == "yes" + + +def test_unpin_script(driver, pages): + """Test unpinning a script.""" + function_declaration = "() => { window.unpinnableScript = 'executed'; }" + + script_id = driver.script.pin(function_declaration) + driver.script.unpin(script_id) + + pages.load("blank.html") + + result = driver.script.execute("() => typeof window.unpinnableScript") + assert result["value"] == "undefined" + + +def test_execute_script_with_undefined_argument(driver, pages): + """Test executing script with undefined argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!==undefined) + throw Error("Argument should be undefined, but was "+arg); + return arg; + }""", + None, + ) + + assert result["type"] == "undefined" + + +def test_execute_script_with_number_argument(driver, pages): + """Test executing script with number argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!==1.4) + throw Error("Argument should be 1.4, but was "+arg); + return arg; + }""", + 1.4, + ) + + assert result["type"] == "number" + assert result["value"] == 1.4 + + +def test_execute_script_with_boolean_argument(driver, pages): + """Test executing script with boolean argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!==true) + throw Error("Argument should be true, but was "+arg); + return arg; + }""", + True, + ) + + assert result["type"] == "boolean" + assert result["value"] is True + + +def test_execute_script_with_string_argument(driver, pages): + """Test executing script with string argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!=="hello world") + throw Error("Argument should be 'hello world', but was "+arg); + return arg; + }""", + "hello world", + ) + + assert result["type"] == "string" + assert result["value"] == "hello world" + + +def test_execute_script_with_array_argument(driver, pages): + """Test executing script with array argument.""" + pages.load("blank.html") + + test_list = [1, 2, 3] + + result = driver.script.execute( + """(arg) => { + if(!(arg instanceof Array)) + throw Error("Argument type should be Array, but was "+ + Object.prototype.toString.call(arg)); + if(arg.length !== 3) + throw Error("Array should have 3 elements, but had "+arg.length); + return arg; + }""", + test_list, + ) + + assert result["type"] == "array" + values = result["value"] + assert len(values) == 3 + + +def test_execute_script_with_multiple_arguments(driver, pages): + """Test executing script with multiple arguments.""" + pages.load("blank.html") + + result = driver.script.execute( + """(a, b, c) => { + if(a !== 1) throw Error("First arg should be 1"); + if(b !== "test") throw Error("Second arg should be 'test'"); + if(c !== true) throw Error("Third arg should be true"); + return a + b.length + (c ? 1 : 0); + }""", + 1, + "test", + True, + ) + + assert result["type"] == "number" + assert result["value"] == 6 # 1 + 4 + 1 + + +def test_execute_script_returns_promise(driver, pages): + """Test executing script that returns a promise.""" + pages.load("blank.html") + + result = driver.script.execute( + """() => { + return Promise.resolve("async result"); + }""", + ) + + assert result["type"] == "string" + assert result["value"] == "async result" + + +def test_execute_script_with_exception(driver, pages): + """Test executing script that throws an exception.""" + pages.load("blank.html") + + from selenium.common.exceptions import WebDriverException + + with pytest.raises(WebDriverException) as exc_info: + driver.script.execute( + """() => { + throw new Error("Test error message"); + }""", + ) + + assert "Test error message" in str(exc_info.value) + + +def test_execute_script_accessing_dom(driver, pages): + """Test executing script that accesses DOM elements.""" + pages.load("formPage.html") + + result = driver.script.execute( + """() => { + return document.title; + }""", + ) + + assert result["type"] == "string" + assert result["value"] == "We Leave From Here" + + +def test_execute_script_with_nested_objects(driver, pages): + """Test executing script with nested object arguments.""" + pages.load("blank.html") + + nested_data = { + "user": { + "name": "John", + "age": 30, + "hobbies": ["reading", "coding"], + }, + "settings": {"theme": "dark", "notifications": True}, + } + + result = driver.script.execute( + """(data) => { + return { + userName: data.user.name, + userAge: data.user.age, + hobbyCount: data.user.hobbies.length, + theme: data.settings.theme + }; + }""", + nested_data, + ) + + assert result["type"] == "object" + value_dict = {k: v["value"] for k, v in result["value"]} + assert value_dict["userName"] == "John" + assert value_dict["userAge"] == 30 + assert value_dict["hobbyCount"] == 2 From 8e72a32f2f978e3c114c6fbed19f5641b2c27e82 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Wed, 25 Jun 2025 11:49:45 +0530 Subject: [PATCH 3/4] add support for more types --- py/selenium/webdriver/common/bidi/script.py | 29 +++++- .../webdriver/common/bidi_script_tests.py | 95 ++++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/py/selenium/webdriver/common/bidi/script.py b/py/selenium/webdriver/common/bidi/script.py index ebe71b26145ea..74b8a3568ac3a 100644 --- a/py/selenium/webdriver/common/bidi/script.py +++ b/py/selenium/webdriver/common/bidi/script.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +import datetime +import math from dataclasses import dataclass from typing import Any, Optional @@ -333,13 +335,38 @@ def __convert_to_local_value(self, value) -> dict: Converts a Python value to BiDi LocalValue format. """ if value is None: - return {"type": "undefined"} + return {"type": "null"} elif isinstance(value, bool): return {"type": "boolean", "value": value} elif isinstance(value, (int, float)): + if isinstance(value, float): + if math.isnan(value): + return {"type": "number", "value": "NaN"} + elif math.isinf(value): + if value > 0: + return {"type": "number", "value": "Infinity"} + else: + return {"type": "number", "value": "-Infinity"} + elif value == 0.0 and math.copysign(1.0, value) < 0: + return {"type": "number", "value": "-0"} + + JS_MAX_SAFE_INTEGER = 9007199254740991 + if isinstance(value, int) and (value > JS_MAX_SAFE_INTEGER or value < -JS_MAX_SAFE_INTEGER): + return {"type": "bigint", "value": str(value)} + return {"type": "number", "value": value} + elif isinstance(value, str): return {"type": "string", "value": value} + elif isinstance(value, datetime.datetime): + # Convert Python datetime to JavaScript Date (ISO 8601 format) + return {"type": "date", "value": value.isoformat() + "Z" if value.tzinfo is None else value.isoformat()} + elif isinstance(value, datetime.date): + # Convert Python date to JavaScript Date + dt = datetime.datetime.combine(value, datetime.time.min).replace(tzinfo=datetime.timezone.utc) + return {"type": "date", "value": dt.isoformat()} + elif isinstance(value, set): + return {"type": "set", "value": [self.__convert_to_local_value(item) for item in value]} elif isinstance(value, (list, tuple)): return {"type": "array", "value": [self.__convert_to_local_value(item) for item in value]} elif isinstance(value, dict): diff --git a/py/test/selenium/webdriver/common/bidi_script_tests.py b/py/test/selenium/webdriver/common/bidi_script_tests.py index 4a11e280f49e4..104ab18303eee 100644 --- a/py/test/selenium/webdriver/common/bidi_script_tests.py +++ b/py/test/selenium/webdriver/common/bidi_script_tests.py @@ -563,7 +563,7 @@ def test_disown_handles(driver, pages): ) -# Tests for high-level SCRIPT API commands +# Tests for high-level SCRIPT API commands - pin, unpin, and execute def test_pin_script(driver, pages): @@ -626,6 +626,76 @@ def test_execute_script_with_number_argument(driver, pages): assert result["value"] == 1.4 +def test_execute_script_with_nan(driver, pages): + """Test executing script with NaN argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(!Number.isNaN(arg)) + throw Error("Argument should be NaN, but was "+arg); + return arg; + }""", + float("nan"), + ) + + assert result["type"] == "number" + assert result["value"] == "NaN" + + +def test_execute_script_with_inf(driver, pages): + """Test executing script with number argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!==Infinity) + throw Error("Argument should be Infinity, but was "+arg); + return arg; + }""", + float("inf"), + ) + + assert result["type"] == "number" + assert result["value"] == "Infinity" + + +def test_execute_script_with_minus_inf(driver, pages): + """Test executing script with number argument.""" + pages.load("blank.html") + + result = driver.script.execute( + """(arg) => { + if(arg!==-Infinity) + throw Error("Argument should be -Infinity, but was "+arg); + return arg; + }""", + float("-inf"), + ) + + assert result["type"] == "number" + assert result["value"] == "-Infinity" + + +def test_execute_script_with_bigint_argument(driver, pages): + """Test executing script with BigInt argument.""" + pages.load("blank.html") + + # Use a large integer that exceeds JavaScript safe integer limit + large_int = 9007199254740992 + result = driver.script.execute( + """(arg) => { + if(arg !== 9007199254740992n) + throw Error("Argument should be 9007199254740992n (BigInt), but was "+arg+" (type: "+typeof arg+")"); + return arg; + }""", + large_int, + ) + + assert result["type"] == "bigint" + assert result["value"] == str(large_int) + + def test_execute_script_with_boolean_argument(driver, pages): """Test executing script with boolean argument.""" pages.load("blank.html") @@ -660,6 +730,29 @@ def test_execute_script_with_string_argument(driver, pages): assert result["value"] == "hello world" +def test_execute_script_with_date_argument(driver, pages): + """Test executing script with date argument.""" + import datetime + + pages.load("blank.html") + + date = datetime.datetime(2023, 12, 25, 10, 30, 45) + result = driver.script.execute( + """(arg) => { + if(!(arg instanceof Date)) + throw Error("Argument type should be Date, but was "+ + Object.prototype.toString.call(arg)); + if(arg.getFullYear() !== 2023) + throw Error("Year should be 2023, but was "+arg.getFullYear()); + return arg; + }""", + date, + ) + + assert result["type"] == "date" + assert "2023-12-25T10:30:45" in result["value"] + + def test_execute_script_with_array_argument(driver, pages): """Test executing script with array argument.""" pages.load("blank.html") From 6ff4cc468c20d94dc2449767c1f48913e444397f Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Fri, 27 Jun 2025 15:44:23 +0530 Subject: [PATCH 4/4] fix null test --- py/test/selenium/webdriver/common/bidi_script_tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/py/test/selenium/webdriver/common/bidi_script_tests.py b/py/test/selenium/webdriver/common/bidi_script_tests.py index 104ab18303eee..35f8e455573be 100644 --- a/py/test/selenium/webdriver/common/bidi_script_tests.py +++ b/py/test/selenium/webdriver/common/bidi_script_tests.py @@ -593,20 +593,20 @@ def test_unpin_script(driver, pages): assert result["value"] == "undefined" -def test_execute_script_with_undefined_argument(driver, pages): +def test_execute_script_with_null_argument(driver, pages): """Test executing script with undefined argument.""" pages.load("blank.html") result = driver.script.execute( """(arg) => { - if(arg!==undefined) - throw Error("Argument should be undefined, but was "+arg); + if(arg!==null) + throw Error("Argument should be null, but was "+arg); return arg; }""", None, ) - assert result["type"] == "undefined" + assert result["type"] == "null" def test_execute_script_with_number_argument(driver, pages):