Repository: robocorp/rpaframework

Issue:
Simplify `options` passing in `RPA.Browser.Selenium`
Instead of passing `options=add_argument("<arg>");set_capability("<cap>")` let's have a simpler format, like:
```Python
{"arguments": ["--headless", "--incognitio"], "capabilities": {"acceptInsecureCerts": True}}
```
during the string-based options setting when using any of `Open Available Browser` and `Open Browser` keywords.


Discussion:
Here's a list of common relevant methods supported by all `<Browser>Options` instances (which produce a browser run effect):
- **add_argument**
- **set_capability**
- ignore_local_proxy_environment_variables
- browser_version
- platform_name
- page_load_strategy
- unhandled_prompt_behavior
- timeouts
- enable_mobile
- accept_insecure_certs
- strict_file_interactability
- set_window_rect
- proxy

And each of the final derivates support their own custom methods, so I feel the following holds true:
1. Importing and passing as `options` a real Python object instance if you want to be in full control.
2. Passing the string version (`add_argument("<arg>"); set_capability("<cap>")[; ...etc]`) may support an arbitrary number of methods, as they are parsed and applied over a new Options object in the end.
3. Passing the dict version (the one in this Issue) will be limited only to arguments and capabilities now. For anything else we'll have the ways above still available.

Resolving patch:
diff --git a/packages/main/src/RPA/Browser/Selenium.py b/packages/main/src/RPA/Browser/Selenium.py
--- a/packages/main/src/RPA/Browser/Selenium.py
+++ b/packages/main/src/RPA/Browser/Selenium.py
@@ -13,7 +13,7 @@
 from functools import partial
 from itertools import product
 from pathlib import Path
-from typing import Any, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 
 import robot
 from robot.libraries.BuiltIn import BuiltIn, RobotNotRunningError
@@ -35,7 +35,8 @@
 from RPA.core import webdriver as core_webdriver
 from RPA.core.locators import BrowserLocator, LocatorsDatabase
 
-OptionsType = Union[ArgOptions, str]
+
+OptionsType = Union[ArgOptions, str, Dict[str, Union[str, List, Dict]]]
 AliasType = Union[str, int]
 
 
@@ -103,6 +104,7 @@ def open_browser(
         executable_path: Optional[str] = None,
     ) -> str:
         url = ensure_scheme(url, self._default_scheme)
