← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:selenium into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:selenium into launchpad:master.

Commit message:
New test browser based on Selenium

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/426561

New test browser based on Selenium

* Replace the test browser based on WebKit/GTK with Selenium
* The test results are saved to `window.test_results` instead of the status bar which is deprecated
* The test browser is launched once per test session, instead of once per test (the browser is initialized in the test layer)
* Some JavaScript tests required minor changes to make them pass in Firefox (this breaks compatibility with the old WebKit/GTK browser)
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:selenium into launchpad:master.
diff --git a/.gitignore b/.gitignore
index 7da2736..da135a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,3 +75,4 @@ yarn/node_modules
 /wheels
 requirements/combined.txt
 .tox/
+/geckodriver.log
diff --git a/lib/lp/app/javascript/formoverlay/tests/test_formoverlay.js b/lib/lp/app/javascript/formoverlay/tests/test_formoverlay.js
index 2f18a28..863f319 100644
--- a/lib/lp/app/javascript/formoverlay/tests/test_formoverlay.js
+++ b/lib/lp/app/javascript/formoverlay/tests/test_formoverlay.js
@@ -108,17 +108,8 @@ YUI.add('lp.formoverlay.test', function (Y) {
             // have the focus.
             this.form_overlay.hide();
             first_input.blur();
-
-            var focused = false;
-
-            var onFocus = function(e) {
-                focused = true;
-            };
-
-            first_input.on('focus', onFocus);
-
             this.form_overlay.show();
-            Assert.isTrue(focused,
+            Assert.isTrue(document.activeElement === first_input.getDOMNode(),
                 "The form overlay's first input field receives focus " +
                 "when the overlay is shown.");
         },
@@ -129,14 +120,13 @@ YUI.add('lp.formoverlay.test', function (Y) {
             this.form_overlay.form_node.empty();
             var close_button = this.form_overlay.get(
                 'boundingBox').one('.close-button');
-            var focused = false;
-            close_button.on('focus', function(e) {
-                focused = true;
-            });
             this.form_overlay.hide();
             close_button.blur();
             this.form_overlay.show();
-            Assert.isTrue(focused, "The close-button was not focused.");
+            Assert.isTrue(
+                document.activeElement === close_button.getDOMNode(),
+                "The close-button was not focused."
+            );
         },
         test_focusChild_form_with_hidden_inputs: function() {
             // When focusing a form element, the method skips non-visible
@@ -148,12 +138,11 @@ YUI.add('lp.formoverlay.test', function (Y) {
                 '<input type="text" name="c" id="c" />'
                 );
             var visible_input = this.form_overlay.get('boundingBox').one('#c');
-            var focused = false;
-            visible_input.on('focus', function(e) {
-                focused = true;
-            });
             this.form_overlay._focusChild();
-            Assert.isTrue(focused, "The visble input was not focused.");
+            Assert.isTrue(
+                document.activeElement === visible_input.getDOMNode(),
+                "The visible input was not focused."
+            );
         },
 
         test_form_submit_in_body_content: function() {
diff --git a/lib/lp/app/javascript/inlineedit/tests/test_inline_edit.js b/lib/lp/app/javascript/inlineedit/tests/test_inline_edit.js
index fd9bb85..857be86 100644
--- a/lib/lp/app/javascript/inlineedit/tests/test_inline_edit.js
+++ b/lib/lp/app/javascript/inlineedit/tests/test_inline_edit.js
@@ -275,27 +275,20 @@ YUI.add('lp.inline_edit.test', function (Y) {
 
         test_focus_method_focuses_editor_input: function() {
             this.editor.render();
-
-            var input = this.editor.get('input_field'),
-                focused = false;
-
-            Y.on('focus', function() {
-                focused = true;
-            }, input);
-
+            var input = this.editor.get('input_field');
             this.editor.focus();
-
-            Assert.isTrue(focused,
+            Assert.isTrue(
+                document.activeElement === input.getDOMNode(),
                 "The editor's input field should have received focus " +
-                "after calling the editor's focus method.");
+                "after calling the editor's focus method."
+            );
         },
 
         test_input_receives_focus_after_editor_errors: function() {
             this.editor.render();
 
             var ed = this.editor,
-                input = this.editor.get('input_field'),
-                got_focus = false;
+                input = this.editor.get('input_field');
 
             Assert.isFalse(
                 ed.get('in_error'),
@@ -309,11 +302,6 @@ YUI.add('lp.inline_edit.test', function (Y) {
             // empty string.
             input.set('value', '');
 
-            // Add our focus event listener.
-            Y.on('focus', function() {
-                got_focus = true;
-            }, input);
-
             ed.save();
             Assert.isTrue(
                 ed.get('in_error'),
@@ -321,7 +309,7 @@ YUI.add('lp.inline_edit.test', function (Y) {
                 "after saving an empty value.");
 
             Assert.isTrue(
-                got_focus,
+                document.activeElement === input.getDOMNode(),
                 "The editor's input field should have the current " +
                 "focus.");
         },
diff --git a/lib/lp/app/javascript/overlay/tests/test_overlay.js b/lib/lp/app/javascript/overlay/tests/test_overlay.js
index fb9970e..856cb7d 100644
--- a/lib/lp/app/javascript/overlay/tests/test_overlay.js
+++ b/lib/lp/app/javascript/overlay/tests/test_overlay.js
@@ -280,16 +280,15 @@ YUI.add('lp.overlay.test', function (Y) {
             });
             this.overlay.render();
             var nodes = this.overlay.get('boundingBox').all('a');
-            var focused = false;
-            nodes.item(0).after('focus', function(e) {
-                focused = true;});
             var halted = false;
             var fake_e = {
                 keyCode: 9, shiftKey: false, currentTarget: nodes.item(2),
                 halt: function() {halted = true;}};
             this.overlay._handleTab(fake_e);
             Y.Assert.isTrue(halted);
-            Y.Assert.isTrue(focused);
+            Y.Assert.isTrue(
+                document.activeElement === nodes.item(0).getDOMNode()
+            );
         },
 
         test_handleTab_first_to_last: function() {
@@ -302,9 +301,6 @@ YUI.add('lp.overlay.test', function (Y) {
             });
             this.overlay.render();
             var nodes = this.overlay.get('boundingBox').all('a');
-            var focused = false;
-            nodes.item(2).after('focus', function(e) {
-                focused = true;});
             var halted = false;
             var fake_e = {
                 keyCode: 9, shiftKey: true, currentTarget: nodes.item(0),
@@ -312,7 +308,9 @@ YUI.add('lp.overlay.test', function (Y) {
             };
             this.overlay._handleTab(fake_e);
             Y.Assert.isTrue(halted);
-            Y.Assert.isTrue(focused);
+            Y.Assert.isTrue(
+                document.activeElement === nodes.item(2).getDOMNode()
+            );
         },
 
         test_handleTab_within_tab_range: function() {
@@ -325,15 +323,15 @@ YUI.add('lp.overlay.test', function (Y) {
             });
             this.overlay.render();
             var nodes = this.overlay.get('boundingBox').all('a');
-            var focused = false;
-            nodes.after('focus', function(e) {focused = true;});
             var halted = false;
             var fake_e = {
                 keyCode: 9, shiftKey: false, currentTarget: nodes.item(0),
                 halt: function() {halted = true;}
             };
             this.overlay._handleTab(fake_e);
-            Y.Assert.isFalse(focused);
+            nodes.each(function(node) {
+                Y.Assert.isFalse(document.activeElement === node.getDOMNode());
+            });
             Y.Assert.isFalse(halted);
         }
     }));
diff --git a/lib/lp/app/javascript/picker/tests/test_personpicker.js b/lib/lp/app/javascript/picker/tests/test_personpicker.js
index 3e44ba6..e54d577 100644
--- a/lib/lp/app/javascript/picker/tests/test_personpicker.js
+++ b/lib/lp/app/javascript/picker/tests/test_personpicker.js
@@ -549,7 +549,7 @@ YUI.add('lp.personpicker.test', function (Y) {
 
     // Hook for the test runner to get test results.
     var handle_complete = function(data) {
-        window.status = '::::' + JSON.stringify({
+        window.top.test_results = JSON.stringify({
             results: data.results,
             type: data.type
         });
diff --git a/lib/lp/app/javascript/picker/tests/test_picker.js b/lib/lp/app/javascript/picker/tests/test_picker.js
index 1a6e76e..b6874a7 100644
--- a/lib/lp/app/javascript/picker/tests/test_picker.js
+++ b/lib/lp/app/javascript/picker/tests/test_picker.js
@@ -1149,12 +1149,11 @@ YUI.add('lp.picker.test', function (Y) {
                 'type="text" />'));
             var extra_input =
                 this.picker.get('boundingBox').one('.extra-input');
-            var got_focus = false;
-            extra_input.on('focus', function(e) {
-                got_focus = true;
-            });
             extra_input.focus();
-            Assert.isTrue(got_focus, "focus didn't go to the extra input.");
+            Assert.isTrue(
+                document.activeElement === extra_input.getDOMNode(),
+                "focus didn't go to the extra input."
+            );
         },
 
         test_overlay_progress_value: function () {
@@ -1191,12 +1190,11 @@ YUI.add('lp.picker.test', function (Y) {
 
             var bb = this.picker.get('boundingBox');
             var search_input = bb.one('.yui3-picker-search');
-            var got_focus = false;
-            search_input.on('focus', function(e) {
-                got_focus = true;
-            });
             this.picker.set('search_mode', false);
-            Assert.isTrue(got_focus, "focus didn't go to the search input.");
+            Assert.isTrue(
+                document.activeElement === search_input.getDOMNode(),
+                "focus didn't go to the search input."
+            );
         }
     }));
 
@@ -1421,10 +1419,6 @@ YUI.add('lp.picker.test', function (Y) {
                 metadata: 'new_metadata'}]
             );
             this.picker.render();
-            var got_focus = false;
-            this.search_input.on('focus', function(e) {
-                got_focus = true;
-            });
             simulate(
                 this.picker.get('boundingBox'),
                     '.yui3-picker-results li', 'click');
@@ -1434,7 +1428,10 @@ YUI.add('lp.picker.test', function (Y) {
                 'new_metadata', this.picker.get('selected_value_metadata'));
             Assert.areEqual(
                 'first', this.picker.get('selected_value'));
-            Assert.isTrue(got_focus, "focus didn't go to the search input.");
+            Assert.isTrue(
+                document.activeElement === this.search_input.getDOMNode(),
+                "focus didn't go to the search input."
+            );
         }
 
     }));
diff --git a/lib/lp/app/javascript/server_fixture.js b/lib/lp/app/javascript/server_fixture.js
index b7a871c..e84c5fd 100644
--- a/lib/lp/app/javascript/server_fixture.js
+++ b/lib/lp/app/javascript/server_fixture.js
@@ -134,7 +134,7 @@ module.teardown = function(testcase) {
 
 module.run = function(suite) {
   var handle_complete = function(data) {
-    window.status = '::::' + Y.JSON.stringify(data);
+    window.top.test_results = Y.JSON.stringify(data);
   };
   Y.Test.Runner.on('complete', handle_complete);
   var handle_pass = function(data) {
diff --git a/lib/lp/app/javascript/testing/testrunner.js b/lib/lp/app/javascript/testing/testrunner.js
index 27a61c8..fc1416a 100644
--- a/lib/lp/app/javascript/testing/testrunner.js
+++ b/lib/lp/app/javascript/testing/testrunner.js
@@ -23,12 +23,29 @@ Runner.run = function(suite) {
 
     // Lock, stock, and two smoking barrels.
     var handle_complete = function(data) {
-        window.status = '::::' + JSON.stringify({
+        window.top.test_results = JSON.stringify({
             results: data.results,
             type: data.type
         });
     };
     Y.Test.Runner.on('complete', handle_complete);
+    var handle_pass = function(data) {
+        window.top.test_results = Y.JSON.stringify(
+          {testCase: data.testCase.name,
+           testName: data.testName,
+           type: data.type
+          });
+    };
+    Y.Test.Runner.on('pass', handle_pass);
+    var handle_fail = function(data) {
+        window.top.test_results = Y.JSON.stringify(
+          {testCase: data.testCase.name,
+           testName: data.testName,
+           type: data.type,
+           error: data.error.getMessage()
+          });
+    };
+    Y.Test.Runner.on('fail', handle_fail);
     Y.Test.Runner.add(suite);
 
     Y.on("domready", function() {
diff --git a/lib/lp/app/javascript/tests/test_lp_client.js b/lib/lp/app/javascript/tests/test_lp_client.js
index c0572c0..5c00536 100644
--- a/lib/lp/app/javascript/tests/test_lp_client.js
+++ b/lib/lp/app/javascript/tests/test_lp_client.js
@@ -71,10 +71,9 @@ YUI.add('lp.client.test', function (Y) {
 
         test_append_qs: function() {
             var qs = "";
-            qs = Y.lp.client.append_qs(qs, "Pöllä", "Perelló");
+            qs = Y.lp.client.append_qs(qs, "P\u00F6ll\u00E4", "Perell\u00F3");
             Assert.areEqual(
-                "P%C3%83%C2%B6ll%C3%83%C2%A4=Perell%C3%83%C2%B3", qs,
-                'This tests is known to fail in Chrome.');
+                "P%C3%B6ll%C3%A4=Perell%C3%B3", qs);
         },
 
         test_append_qs_with_array: function() {
@@ -92,9 +91,9 @@ YUI.add('lp.client.test', function (Y) {
             // append_qs() appends objects to the query string with
             // proper encoding/escaping.
             var qs = "";
-            qs = Y.lp.client.append_qs(qs, "foo", {"text": "A\n&ç"});
+            qs = Y.lp.client.append_qs(qs, "foo", {"text": "A\n&\u00E7"});
             Assert.areEqual(
-                "foo=%7B%22text%22%3A%22A%5Cn%26%C3%83%C2%A7%22%7D", qs);
+                "foo=%7B%22text%22%3A%22A%5Cn%26%C3%A7%22%7D", qs);
         },
 
         test_field_uri: function() {
diff --git a/lib/lp/bugs/javascript/tests/test_filebug.js b/lib/lp/bugs/javascript/tests/test_filebug.js
index 4b25894..b73df41 100644
--- a/lib/lp/bugs/javascript/tests/test_filebug.js
+++ b/lib/lp/bugs/javascript/tests/test_filebug.js
@@ -313,10 +313,8 @@ YUI.add('lp.bugs.filebug.test', function (Y) {
             this.setupForm(true);
             var information_type_popup = Y.one('.information_type-content a');
             information_type_popup.simulate('click');
-            var header_text =
-                Y.one('.yui3-ichoicelist-focused .yui3-widget-hd h2')
-                    .get('text');
-            Y.Assert.areEqual('Set information type as', header_text);
+            var header = Y.one('.yui3-ichoicelist .yui3-widget-hd h2');
+            Y.Assert.areEqual('Set information type as', header.get('text'));
             var information_type_choice = Y.one(
                 '.yui3-ichoicelist-content a[href="#USERDATA"]');
             information_type_choice.simulate('click');
diff --git a/lib/lp/registry/javascript/tests/test_structural_subscription.js b/lib/lp/registry/javascript/tests/test_structural_subscription.js
index ed9afc1..0a38939 100644
--- a/lib/lp/registry/javascript/tests/test_structural_subscription.js
+++ b/lib/lp/registry/javascript/tests/test_structural_subscription.js
@@ -441,15 +441,14 @@ YUI.add('lp.structural_subscription.test', function (Y) {
             module.setup(this.configuration);
             module._show_add_overlay(this.configuration);
             var first_input = Y.Node.create('<input type="text" name="t" />');
-            var focused = false;
-            first_input.on('focus', function(e) {
-                focused = true;
-            });
             module._add_subscription_overlay.form_node.insert(first_input, 0);
             module._add_subscription_overlay.hide();
             first_input.blur();
             module._add_subscription_overlay.show();
-            Assert.isTrue(focused, "The first input was not focused.");
+            Assert.isTrue(
+                document.activeElement === first_input.getDOMNode(),
+                "The first input was not focused."
+            );
         },
 
         test_setup_overlay_missing_content_box: function() {
diff --git a/lib/lp/services/messages/javascript/tests/test_messages.edit.js b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
index dfc2a07..8b72b5e 100644
--- a/lib/lp/services/messages/javascript/tests/test_messages.edit.js
+++ b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
@@ -223,7 +223,7 @@ YUI.add('lp.services.messages.edit.test', function(Y) {
                 var title = rev.one('.message-revision-title');
                 var body = rev.one('.message-revision-body');
                 var expected_title = Y.Lang.sub(
-                    'Remove  Revision #{revision},'+
+                    'Remove Revision #{revision},'+
                     ' created at {date_created_display}',
                     entry);
                 Y.Assert.areEqual(
diff --git a/lib/lp/testing/__init__.py b/lib/lp/testing/__init__.py
index bfc08cc..ff8666b 100644
--- a/lib/lp/testing/__init__.py
+++ b/lib/lp/testing/__init__.py
@@ -1063,32 +1063,49 @@ class AbstractYUITestCase(TestCase):
         """Return an ID for this test based on the file path."""
         return os.path.relpath(self.test_path, config.root)
 
-    def setUp(self):
-        super().setUp()
-        # html5browser imports from the gir/pygtk stack which causes
-        # twisted tests to break because of gtk's initialize.
-        from lp.testing import html5browser
-        client = html5browser.Browser()
-        page = client.load_page(self.html_uri,
-                                timeout=self.suite_timeout,
-                                initial_timeout=self.initial_timeout,
-                                incremental_timeout=self.incremental_timeout)
+    def checkResults(self):
+        """Check the results.
+
+        The tests are run during `setUp()`, but failures need to be reported
+        from here.
+        """
+        assert self.layer.browser
+        results = self.layer.browser.run_tests(
+            self.html_uri,
+            timeout=self.suite_timeout,
+            incremental_timeout=self.incremental_timeout,
+        )
         report = None
-        if page.content:
-            report = simplejson.loads(page.content)
-        if page.return_code == page.CODE_FAIL:
-            self._yui_results = self.TIMEOUT
-            self._last_test_info = report
-            return
+        if results.results:
+            report = results.results
+
+        if results.status == results.Status.TIMEOUT:
+            msg = 'JS timed out.'
+            if results.last_test_message is not None:
+                try:
+                    msg += (' The last test that ran to '
+                            'completion before timing out was '
+                            '{testCase}:{testName}.  The test {type}ed.'
+                            .format(**results.last_test_message))
+                except (KeyError, TypeError):
+                    msg += (' The test runner received an unexpected error '
+                            'when trying to show information about the last '
+                            'test to run.  The data it received was {}.'
+                            .format(results.last_test_message))
+            elif (self.incremental_timeout is not None or
+                  self.initial_timeout is not None):
+                msg += '  The test may never have started.'
+            self.fail(msg)
+
         # Data['type'] is complete (an event).
         # Data['results'] is a dict (type=report)
         # with 1 or more dicts (type=testcase)
         # with 1 for more dicts (type=test).
         if report.get('type', None) != 'complete':
             # Did not get a report back.
-            self._yui_results = self.MISSING_REPORT
-            return
-        self._yui_results = {}
+            self.fail("The data returned by js is not a test report.")
+
+        yui_results = {}
         for key, value in report['results'].items():
             if isinstance(value, dict) and value['type'] == 'testcase':
                 testcase_name = key
@@ -1097,43 +1114,21 @@ class AbstractYUITestCase(TestCase):
                     if isinstance(value, dict) and value['type'] == 'test':
                         test_name = '%s.%s' % (testcase_name, key)
                         test = value
-                        self._yui_results[test_name] = dict(
+                        yui_results[test_name] = dict(
                             result=test['result'], message=test['message'])
 
-    def checkResults(self):
-        """Check the results.
-
-        The tests are run during `setUp()`, but failures need to be reported
-        from here.
-        """
-        if self._yui_results == self.TIMEOUT:
-            msg = 'JS timed out.'
-            if self._last_test_info is not None:
-                try:
-                    msg += ('  The last test that ran to '
-                            'completion before timing out was '
-                            '%(testCase)s:%(testName)s.  The test %(type)sed.'
-                            % self._last_test_info)
-                except (KeyError, TypeError):
-                    msg += ('  The test runner received an unexpected error '
-                            'when trying to show information about the last '
-                            'test to run.  The data it received was %r.'
-                            % (self._last_test_info,))
-            elif (self.incremental_timeout is not None or
-                  self.initial_timeout is not None):
-                msg += '  The test may never have started.'
-            self.fail(msg)
-        elif self._yui_results == self.MISSING_REPORT:
-            self.fail("The data returned by js is not a test report.")
-        elif self._yui_results is None or len(self._yui_results) == 0:
+        if not yui_results:
             self.fail("Test harness or js report format changed.")
+
         failures = []
-        for test_name in self._yui_results:
-            result = self._yui_results[test_name]
+        for test_name, result in yui_results.items():
             if result['result'] not in ('pass', 'ignore'):
                 failures.append(
-                    'Failure in %s.%s: %s' % (
-                    self.test_path, test_name, result['message']))
+                    'Failure in {}.{}: {}'.format(
+                        self.test_path, test_name, result['message']
+                    )
+                )
+
         self.assertEqual([], failures, '\n'.join(failures))
 
 
diff --git a/lib/lp/testing/html5browser.py b/lib/lp/testing/html5browser.py
index 7230ab2..34f8ee6 100644
--- a/lib/lp/testing/html5browser.py
+++ b/lib/lp/testing/html5browser.py
@@ -1,205 +1,131 @@
-# Copyright (C) 2011 - Curtis Hovey <sinzui.is at verizon.net>
-# Copyright 2020 Canonical Ltd.
-#
-# This software is licensed under the MIT license:
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-"""A Web browser that can be driven by an application."""
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
 
-__all__ = [
-    'Browser',
-    'Command',
-    ]
-
-import gi
-
-
-gi.require_version('Gtk', '3.0')
-gi.require_version('WebKit', '3.0')
+"""
+A Web browser powered by Selenium that can be used to run JavaScript tests.
+"""
 
-from gi.repository import (  # noqa: E402
-    GLib,
-    Gtk,
-    WebKit,
+__all__ = [
+    "Browser",
+]
+
+from enum import Enum
+import json
+import time
+from typing import (
+    Any,
+    Optional,
     )
 
+from selenium import webdriver
+from selenium.common.exceptions import TimeoutException
+from selenium.webdriver.support.ui import WebDriverWait
+
 
-class Command:
-    """A representation of the status and result of a command."""
-    STATUS_RUNNING = object()
-    STATUS_COMPLETE = object()
+class Results:
 
-    CODE_UNKNOWN = -1
-    CODE_SUCCESS = 0
-    CODE_FAIL = 1
+    class Status(Enum):
+        SUCCESS = 1
+        TIMEOUT = 2
 
-    def __init__(self, status=STATUS_RUNNING, return_code=CODE_UNKNOWN,
-                 content=None):
+    def __init__(
+        self,
+        status: Status,
+        results: Optional[Any] = None,
+        last_test_message: Optional[Any] = None,
+    ):
         self.status = status
-        self.return_code = return_code
-        self.content = content
+        self.results = results
+        self.last_test_message = last_test_message
 
 
-class Browser(WebKit.WebView):
-    """A browser that can be driven by an application."""
+class Browser:
+    """
+    A Web browser powered by Selenium that can be used to run JavaScript tests.
+
+    The tests must save the results to `window.top.test_results` object.
+    """
 
-    STATUS_PREFIX = '::::'
     TIMEOUT = 5000
-    INCREMENTAL_PREFIX = '>>>>'
-    INITIAL_TIMEOUT = None
-    INCREMENTAL_TIMEOUT = None
-
-    def __init__(self, show_window=False, hide_console_messages=True):
-        super().__init__()
-        self.show_window = show_window
-        self.hide_console_messages = hide_console_messages
-        self.browser_window = None
-        self.script = None
-        self.command = None
-        self.listeners = {}
-        self._connect('console-message', self._on_console_message, False)
-
-    def load_page(self, uri,
-                  timeout=TIMEOUT,
-                  initial_timeout=INITIAL_TIMEOUT,
-                  incremental_timeout=INCREMENTAL_TIMEOUT):
-        """Load a page and return the content."""
-        self._setup_listening_operation(
-            timeout, initial_timeout, incremental_timeout)
-        if uri.startswith('/'):
-            uri = 'file://' + uri
-        self.load_uri(uri)
-        Gtk.main()
-        return self.command
-
-    def run_script(self, script,
-                   timeout=TIMEOUT,
-                   initial_timeout=INITIAL_TIMEOUT,
-                   incremental_timeout=INCREMENTAL_TIMEOUT):
-        """Run a script and return the result."""
-        self._setup_listening_operation(
-            timeout, initial_timeout, incremental_timeout)
-        self.script = script
-        self._connect('notify::load-status', self._on_script_load_finished)
-        self.load_string(
-            '<html><head></head><body></body></html>',
-            'text/html', 'UTF-8', 'file:///')
-        Gtk.main()
-        return self.command
-
-    def _setup_listening_operation(self, timeout, initial_timeout,
-                                   incremental_timeout):
-        """Setup a one-time listening operation for command's completion."""
-        self._create_window()
-        self.command = Command()
-        self._last_status = None
-        self._incremental_timeout = incremental_timeout
-        self._connect(
-            'status-bar-text-changed', self._on_status_bar_text_changed)
-        self._timeout_source = GLib.timeout_add(timeout, self._on_timeout)
-        if initial_timeout is None:
-            initial_timeout = incremental_timeout
-        if initial_timeout is not None:
-            self._incremental_timeout_source = GLib.timeout_add(
-                initial_timeout, self._on_timeout)
-        else:
-            self._incremental_timeout_source = None
-
-    def _create_window(self):
-        """Create a window needed to render pages."""
-        if self.browser_window is not None:
-            return
-        self.browser_window = Gtk.Window()
-        self.browser_window.set_default_size(800, 600)
-        self.browser_window.connect("destroy", self._on_quit)
-        scrolled = Gtk.ScrolledWindow()
-        scrolled.add(self)
-        self.browser_window.add(scrolled)
-        if self.show_window:
-            self.browser_window.show_all()
-
-    def _on_quit(self, widget=None):
-        Gtk.main_quit()
-
-    def _clear_status(self):
-        self.execute_script('window.status = "";')
-
-    def _on_status_bar_text_changed(self, view, text):
-        if text.startswith(self.INCREMENTAL_PREFIX):
-            self._clear_incremental_timeout()
-            self._clear_status()
-            self._last_status = text[4:]
-            if self._incremental_timeout:
-                self._incremental_timeout_source = GLib.timeout_add(
-                    self._incremental_timeout, self._on_timeout)
-        elif text.startswith(self.STATUS_PREFIX):
-            self._clear_timeout()
-            self._clear_incremental_timeout()
-            self._disconnect('status-bar-text-changed')
-            self._clear_status()
-            self.command.status = Command.STATUS_COMPLETE
-            self.command.return_code = Command.CODE_SUCCESS
-            self.command.content = text[4:]
-            self._on_quit()
-
-    def _on_script_load_finished(self, view, load_status):
-        # pywebkit does not have WebKit.LoadStatus.FINISHED.
-        statuses = ('WEBKIT_LOAD_FINISHED', 'WEBKIT_LOAD_FAILED')
-        if self.props.load_status.value_name not in statuses:
-            return
-        self._disconnect('notify::load-status')
-        self.execute_script(self.script)
-        self.script = None
-
-    def _clear_incremental_timeout(self):
-        if self._incremental_timeout_source is not None:
-            GLib.source_remove(self._incremental_timeout_source)
-            self._incremental_timeout_source = None
-
-    def _clear_timeout(self):
-        if self._timeout_source is not None:
-            GLib.source_remove(self._timeout_source)
-            self._timeout_source = None
-
-    def _on_timeout(self):
-        self._clear_timeout()
-        self._clear_incremental_timeout()
-        if self.command.status is not Command.STATUS_COMPLETE:
-            self._disconnect()
-            self.command.status = Command.STATUS_COMPLETE
-            self.command.return_code = Command.CODE_FAIL
-            self.command.content = self._last_status
-            self._on_quit()
-        return False
-
-    def _on_console_message(self, view, message, line_no, source_id, data):
-        return self.hide_console_messages
-
-    def _connect(self, signal, callback, *args):
-        self.listeners[signal] = self.connect(signal, callback, *args)
-
-    def _disconnect(self, signal=None):
-        if signal is None:
-            signals = list(self.listeners.keys())
-        elif isinstance(signal, str):
-            signals = [signal]
-        for key in signals:
-            self.disconnect(self.listeners[key])
-            del self.listeners[key]
+
+    def __init__(self):
+        firefox_profile = webdriver.FirefoxProfile()
+        firefox_profile.set_preference("dom.disable_open_during_load", False)
+        firefox_options = webdriver.FirefoxOptions()
+        firefox_options.headless = True
+        self.driver = webdriver.Firefox(
+            options=firefox_options, firefox_profile=firefox_profile
+        )
+
+    def run_tests(
+        self,
+        uri: str,
+        timeout: int = TIMEOUT,
+        incremental_timeout: Optional[int] = None,
+    ) -> Results:
+        """
+        Load a page with JavaScript tests return the tests results.
+
+        :param uri: URI of the HTML page containing the tests
+        :param timeout: timeout value for the entire test suite (milliseconds)
+        :param incremental_timeout: optional timeout value for
+            individual tests (milliseconds)
+        :return: test results
+        """
+
+        self.driver.get(uri)
+
+        start_time = time.time()
+        results = None
+        time_left = timeout  # milliseconds
+        while time_left > 0:
+            try:
+                results = self._get_test_results(
+                    incremental_timeout or time_left
+                )
+            except TimeoutException:
+                return Results(
+                    status=Results.Status.TIMEOUT,
+                    last_test_message=results
+                )
+            if results.get("type") == "complete":
+                return Results(
+                    status=Results.Status.SUCCESS,
+                    results=results,
+                )
+            time_left -= ((time.time() - start_time) * 1000)
+
+        return Results(
+            status=Results.Status.TIMEOUT,
+            last_test_message=results
+        )
+
+    def _get_test_results(self, timeout: int) -> Any:
+        """
+        Load the test results from the page.
+
+        When found, the results are removed from the page.
+
+        TimeoutException is raised if results could not be fetched within
+        the specified timeout.
+
+        :param timeout: timeout in milliseconds
+        :return: test results
+        """
+        timeout = timeout / 1000  # milliseconds to seconds
+        results = WebDriverWait(
+            self.driver,
+            timeout,
+            # Adjust the poll frequency based on the timeout value
+            poll_frequency=min(0.1, timeout / 10),
+        ).until(
+            lambda d: d.execute_script("""
+                let results = window.test_results;
+                delete window.test_results;
+                return results;
+            """)
+        )
+        return json.loads(results)
+
+    def close(self):
+        self.driver.close()
diff --git a/lib/lp/testing/layers.py b/lib/lp/testing/layers.py
index abdc224..3be9e73 100644
--- a/lib/lp/testing/layers.py
+++ b/lib/lp/testing/layers.py
@@ -152,6 +152,7 @@ from lp.testing import (
     logout,
     reset_logging,
     )
+from lp.testing.html5browser import Browser
 from lp.testing.pgsql import PgTestSetup
 import zcml
 
@@ -1919,15 +1920,19 @@ class ZopelessAppServerLayer(LaunchpadZopelessLayer):
 class YUITestLayer(FunctionalLayer):
     """The layer for all YUITests cases."""
 
+    browser = None
+
     @classmethod
     @profiled
     def setUp(cls):
-        pass
+        cls.browser = Browser()
 
     @classmethod
     @profiled
     def tearDown(cls):
-        pass
+        if cls.browser:
+            cls.browser.close()
+            cls.browser = None
 
     @classmethod
     @profiled
@@ -1943,16 +1948,22 @@ class YUITestLayer(FunctionalLayer):
 class YUIAppServerLayer(MemcachedLayer):
     """The layer for all YUIAppServer test cases."""
 
+    browser = None
+
     @classmethod
     @profiled
     def setUp(cls):
         LayerProcessController.setConfig()
         LayerProcessController.startAppServer('run-testapp')
+        cls.browser = Browser()
 
     @classmethod
     @profiled
     def tearDown(cls):
         LayerProcessController.stopAppServer()
+        if cls.browser:
+            cls.browser.close()
+            cls.browser = None
 
     @classmethod
     @profiled
diff --git a/lib/lp/testing/tests/test_html5browser.py b/lib/lp/testing/tests/test_html5browser.py
index bc30aad..3e32510 100644
--- a/lib/lp/testing/tests/test_html5browser.py
+++ b/lib/lp/testing/tests/test_html5browser.py
@@ -1,236 +1,101 @@
-# Copyright (C) 2011 - Curtis Hovey <sinzui.is at verizon.net>
-# Copyright 2020 Canonical Ltd.
-#
-# This software is licensed under the MIT license:
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
 
 from tempfile import NamedTemporaryFile
 
 from lp.testing import TestCase
-from lp.testing.html5browser import (
-    Browser,
-    Command,
-    )
+from lp.testing.html5browser import Browser
 
 
-load_page_set_window_status_returned = """\
-    <html><head>
-    <script type="text/javascript">
-    window.status = '::::fnord';
-    </script>
-    </head><body></body></html>
-    """
-
-incremental_timeout_page = """\
-    <html><head>
-    <script type="text/javascript">
-    window.status = '>>>>shazam';
-    </script>
-    </head><body></body></html>
-    """
-
-
-load_page_set_window_status_ignores_non_commands = """\
-    <html><head>
-    <script type="text/javascript">
-    window.status = 'snarf';
-    </script>
-    </head><body>
-    <script type="text/javascript">
-    window.status = '::::pting';
-    </script>
-    </body></html>
-    """
-
-timeout_page = """\
-    <html><head></head><body></body></html>
-    """
-
-initial_long_wait_page = """\
-    <html><head>
-    <script type="text/javascript">
-    setTimeout(function() {
-      window.status = '>>>>initial';
-      setTimeout(function() {window.status = '::::ended'}, 200);
-    }, 1000);
-    </script>
-    </head><body></body></html>"""
-
-
-class BrowserTestCase(TestCase):
+class TestBrowser(TestCase):
     """Verify Browser methods."""
 
     def setUp(self):
         super().setUp()
         self.file = NamedTemporaryFile(
-            mode='w+', prefix='html5browser_', suffix='.html')
-        self.addCleanup(self.file.close)
-
-    def test_init_default(self):
-        browser = Browser()
-        self.assertFalse(browser.show_window)
-        self.assertTrue(browser.hide_console_messages)
-        self.assertIsNone(browser.command)
-        self.assertIsNone(browser.script)
-        self.assertIsNone(browser.browser_window)
-        self.assertEqual(['console-message'], list(browser.listeners))
-
-    def test_init_show_browser(self):
-        # The Browser can be set to show the window.
-        browser = Browser(show_window=True)
-        self.assertTrue(browser.show_window)
-
-    def test_load_page_set_window_status_returned(self):
-        # When window status is set with leading ::::, the command ends.
-        self.file.write(load_page_set_window_status_returned)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(self.file.name)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_SUCCESS, command.return_code)
-        self.assertEqual('fnord', command.content)
-        self.assertEqual('::::', Browser.STATUS_PREFIX)
-
-    def test_load_page_set_window_status_ignored_non_commands(self):
-        # Setting window status without a leading :::: is ignored.
-        self.file.write(load_page_set_window_status_ignores_non_commands)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(self.file.name)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_SUCCESS, command.return_code)
-        self.assertEqual('pting', command.content)
-
-    def test_load_page_initial_timeout(self):
-        # If a initial_timeout is set, it can cause a timeout.
-        self.file.write(timeout_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, initial_timeout=1000, timeout=30000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-
-    def test_load_page_incremental_timeout(self):
-        # If an incremental_timeout is set, it can cause a timeout.
-        self.file.write(timeout_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, incremental_timeout=1000, timeout=30000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-
-    def test_load_page_initial_timeout_has_precedence_first(self):
-        # If both an initial_timeout and an incremental_timeout are set,
-        # initial_timeout takes precedence for the first wait.
-        self.file.write(initial_long_wait_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, initial_timeout=3000,
-            incremental_timeout=500, timeout=30000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_SUCCESS, command.return_code)
-        self.assertEqual('ended', command.content)
-
-    def test_load_page_incremental_timeout_has_precedence_second(self):
-        # If both an initial_timeout and an incremental_timeout are set,
-        # incremental_timeout takes precedence for the second wait.
-        self.file.write(initial_long_wait_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, initial_timeout=3000,
-            incremental_timeout=100, timeout=30000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-        self.assertEqual('initial', command.content)
-
-    def test_load_page_timeout_always_wins(self):
-        # If timeout, initial_timeout, and incremental_timeout are set,
-        # the main timeout will still be honored.
-        self.file.write(initial_long_wait_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, initial_timeout=3000,
-            incremental_timeout=3000, timeout=100)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-        self.assertIsNone(command.content)
-
-    def test_load_page_default_timeout_values(self):
-        # Verify our expected class defaults.
-        self.assertEqual(5000, Browser.TIMEOUT)
-        self.assertIsNone(Browser.INITIAL_TIMEOUT)
-        self.assertIsNone(Browser.INCREMENTAL_TIMEOUT)
-
-    def test_load_page_timeout(self):
-        # A page that does not set window.status in 5 seconds will timeout.
-        self.file.write(timeout_page)
-        self.file.flush()
-        browser = Browser()
-        command = browser.load_page(self.file.name, timeout=1000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-
-    def test_load_page_set_window_status_incremental_timeout(self):
-        # Any incremental information is returned on a timeout.
-        self.file.write(incremental_timeout_page)
+            mode="w+", prefix="html5browser_", suffix=".html"
+        )
+        self.file.write(
+            """
+            <html><head>
+            <script type="text/javascript">
+                // First test
+                setTimeout(function() {
+                    window.top.test_results = JSON.stringify({
+                        testCase: "first",
+                        testName: "first",
+                        type: "passed"
+                    });
+                    // Second test
+                    setTimeout(function() {
+                        window.top.test_results = JSON.stringify({
+                            testCase: "second",
+                            testName: "second",
+                            type: "passed"
+                        });
+                        // Final results
+                        setTimeout(function() {
+                            window.top.test_results = JSON.stringify({
+                                results: {"spam": "ham"},
+                                type: "complete"
+                            });
+                        }, 200);
+                    }, 200);
+                }, 100);
+            </script>
+            </head><body></body></html>
+        """
+        )
         self.file.flush()
-        browser = Browser()
-        command = browser.load_page(
-            self.file.name, timeout=30000, initial_timeout=30000,
-            incremental_timeout=1000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-        self.assertEqual('shazam', command.content)
-
-    def test_run_script_timeout(self):
-        # A script that does not set window.status in 5 seconds will timeout.
-        browser = Browser()
-        script = "document.body.innerHTML = '<p>fnord</p>';"
-        command = browser.run_script(script, timeout=1000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_FAIL, command.return_code)
-
-    def test_run_script_complete(self):
-        # A script that sets window.status with the status prefix completes.
-        browser = Browser()
-        script = (
-            "document.body.innerHTML = '<p>pting</p>';"
-            "window.status = '::::' + document.body.innerText;")
-        command = browser.run_script(script, timeout=5000)
-        self.assertEqual(Command.STATUS_COMPLETE, command.status)
-        self.assertEqual(Command.CODE_SUCCESS, command.return_code)
-        self.assertEqual('pting', command.content)
-
-    def test__on_console_message(self):
-        # The method returns the value of hide_console_messages.
-        # You should not see "** Message: console message:" on stderr
-        # when running this test.
-        browser = Browser(hide_console_messages=True)
-        script = (
-            "console.log('hello');"
-            "window.status = '::::goodbye;'")
-        browser.run_script(script, timeout=5000)
-        self.assertTrue(
-            browser._on_console_message(browser, 'message', 1, None, None))
+        self.file_uri = "file://{}".format(self.file.name)
+        self.addCleanup(self.file.close)
+        self.browser = Browser()
+        self.addCleanup(self.browser.close)
+
+    def test_load_test_results(self):
+        results = self.browser.run_tests(self.file_uri, timeout=1000)
+        self.assertEqual(results.status, results.Status.SUCCESS)
+        self.assertEqual(
+            results.results,
+            {
+                "type": "complete",
+                "results": {"spam": "ham"},
+            },
+        )
+
+    def test_timeout_error(self):
+        results = self.browser.run_tests(self.file_uri, timeout=250)
+        self.assertEqual(results.status, results.Status.TIMEOUT)
+        self.assertIsNone(results.results)
+        self.assertEqual(
+            results.last_test_message,
+            {"testCase": "first", "testName": "first", "type": "passed"},
+        )
+
+    def test_incremental_timeout_success(self):
+        results = self.browser.run_tests(
+            self.file_uri,
+            timeout=1000,
+            incremental_timeout=300
+        )
+        self.assertEqual(results.status, results.Status.SUCCESS)
+        self.assertEqual(
+            results.results,
+            {
+                "type": "complete",
+                "results": {"spam": "ham"},
+            },
+        )
+
+    def test_incremental_timeout_error(self):
+        results = self.browser.run_tests(
+            self.file_uri,
+            timeout=1000,
+            incremental_timeout=150
+        )
+        self.assertEqual(results.status, results.Status.TIMEOUT)
+        self.assertIsNone(results.results)
+        self.assertEqual(
+            results.last_test_message,
+            {"testCase": "first", "testName": "first", "type": "passed"},
+        )
diff --git a/requirements/launchpad.txt b/requirements/launchpad.txt
index 984e30f..11f6273 100644
--- a/requirements/launchpad.txt
+++ b/requirements/launchpad.txt
@@ -148,6 +148,7 @@ responses==0.9.0
 rfc3986==1.5.0
 s3transfer==0.3.6
 secure-cookie==0.1.0
+selenium==3.141.0
 service-identity==18.1.0
 setproctitle==1.1.7
 setuptools-git==1.2
diff --git a/setup.cfg b/setup.cfg
index 523364e..28c085b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -91,6 +91,7 @@ install_requires =
     requests-toolbelt
     responses
     secure-cookie
+    selenium
     setproctitle
     setuptools
     simplejson