Repository: SFDO-Tooling/CumulusCI
Pull request #3408 (2022-10-20 00:46:27)

Playwright debugger
This adds support for the "breakpoint" command when using playwright, which previously only worked with Selenium. 

To test this, make sure you run `cci robot install_playwright` to make sure you have all of the playwright code installed.

To test, create a test that looks like this:

```
*** Settings ***
Resource  cumulusci/robotframework/SalesforcePlaywright.robot

Suite Setup     Open test browser
Suite Teardown  Delete Records and Close Browser

*** Test Cases ***
Example
    ${foo}=  set variable  hello, world
    breakpoint
```

You can then run the test and enable the debugger from the command line like so:

```
$ cci task run robot --robot_debug=True  --suites path/to/the/test.robot
```

Once the browser opens, you should eventually get a prompt in the console. You can then type `locate_elements  xpath=//button` (two spaces after `locate_elements`) , and it should highlight with a yellow outline all of the buttons on the page:

```
:::
::: Welcome to rdb, the Robot Framework debugger
:::

Type help or ? to list commands.

> Debugger.Example
-> <Keyword: cumulusci.robotframework.SalesforcePlaywright.Breakpoint>
rdb> locate_elements  xpath=//button
51 elements found
rdb> 
```

To remove the highlight, issue the command `reset_elements`:

```
rdb> reset_elements
```



Patch:
diff --git a/cumulusci/tasks/robotframework/debugger/DebugListener.py b/cumulusci/tasks/robotframework/debugger/DebugListener.py
index 8cd9145e4..d8d741b3d 100644
--- a/cumulusci/tasks/robotframework/debugger/DebugListener.py
+++ b/cumulusci/tasks/robotframework/debugger/DebugListener.py
@@ -35,7 +35,7 @@ class DebugListener(object):
             self.breakpoints = list(breakpoints)
         else:
             self.breakpoints = [
-                Breakpoint(Keyword, "*::cumulusci.robotframework.Salesforce.Breakpoint")
+                Breakpoint(Keyword, "*::cumulusci.robotframework.*.Breakpoint"),
             ]
 
     def start_suite(self, name, attrs):
diff --git a/cumulusci/tasks/robotframework/debugger/ui.py b/cumulusci/tasks/robotframework/debugger/ui.py
index 068c32be8..31adc0e31 100644
--- a/cumulusci/tasks/robotframework/debugger/ui.py
+++ b/cumulusci/tasks/robotframework/debugger/ui.py
@@ -7,10 +7,21 @@ import sys
 import textwrap
 
 from robot.libraries.BuiltIn import BuiltIn
-from selenium.common.exceptions import InvalidSelectorException
 
 from cumulusci.cli.ui import CliTable
 
