launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28756
[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