+        options: ArgOptions = self.ctx.normalize_options(options, browser=browser)
         return super().open_browser(
             url=url,
             browser=browser,
@@ -638,10 +640,11 @@ def open_available_browser(
         certain user-agent string for Selenium, which can be overridden
         with the ``user_agent`` argument.
 
-        Webdriver creation can be customized with ``options``. This accepts either a
-        class instance (e.g. ``ChromeOptions``) or being passed as string:
-        `add_argument("--incognito")`. (multiple arguments should be separated with
-        `;`)
+        WebDriver creation can be customized with ``options``. This accepts a class
+        instance (e.g. ``ChromeOptions``), a string like
+        `add_argument("--incognito");set_capability("acceptInsecureCerts", True)` or
+        even a simple dictionary like:
+        `{"arguments": ["--incognito"], "capabilities": {"acceptInsecureCerts": True}}`
 
         A custom ``port`` can be provided to start the browser without a random one.
         Make sure you provide every time a unique system-available local port if you
@@ -875,7 +878,62 @@ def _set_chrome_options(
             kwargs["service_log_path"] = "chromedriver.log"
             kwargs["service_args"] = ["--verbose"]
 
-    def _get_driver_args(
+    def _set_option(
+        self, name: str, values: Union[str, List, Dict], *, method: Callable
+    ):
+        if name == "arguments":
+            if isinstance(values, str):
+                values = [value.strip() for value in values.split(",")]
+            for value in values:
+                self.logger.debug("Setting argument: %s", value)
+                method(value)
+        elif name == "capabilities":
+            if isinstance(values, str):
+                values, _values = {}, values
+                for item in _values.split(","):
+                    key, val = item.strip().split(":")
+                    values[key.strip()] = val.strip()
+            for key, val in values.items():
+                self.logger.debug("Setting capability: %s=%s", key, val)
+                method(key, val)
+        elif name == "binary_location":
+            self.logger.debug("Setting binary location: %s", values)
+            method(values)
+
+    def normalize_options(
+        self, options: Optional[OptionsType], *, browser: str
+    ) -> ArgOptions:
+        """Normalize `options` to `<Browser>Options` instance."""
+        browser = browser.lower()
+        options_obj = self.AVAILABLE_OPTIONS[browser]()
+        if not options:
+            # Empty options object.
+            return options_obj
+
+        if isinstance(options, dict):
+            option_method_map = {
+                "arguments": options_obj.add_argument,
+                "capabilities": options_obj.set_capability,
+                "binary_location": lambda path: setattr(
+                    options_obj, "binary_location", path
+                ),
+            }
+            for name, values in options.items():
+                if name not in option_method_map:
+                    raise TypeError(
+                        f"Option type {name!r} not supported, choose from "
+                        f"{list(option_method_map)} or try providing them as string "
+                        "or object"
+                    )
+                method = option_method_map[name]
+                self._set_option(name, values, method=method)
+
+            return options_obj
+
+        # String or object based provided options.
+        return SeleniumOptions().create(self.BROWSER_NAMES[browser], options)
+
+    def _get_driver_args(  # noqa: C901
         self,
         browser: str,
         headless: bool = False,
@@ -894,13 +952,7 @@ def _get_driver_args(
         if browser not in self.AVAILABLE_OPTIONS:
             return {}, []
 
-        # Normalize `options` to `<Browser>Options` instance.
-        if options:
-            options: ArgOptions = SeleniumOptions().create(
-                self.BROWSER_NAMES[browser], options
-            )
-        else:
-            options: ArgOptions = self.AVAILABLE_OPTIONS[browser]()
+        options: ArgOptions = self.normalize_options(options, browser=browser)
         headless = headless or bool(int(os.getenv("RPA_HEADLESS_MODE", "0")))
         if headless:
             self._set_headless_options(browser, options)
@@ -924,6 +976,18 @@ def _get_driver_args(
         elif use_profile:
             self.logger.warning("Profiles are supported with Chrome only")
 
+        try:
+            path = options.binary_location or None
+        except AttributeError:
+            path = None
+        if path:
+            self.logger.warning(
+                f"The custom provided browser ({path}) might be "
+                "incompatible with the default downloaded webdriver. Use "
+                "``Open Browser`` with these `options` and a compatible "
+                "`executable_path` if running into issues."
+            )
+
         kwargs["options"] = options
         return kwargs, options.arguments
 


Test patch:
diff --git a/packages/main/tests/python/test_browser.py b/packages/main/tests/python/test_browser.py
--- a/packages/main/tests/python/test_browser.py
+++ b/packages/main/tests/python/test_browser.py
@@ -1,54 +1,77 @@
 import urllib.parse
+
 import pytest
+from selenium.webdriver import ChromeOptions
 
 from RPA.Browser.Selenium import Selenium, ensure_scheme
 
 from . import RESOURCES_DIR, temp_filename
 
 
-@pytest.fixture()
+@pytest.fixture
 def library():
     lib = Selenium()
     yield lib
     lib.close_all_browsers()
 
 
-@pytest.mark.skip()
-class TestBrowserFunctionality:
+def get_chrome_options():
+    """Returns `ChromeOptions` with custom arguments and capabilities."""
+    options = ChromeOptions()
+    options.add_argument("--headless")
+    options.set_capability("acceptInsecureCerts", True)
+    return options
+
+
+class TestSelenium:
+    """`RPA.Browser.Selenium` library tests."""
+
     def test_print_to_pdf(self, library):
         testfile = RESOURCES_DIR / "browser_docs.html"
         library.open_available_browser(f"file://{testfile}", headless=True)
-        with temp_filename() as tmp_file:
+        with temp_filename(suffix=".pdf") as tmp_file:
             library.print_to_pdf(tmp_file)
-            # TODO: get the text without PDF library dependency
-            # text = PDF().get_text_from_pdf(tmp_file)
-
-            # assert "Please explicitly use either RPA.Browser.Selenium" in text[1]
-
-    def test_print_to_pdf_different_from_start_page(self, library):
-        startpage = RESOURCES_DIR / "alert.html"
-        testfile = RESOURCES_DIR / "browser_docs.html"
-        library.open_available_browser(f"file://{startpage}", headless=True)
-        with temp_filename() as tmp_file:
-            library.go_to(f"file://{testfile}")
-            library.print_to_pdf(output_path=tmp_file)
-            # TODO: get the text without PDF library dependency
-            # text = PDF().get_text_from_pdf(tmp_file)
-
-            # assert "Please explicitly use either RPA.Browser.Selenium" in text[1]
+            with open(tmp_file, "rb") as stream:
+                data = stream.read()
+            assert b"selenium" in data
 
+    @pytest.mark.xfail(reason="Firefox not available")
     def test_print_to_pdf_exception_on_non_supported_driver(self, library):
         testfile = RESOURCES_DIR / "browser_docs.html"
         library.open_available_browser(
             f"file://{testfile}", browser_selection="firefox", headless=True
         )
+        err_msg = "PDF printing works only with Chrome/Chromium"
+        with pytest.raises(NotImplementedError, match=err_msg):
+            library.print_to_pdf(output_path=None)
 
-        expected = "PDF printing works only with Chrome/Chromium"
+    @pytest.mark.parametrize(
+        "options",
+        [
+            {
+                "arguments": ["--headless"],
+                "capabilities": {"acceptInsecureCerts": True},
+            },
+            {"arguments": "--headless", "capabilities": "acceptInsecureCerts:True"},
+            "add_argument('--headless');set_capability('acceptInsecureCerts', True)",
+            get_chrome_options(),
+        ],
+    )
+    def test_options_normalization(self, library, options):
+        options_obj = library.normalize_options(options, browser="Chrome")
+        assert options_obj.headless
+        assert options_obj.accept_insecure_certs
 
-        with pytest.raises(NotImplementedError) as err:
-            library.print_to_pdf(output_path=None)
+    def test_unrecognized_option(self, library):
+        options = {"argument": "--headless"}
+        with pytest.raises(TypeError):
+            library.normalize_options(options, browser="Chrome")
 
-        assert str(err.value) == expected
+    def test_custom_options(self, library):
+        path = "path/to/chrome"
+        options = {"binary_location": path}
+        options_obj = library.normalize_options(options, browser="Chrome")
+        assert options_obj.binary_location == path
 
 
 @pytest.mark.parametrize(
diff --git a/packages/main/tests/robot/test_browser.robot b/packages/main/tests/robot/test_browser.robot
--- a/packages/main/tests/robot/test_browser.robot
+++ b/packages/main/tests/robot/test_browser.robot
@@ -111,3 +111,14 @@ Open In Incognito With Custom Options
         Directory Should Not Be Empty      ${data_dir}
         RPA.FileSystem.Remove directory    ${data_dir}      recursive=${True}
     END
+
+Open Browser With Dict Options
+    @{args} =   Create List     --headless
+    &{caps} =   Create Dictionary   acceptInsecureCerts     ${True}
+    &{options} =    Create Dictionary   arguments   ${args}     capabilities    ${caps}
+
+    ${driver_path} =   Evaluate    RPA.core.webdriver.download("Chrome")
+    ...     modules=RPA.core.webdriver
+    Log To Console      Downloaded webdriver path: ${driver_path}
+    Open Browser    https://robocorp.com    browser=Chrome      options=${options}
+    ...     executable_path=${driver_path}