+# this is code we use to inject a style into the DOM
+initialize_highlight_js = """
+    if (!window.rdbInitialized) {
+        console.log("initializing rdb...");
+        window.rdbInitialized = true;
+        var new_style = document.createElement('style');
+        new_style.type = 'text/css';
+        new_style.innerHTML = '.rdbHighlight {box-shadow: 0px 1px 4px 2px inset #FFFF00}';
+        document.getElementsByTagName('head')[0].appendChild(new_style);
+    };
+"""
+
 
 class DebuggerCli(cmd.Cmd, object):
     intro = textwrap.dedent(
@@ -39,8 +50,18 @@ class DebuggerCli(cmd.Cmd, object):
         self.do_s = self.do_step
 
     @property
-    def selenium(self):
-        return self.builtin.get_library_instance("SeleniumLibrary")
+    def webbrowser(self):
+        # This is a property because we can't initialize it until we
+        # need it. We'll reinitialize it every time it's used. It's
+        # fast enough, and this way it should work even if the user
+        # is testing a suite with some playwright and some selenium
+        # tests
+        libraries = self.builtin.get_library_instance(all=True)
+        if "SeleniumLibrary" in libraries:
+            return SeleniumProxy(libraries["SeleniumLibrary"])
+        elif "Browser" in libraries:
+            return BrowserProxy(libraries["Browser"])
+        return None
 
     def default(self, line):
         """Ignore lines that begin with #"""
@@ -63,23 +84,16 @@ class DebuggerCli(cmd.Cmd, object):
         return True
 
     def do_locate_elements(self, locator):
-        """Find and highlight all elements that match the given selenium locator
+        """Find and highlight all elements that match the given locator
 
         Example:
 
-            rdb> locate_elements //button[@title='Learn More']
+            rdb> locate_elements  //button[@title='Learn More']
         """
         try:
-            elements = self.selenium.get_webelements(locator)
-            print("Found {} matches".format(len(elements)), file=self.stdout)
-            for element in elements:
-                self._highlight_element(element)
-
-        except InvalidSelectorException:
-            print("invalid locator '{}'".format(locator), file=self.stdout)
-
+            self.webbrowser.highlight_elements(locator)
         except Exception as e:
-            print(str(e), file=self.stdout)
+            print(f"Unable to highlight elements: {e}")
 
     def do_pdb(self, arg=None):
         """Start pdb
@@ -97,9 +111,10 @@ class DebuggerCli(cmd.Cmd, object):
 
     def do_reset_elements(self, arg=None):
         """Remove all highlighting added by `locate_elements`"""
-        elements = self.selenium.get_webelements("//*[@data-original-style]")
-        for element in elements:
-            self._restore_element_style(element)
+        try:
+            self.webbrowser.restore_element_style()
+        except Exception as e:
+            print(f"Unable to reset element styles: {e}")
 
     def do_shell(self, arg):
         """
@@ -181,55 +196,64 @@ class DebuggerCli(cmd.Cmd, object):
             print("{}: {}-> {}".format(i, indent, x.longname), file=self.stdout)
         print("", file=self.stdout)
 
-    def _highlight_element(self, element):
-        """Highlight a Selenium Webdriver element
 
-        This works by replacing the `style` attribute of the element with
-        a custom style. The original style is saved in a custom attribute
-        named `data-original-style`, which is used by _restore_element_style.
+class SeleniumProxy:
+    """Proxy for performing debug operations in a Selenium browser"""
 
-        If the element already has a data-original-style attribute it will
-        not be overwritten.
-        """
+    def __init__(self, library_instance):
+        self.selenium = library_instance
 
-        element_style = """
-            box-shadow: 0px 1px 4px 2px inset #FFFF00;
-        """
-        original_style = element.get_attribute("style")
-        new_style = original_style + element_style
-        self.selenium.driver.execute_script(
-            """
-            if (!arguments[0].hasAttribute("data-original-style")) {
-                console.log('adding data-original-style...');
-                /* only save the original style if we haven't already done so */
-                arguments[0].setAttribute('data-original-style', arguments[0].getAttribute('style'));
-            };
-            arguments[0].setAttribute('style', arguments[1]);
-
-        """,
-            element,
-            new_style,
-        )
+    def highlight_elements(self, locator):
+        """Highlights Selenium Webdriver elements that match a locator"""
+        self.selenium.driver.execute_script(initialize_highlight_js)
 
-    def _restore_element_style(self, element):
-        """Restore the element style from the data-original-style attribute
+        elements = self.selenium.get_webelements(locator)
+        for element in elements:
+            self.selenium.driver.execute_script(
+                "arguments[0].classList.add('rdbHighlight');", element
+            )
+        print(f"{len(elements)} elements found")
+
+    def restore_element_style(self):
+        """Remove the style added by `highlight_elements`"""
+        for element in self.selenium.get_webelements("css:.rdbHighlight"):
+            self.selenium.driver.execute_script(
+                "arguments[0].classList.remove('rdbHighlight')", element
+            )
 
-        This is to undo the effects of _highlight_element
-        """
-        js = """
-        if (arguments[0].hasAttribute('data-original-style')) {
-            var original_style = arguments[0].getAttribute('data-original-style');
-            arguments[0].setAttribute('style', original_style);
-            arguments[0].removeAttribute('data-original-style');
-            return true;
-        } else {
-            return false;
-        }
-        """
 
-        result = self.selenium.driver.execute_script(js, element)
-        if result is False:
-            self.builtin.log(
-                "unable to restore style; original style not found", "DEBUG"
+class BrowserProxy:
+    """Proxy for performing debug operations in a Playwright browser"""
+
+    def __init__(self, library_instance):
+        self.browser = library_instance
+
+    def highlight_elements(self, selector):
+        """Highlight one or more elements by applying a custom css class"""
+        init_script = f"() => {{{initialize_highlight_js}}};"
+        self.browser.evaluate_javascript(None, init_script)
+
+        elements = self.browser.get_elements(selector)
+        if elements:
+            self.browser.evaluate_javascript(
+                selector,
+                """(elements) => {
+                    for (element of elements) {
+                        element.classList.add('rdbHighlight')
+                    }
+                }""",
+                all_elements=True,
             )
-        return result
+        print(f"{len(elements)} elements found")
+
+    def restore_element_style(self):
+        """Remove the style added by `highlight_elements`"""
+        self.browser.evaluate_javascript(
+            ".rdbHighlight",
+            """(elements) => {
+                for (element of elements) {
+                    element.classList.remove('rdbHighlight')
+                }
+            }""",
+            all_elements=True,
+        )


Test patch:
diff --git a/cumulusci/tasks/robotframework/tests/test_browser_proxies.py b/cumulusci/tasks/robotframework/tests/test_browser_proxies.py
new file mode 100644
index 000000000..0a7765d48
--- /dev/null
+++ b/cumulusci/tasks/robotframework/tests/test_browser_proxies.py
@@ -0,0 +1,137 @@
+import io
+from unittest import mock
+
+from cumulusci.tasks.robotframework import debugger
+from cumulusci.tasks.robotframework.debugger.ui import (
+    BrowserProxy,
+    SeleniumProxy,
+    initialize_highlight_js,
+)
+
+"""
+These tests verify that the proxy used by the debugger uses
+the appropriate javascript methods for the given browser library
+"""
+
+
+class StringContaining(str):
+    """Used for fuzzy assertions"""
+
+    def __eq__(self, other):
+        return self in other
+
+
+class TestSeleniumProxy:
+    @classmethod
+    def setup_class(cls):
+        cls.selenium = mock.Mock()
+        cls.proxy = SeleniumProxy(cls.selenium)
+
+    def setup_method(self):
+        self.mock_listener = mock.Mock()
+        self.mock_builtin = mock.Mock()
+        self.stdout = io.StringIO()
+        self.cli = debugger.DebuggerCli(self.mock_listener, stdout=self.stdout)
+        self.cli.builtin = self.mock_builtin
+        self.selenium.reset_mock()
+
+    def test_webbrowser_browser(self):
+        """Verify the cli has initialized the webbrowser attribute to SeleniumProxy
+
+        ... but only Browser has been loaded and SeleniumLibrary has not
+        """
+        self.cli.builtin.get_library_instance = mock.Mock(
+            return_value={
+                "SeleniumLibrary": mock.Mock(),
+                "Browser": mock.Mock(),
+            }
+        )
+        assert isinstance(self.cli.webbrowser, SeleniumProxy)
+
+    def test_highlight_injected_css(self):
+        """Verify we attempt to inject css into the DOM when highlighting an element"""
+
+        self.selenium.get_webelements = mock.Mock(return_value=[])
+        self.proxy.highlight_elements("button")
+
+        self.selenium.driver.execute_script.assert_any_call(initialize_highlight_js)
+
+    def test_highlight_element(self):
+        """Verify we attempt to add the custom css to matching elements"""
+
+        element1 = mock.Mock()
+        element2 = mock.Mock()
+        self.selenium.get_webelements = mock.Mock(return_value=[element1, element2])
+
+        self.proxy.highlight_elements("button")
+        # expect 3 calls to execute javascript: once for the initialization
+        # and one for each element we find
+        assert len(self.selenium.driver.execute_script.mock_calls) == 3
+        self.selenium.driver.execute_script.assert_any_call(
+            "arguments[0].classList.add('rdbHighlight');", element1
+        )
+        self.selenium.driver.execute_script.assert_any_call(
+            "arguments[0].classList.add('rdbHighlight');", element2
+        )
+
+
+class TestBrowserProxy:
+    @classmethod
+    def setup_class(cls):
+        cls.browser = mock.Mock()
+        cls.proxy = BrowserProxy(cls.browser)
+
+    def setup_method(self):
+        self.mock_listener = mock.Mock()
+        self.mock_builtin = mock.Mock()
+        self.stdout = io.StringIO()
+        self.cli = debugger.DebuggerCli(self.mock_listener, stdout=self.stdout)
+        self.cli.builtin = self.mock_builtin
+        self.browser.reset_mock()
+
+    def test_webbrowser_browser(self):
+        """Verify the cli has initialized the webbrowser attribute to BrowserProxy
+
+        ... but only Browser has been loaded and SeleniumLibrary has not
+        """
+        self.cli.builtin.get_library_instance = mock.Mock(
+            return_value={
+                "Browser": mock.Mock(),
+            }
+        )
+        assert isinstance(self.cli.webbrowser, BrowserProxy)
+
+    def test_highlight_injected_css(self):
+        """Verify we attempt to inject css into the DOM when highlighting an element"""
+        script = f"() => {{{initialize_highlight_js}}};"
+        self.browser.get_elements = mock.Mock(return_value=[])
+        self.proxy.highlight_elements("button")
+        self.browser.evaluate_javascript.assert_any_call(None, script)
+
+    def test_highlight_element(self):
+        """Verify that we call evaluate some javascript that applies the class to every matching element"""
+        element1 = mock.Mock()
+        element2 = mock.Mock()
+        self.browser.get_elements = mock.Mock(return_value=[element1, element2])
+
+        self.proxy.highlight_elements("button")
+        script = """(elements) => {
+                    for (element of elements) {
+                        element.classList.add('rdbHighlight')
+                    }
+                }"""
+
+        self.browser.evaluate_javascript.assert_any_call(
+            "button", script, all_elements=True
+        )
+
+    def test_restore_element_style(self):
+        self.proxy.restore_element_style()
+        script = """(elements) => {
+                for (element of elements) {
+                    element.classList.remove('rdbHighlight')
+                }
+            }"""
+        self.browser.evaluate_javascript.assert_called_with(
+            ".rdbHighlight", script, all_elements=True
+        )
diff --git a/cumulusci/tasks/robotframework/tests/test_debugger.py b/cumulusci/tasks/robotframework/tests/test_debugger.py
index bdcbaa83a..63446075a 100644
--- a/cumulusci/tasks/robotframework/tests/test_debugger.py
+++ b/cumulusci/tasks/robotframework/tests/test_debugger.py
@@ -1,12 +1,10 @@
-import fnmatch
 import io
 import os
 import signal
 from unittest import mock
 
-from selenium.common.exceptions import InvalidSelectorException
-
 from cumulusci.tasks.robotframework import debugger
+from cumulusci.tasks.robotframework.debugger.ui import BrowserProxy, SeleniumProxy
 
 
 class TestDebugListener:
@@ -20,9 +18,8 @@ class TestDebugListener:
         assert len(listener.breakpoints) == 1
         bp = listener.breakpoints[0]
         assert not bp.temporary
-        assert bp.pattern == "*::cumulusci.robotframework.Salesforce.Breakpoint"
+        assert bp.pattern == "*::cumulusci.robotframework.*.Breakpoint"
         assert bp.breakpoint_type == debugger.Keyword
-        assert bp.regex == fnmatch.translate(bp.pattern)
 
     def test_listener_custom_breakpoints(self):
         """Verify we can create a cli with custom breakpoints"""
@@ -180,76 +177,49 @@ class TestRobotDebugger:
         return_value = self.cli.do_continue("")
         assert return_value
 
-    def test_selenium(self):
-        self.cli.selenium
-        self.cli.builtin.get_library_instance.assert_called_with("SeleniumLibrary")
+    def test_webbrowser_selenium(self):
+        """Verify the cli has initialized the webbrowser attribute to SeleniumProxy
 
-    @mock.patch.object(debugger.DebuggerCli, "selenium", mock.Mock())
-    def test_locate_elements(self):
-        """Test that the 'locate_elements' debugger command works
-
-        This test sets up a mock that acts like selenium found several
-        elements, and verifies that it calls the functions to highlight them
+        ... but only if SeleniumLibrary has been loaded
         """
-        self.cli._highlight_element = mock.Mock()
-        self.cli.selenium.get_webelements.return_value = ["Element1", "Element2"]
-        return_value = self.cli.do_locate_elements("//whatever")
-        assert return_value is None
-        self.cli._highlight_element.assert_has_calls(
-            [mock.call("Element1"), mock.call("Element2")]
+        self.cli.builtin.get_library_instance = mock.Mock(
+            return_value={
+                "SeleniumLibrary": mock.Mock(),
+                "Browser": mock.Mock(),
+            }
         )
-        assert self.cli.stdout.getvalue() == "Found 2 matches\n"
 
-    @mock.patch.object(debugger.DebuggerCli, "selenium", mock.Mock())
-    def test_locate_elements_exception_handling(self):
-        """Verify that the 'locate_elements' debugger command handles exceptions"""
+        assert isinstance(self.cli.webbrowser, SeleniumProxy)
 
-        self.cli._highlight_element = mock.Mock()
-        self.cli.selenium.get_webelements.side_effect = InvalidSelectorException(
-            "invalid xpath"
-        )
-        self.cli.stdout = io.StringIO()
-        return_value = self.cli.do_locate_elements("//whatever")
-        assert return_value is None
-        self.cli._highlight_element.assert_not_called()
-        assert self.cli.stdout.getvalue() == "invalid locator '//whatever'\n"
+    def test_webbrowser_browser(self):
+        """Verify the cli has initialized the webbrowser attribute to BrowserProxy
 
-        # Even if get_webelement throws an exception, the keyword
-        # should handle it gracefully
-        self.cli.selenium.get_webelements.side_effect = Exception(
-            "something unexpected"
+        ... but only Browser has been loaded and SeleniumLibrary has not
+        """
+        self.cli.builtin.get_library_instance = mock.Mock(
+            return_value={
+                "Browser": mock.Mock(),
+            }
         )
-        self.cli.stdout = io.StringIO()
-        return_value = self.cli.do_locate_elements("//whatever")
-        assert return_value is None
-        self.cli._highlight_element.assert_not_called()
-        assert self.cli.stdout.getvalue() == "something unexpected\n"
 
-    @mock.patch.object(debugger.DebuggerCli, "selenium", mock.Mock())
-    def test_highlight_element_executes_javascript(self):
-        mock_element = mock.Mock()
-        mock_element.get_attribute.return_value = ""
-        self.cli._highlight_element(mock_element)
-        self.cli.selenium.driver.execute_script.assert_called()
+        assert isinstance(self.cli.webbrowser, BrowserProxy)
+
+    @mock.patch.object(debugger.DebuggerCli, "webbrowser", mock.Mock())
+    def test_locate_elements(self):
+        """Test that the 'locate_elements' debugger command works
+
+        This test simply verifies that "do_locate_elements" calls the appropriate
+        method on the browser proxy. What the proxies actually do is the subject
+        of another test (see test_browser_proxies.py)
+        """
+        self.cli.do_locate_elements("button")
+        self.cli.webbrowser.highlight_elements.assert_called_once_with("button")
 
-    @mock.patch.object(debugger.DebuggerCli, "selenium", mock.Mock())
+    @mock.patch.object(debugger.DebuggerCli, "webbrowser", mock.Mock())
     def test_reset_elements(self):
         """Verify that the 'reset_elements' debugger command works"""
-        self.cli._restore_element_style = mock.Mock()
-        self.cli.selenium.get_webelements.return_value = ["Element1", "Element2"]
-        return_value = self.cli.do_reset_elements("")
-        assert return_value is None
-        self.cli._restore_element_style.assert_has_calls(
-            [mock.call("Element1"), mock.call("Element2")]
-        )
-
-    @mock.patch.object(debugger.DebuggerCli, "selenium", mock.Mock())
-    def test_reset_elements_logging(self):
-        self.cli.selenium.driver.execute_script.return_value = False
-        self.cli._restore_element_style("dummy")
-        self.cli.builtin.log.assert_called_with(
-            "unable to restore style; original style not found", "DEBUG"
-        )
+        self.cli.do_reset_elements()
+        self.cli.webbrowser.restore_element_style.assert_called()
 
     def test_shell_no_variables(self):
         """Verify that the shell command works"""