[PATCH 11/11] Add the beginnings of an automated GUI test suite.
Vratislav Podzimek
vpodzime at redhat.com
Tue Apr 8 07:43:53 UTC 2014
On Mon, 2014-04-07 at 14:03 -0400, Chris Lumens wrote:
> This is enough to make sure all the basic stuff works on a standard livecd.
> The documentation throughout should be enough to explain it all, but here's
> a few quick comments:
>
> * Everything in the inside/ directory runs inside a VM.
>
> * Most things in the outside/ directory run outside of and control a VM.
> outside/template.py is a notable exception to this. It is a template file of
> python code and gets run inside the VM as suite.py.
>
> * runtest.sh runs all the outside/ tests against a livecd previously created
> with make_livecd.sh.
> ---
> tests/gui/anaconda-autogui-testing.ks | 8 +-
> tests/gui/inside/__init__.py | 182 +++++++++++++++++++++
> tests/gui/inside/date_time.py | 59 +++++++
> tests/gui/inside/keyboard.py | 127 +++++++++++++++
> tests/gui/inside/network.py | 49 ++++++
> tests/gui/inside/progress.py | 122 ++++++++++++++
> tests/gui/inside/rootpassword.py | 66 ++++++++
> tests/gui/inside/storage.py | 101 ++++++++++++
> tests/gui/inside/summary.py | 81 ++++++++++
> tests/gui/inside/user.py | 74 +++++++++
> tests/gui/inside/welcome.py | 64 ++++++++
> tests/gui/outside/__init__.py | 280 +++++++++++++++++++++++++++++++++
> tests/gui/outside/template.py | 75 +++++++++
> tests/gui/outside/test_simplelivecd.py | 39 +++++
> tests/gui/runtest.sh | 51 ++++++
> 15 files changed, 1376 insertions(+), 2 deletions(-)
> create mode 100644 tests/gui/inside/__init__.py
> create mode 100644 tests/gui/inside/date_time.py
> create mode 100644 tests/gui/inside/keyboard.py
> create mode 100644 tests/gui/inside/network.py
> create mode 100644 tests/gui/inside/progress.py
> create mode 100644 tests/gui/inside/rootpassword.py
> create mode 100644 tests/gui/inside/storage.py
> create mode 100644 tests/gui/inside/summary.py
> create mode 100644 tests/gui/inside/user.py
> create mode 100644 tests/gui/inside/welcome.py
> create mode 100644 tests/gui/outside/__init__.py
> create mode 100644 tests/gui/outside/template.py
> create mode 100644 tests/gui/outside/test_simplelivecd.py
> create mode 100755 tests/gui/runtest.sh
>
> diff --git a/tests/gui/anaconda-autogui-testing.ks b/tests/gui/anaconda-autogui-testing.ks
> index 2e0dd06..35f96d9 100644
> --- a/tests/gui/anaconda-autogui-testing.ks
> +++ b/tests/gui/anaconda-autogui-testing.ks
> @@ -11,18 +11,22 @@ bootloader --location=mbr
> zerombr
> clearpart --all
> part / --fstype="ext4" --size=4400
> -part swap --fstype="swap" --size=512
>
> %post
> cat >> /etc/rc.d/init.d/livesys << EOF
> +# Mount the attached disk containing test suite information.
> +mkdir /mnt/anactest
> +mount -L ANACTEST /mnt/anactest
> +chown -R liveuser.liveuser /mnt/anactest
>
> if [ -f /usr/share/applications/anaconda.desktop ]; then
> - # Make anaconda start automatically, not the welcome thing.
> + # Make anaconda start automatically.
> if [ -f ~liveuser/.config/autostart/fedora-welcome.desktop ]; then
> rm ~liveuser/.config/autostart/fedora-welcome.desktop
> fi
>
> cp /usr/share/applications/anaconda.desktop ~liveuser/.config/autostart/
> + sed -i -e '/Exec=/ s|/usr/bin/liveinst|python /mnt/anactest/suite.py|' ~liveuser/.config/autostart/anaconda.desktop
>
> # Enable accessibility needed for testing.
> cat >> /usr/share/glib-2.0/schemas/org.gnome.desktop.interface.gschema.override << FOE
> diff --git a/tests/gui/inside/__init__.py b/tests/gui/inside/__init__.py
> new file mode 100644
> index 0000000..1cbe3ed
> --- /dev/null
> +++ b/tests/gui/inside/__init__.py
> @@ -0,0 +1,182 @@
> +#!/usr/bin/python
> +#
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +from dogtail.config import config
> +from dogtail.predicate import GenericPredicate
> +from dogtail.tree import SearchError, root
> +from dogtail.utils import doDelay, screenshot
> +
> +import glob
> +import os
> +import unittest
> +
> +class UITestCase(unittest.TestCase):
> + """A UITestCase is a class that incorporates all the test functions for a
> + single anaconda Hub or Spoke window. Moving to the window, and dealing
> + with what happens after we move away is left up to whatever wraps up
> + all the test cases. A single Hub or Spoke may have multiple instances
> + of this class - one may test the assumption that everything works as it
> + should, while another may test that a specific initial setup fails in
> + an expected way, and another may attempt fuzz testing on all entries on
> + the screen. However, a single TestSuite will only have one instance
> + for each anaconda window.
> +
> + Some basic assumptions about the existence of UI elements are made in
> + UITestCase subclasses. If an element is required to be there (for
> + instance, a button we are going to click) we simply grab the element
> + with the exception-raising dogtail functions and call click on it.
> + If the element does not exist, the exception will be propagated up and
> + fail the test. If we are testing for the existence of some element
> + (for instance, that a dialog was displayed after a button was clicked)
> + then we use UITestCase.find and the unittest.assert* functions.
> +
> + This is kind of subtle, but look at existing test cases for examples.
> +
> + Due to the unusual nature of running such a large program as anaconda
> + in a test suite, combined with the special environment, tests are
> + organized inside a UITestCase as follows:
> +
> + * Testing one little piece of the window is handled by a check_*
> + function. These are explicitly not named test_* as that is special
> + to unittest.
> +
> + * Multiple check_* functions are called linearly in the _run method,
> + which is used instead of runTest for error handling purposes.
> + """
> +
> + ###
> + ### OVERRIDES OF THINGS FROM TestCase
> + ###
> +
> + def runTest(self):
> + """A version of TestCase.runTest that attempts to take a screenshot of
> + anaconda should a test fail. Subclasses should not override this.
> + See the documentation for _run.
> + """
> + config.load({"scratchDir": "/mnt/anactest/result/"})
> +
> + try:
> + self._run()
> + except (AssertionError, SearchError):
> + # Try to take a screenshot of whatever screen anaconda's on, so
> + # we can attempt to figure out what went wrong.
> + screenshot()
> + raise
> +
> + def _run(self):
> + """Do all the tests for this test case. This is like the TestCase.runTest
> + method, but we've overridden that to do more specialized error handling.
> + Thus, all testing should be done in this method.
> + """
> + pass
> +
> + def setUp(self):
> + self.ana = root.application("anaconda")
> +
> + ###
> + ### METHODS FOR FINDING WIDGETS
> + ###
> +
> + def find(self, name, roleName=None):
> + """Wrap findChild, returning None if no widget is found instead of
> + raising an exception. This method also allows for checking if
> + anaconda has hit a traceback and if so, fails the test
> + immediately.
> + """
> + if len(glob.glob("/tmp/anaconda-tb-*")) > 0:
> + self.fail("anaconda encountered a traceback")
> +
> + try:
> + return self.ana.findChild(name=name, roleName=roleName)
> + except SearchError:
> + return None
> +
> + ###
> + ### METHODS FOR CHECKING A SINGLE WIDGET
> + ###
> +
> + def check_window_displayed(self, name):
> + """Verify that a window (such as a hub or spoke) given by the
> + provided name is currently displayed on the screen. If not,
> + the current test case will be failed.
> + """
> + w = self.find(name)
> + self.assertIsNotNone(w, msg="%s not found" % name)
> + self.assertTrue(w.showing, msg="%s is not displayed" % name)
> +
> + def check_dialog_displayed(self, name):
> + """Verify that a dialog given by the provided name is currently
> + displayed on the screen. If not, the current test case will
> + be failed.
> + """
> + w = self.find(name, "dialog")
> + self.assertIsNotNone(w, msg="%s not found" % name)
> + self.assertTrue(w.showing, msg="%s is not displayed" % name)
> +
> + def check_keyboard_layout_indicator(self, layout):
> + """Verify that the keyboard layout indicator is present and that
> + the currently enabled layout is what we expect. If not, the
> + current test case will be failed.
> + """
> + indicator = self.find("Keyboard Layout")
> + self.assertIsNotNone(indicator, msg="keyboard layout indicator not found")
> + self.assertEqual(indicator.description, layout,
> + msg="keyboard layout indicator not set to %s" % layout)
> +
> + def check_no_warning_bar(self):
> + """Verify that the warning bar is not currently displayed."""
> + self.assertIsNone(self.find("Warning"), msg="Warning bar should not be displayed")
> +
> + def check_warning_bar(self, msg=None):
> + """Verify that the warning bar is currently displayed. If msg is given,
> + verify that it is contained in whatever message the warning bar is
> + showing.
> + """
> + bar = self.find("Warning")
> + self.assertTrue(bar.showing, msg="Warning bar should be displayed")
> + if msg:
> + self.assertIn(msg, bar.child(roleName="label").text)
> +
> + def click_button(self, name):
> + """Verify that a button with the given name exists and is sensitive,
> + and then click it.
> + """
> + b = self.find(name, "button")
> + self.assertIsNotNone(b, msg="%s button not found" % name)
> + self.assertTrue(b.sensitive, msg="%s button should be sensitive" % name)
> + b.click()
> +
> + def enter_spoke(self, spokeSelectorName):
> + """Click on the spoke selector for the given spoke, then wait a moment
> + to make sure it has appeared.
> + """
> + selector = self.find(spokeSelectorName, "spoke selector")
> + self.assertIsNotNone(selector, msg="Selector %s not found" % spokeSelectorName)
> + selector.click()
> +
> + def exit_spoke(self, hubName="INSTALLATION SUMMARY"):
> + """Leave a spoke by clicking the Done button in the upper left corner,
> + then verify we have returned to the proper hub. Since most spokes
> + are off the summary hub, that's the default. If we are not back
> + on the hub, the current test case will be failed.
> + """
> + button = self.find("Done", "button")
> + self.assertIsNotNone(button, msg="Done button not found")
> + button.click()
> + doDelay(5)
I'm afraid this may be not enough. I've definitely seen the grey screen
instead of hub for more than 5 seconds on my VMs.
> + self.check_window_displayed(hubName)
> diff --git a/tests/gui/inside/date_time.py b/tests/gui/inside/date_time.py
> new file mode 100644
> index 0000000..3b4a6ff
> --- /dev/null
> +++ b/tests/gui/inside/date_time.py
> @@ -0,0 +1,59 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from . import UITestCase
> +
> +class LiveCDDateTimeTestCase(UITestCase):
> + def check_region_city(self):
> + # FIXME: This encodes default information.
> + entry = self.find("Region", "textentry")
> + self.assertIsNotNone(entry, "Region entry does not exist")
> + self.assertEqual(entry.text, "Americas", msg="Region should be set to default")
> +
> + entry = self.find("City", "textentry")
> + self.assertIsNotNone(entry, "City entry does not exist")
> + self.assertEqual(entry.text, "New York", msg="City should be set to default")
> +
> + def check_ntp(self):
> + # NTP should be enabled given that we started up with networking.
> + # FIXME: This encodes default information.
> + button = self.find("Use Network Time", "button")
> + self.assertIsNotNone(button, msg="Use Network Time button not found")
> + self.assertTrue(button.checked, msg="NTP should be enabled")
> +
> + button = self.find("Configure NTP", "button")
> + self.assertIsNotNone(button, msg="Configure NTP button not found")
> + self.assertTrue(button.sensitive, msg="Configure NTP button should be sensitive")
> +
> + button = self.find("Set Date & Time", "button")
> + self.assertIsNotNone(button, msg="Set Date & Time button not found")
> + self.assertFalse(button.sensitive, msg="Date & Time region should not be sensitive")
> +
> + def _run(self):
> + # First, we need to click on the network spoke selector.
> + self.enter_spoke("DATE & TIME")
> +
> + # Now verify we are on the right screen.
> + self.check_window_displayed("DATE & TIME")
> +
> + # And now we can check everything else on the screen.
> + self.check_region_city()
> + self.check_ntp()
> +
> + # And then we click the Done button to go back to the hub, verifying
> + # that's where we ended up.
> + self.exit_spoke()
> diff --git a/tests/gui/inside/keyboard.py b/tests/gui/inside/keyboard.py
> new file mode 100644
> index 0000000..a3a7a6b
> --- /dev/null
> +++ b/tests/gui/inside/keyboard.py
> @@ -0,0 +1,127 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from dogtail.predicate import GenericPredicate
> +
> +from . import UITestCase
> +
> +class BasicKeyboardTestCase(UITestCase):
> + def _get_enabled_layouts(self, view):
> + return [child.text for child in view.findChildren(GenericPredicate(roleName="table cell"))]
> +
> + def check_options_dialog(self):
> + # Click the options button.
> + self.click_button("Layout Options")
> + self.check_dialog_displayed("Layout Options")
> + self.click_button("Cancel")
> +
> + def check_num_layouts(self):
> + # There ought to be only one layout enabled right now.
> + # FIXME: This encodes default information.
> + view = self.find("Selected Layouts")
> + self.assertIsNotNone(view, "Selected Layouts view not found")
> + self.assertEqual(len(self._get_enabled_layouts(view)), 1, msg="An unexpected number of keyboard layouts are enabled")
> +
> + def check_layout_buttons_initial(self):
> + button = self.find("Add layout", "button")
> + self.assertIsNotNone(button, msg="Add layout button not found")
> + self.assertTrue(button.sensitive, msg="Add layout button should be sensitive")
> +
> + # When no layouts are selected in the view, none of these buttons mean anything.
> + button = self.find("Remove layout", "button")
> + self.assertIsNotNone(button, msg="Remove layout button not found")
> + self.assertFalse(button.sensitive, msg="Remove layout button should not be sensitive")
> +
> + button = self.find("Move selected layout up", "button")
> + self.assertIsNotNone(button, msg="Move layout up button not found")
> + self.assertFalse(button.sensitive, msg="Move layout up button should not be sensitive")
> +
> + button = self.find("Move selected layout down", "button")
> + self.assertIsNotNone(button, msg="Move layout down button not found")
> + self.assertFalse(button.sensitive, msg="Move layout down button should not be sensitive")
> +
> + button = self.find("Preview layout", "button")
> + self.assertIsNotNone(button, msg="Preview layout button not found")
> + self.assertFalse(button.sensitive, msg="Preview layout button should not be sensitive")
> +
> + def check_add_layout_dialog(self):
> + self.click_button("Add layout")
> + self.check_dialog_displayed("Add layout")
> + self.click_button("Cancel")
> +
> + def check_layout_buttons_after_click(self):
> + # Click on the first (and only) layout shown in the view. This
> + # ensures buttons change sensitivity.
> + # FIXME: This encodes default information.
> + view = self.find("Selected Layouts")
> + self.assertIsNotNone(view, msg="Selected Layouts view not found")
> + view.children[1].click()
> +
> + button = self.find("Add layout", "button")
> + self.assertIsNotNone(button, msg="Add layout button not found")
> + self.assertTrue(button.sensitive, msg="Add layout button should be sensitive")
> +
> + button = self.find("Remove layout", "button")
> + self.assertIsNotNone(button, msg="Remove layout button not found")
> + self.assertTrue(button.sensitive, msg="Remove layout button should be sensitive")
> +
> + # These two should still not be sensitive - we've only got one layout.
> + # We ensured that with check_num_layouts.
> + # FIXME: This encodes default information.
> + button = self.find("Move selected layout up", "button")
> + self.assertIsNotNone(button, msg="Move layout up button not found")
> + self.assertFalse(button.sensitive, msg="Move layout up button should not be sensitive")
> +
> + button = self.find("Move selected layout down", "button")
> + self.assertIsNotNone(button, msg="Move layout down button not found")
> + self.assertFalse(button.sensitive, msg="Move layout down button should not be sensitive")
> +
> + button = self.find("Preview layout", "button")
> + self.assertIsNotNone(button, msg="Preview layout button not found")
> + self.assertTrue(button.sensitive, msg="Preview layout button should be sensitive")
> +
> + def check_preview_dialog(self):
> + self.click_button("Preview layout")
> +
> + # Verify the preview dialog is displayed. The dialog out to be titled
> + # with the name of the current layout. We happen to know that - it's
> + # the default.
> + # FIXME: This encodes default information.
> + self.check_dialog_displayed("English (US)")
> +
> + self.click_button("Close")
> +
> + def _run(self):
> + # First, we need to click on the network spoke selector.
> + self.enter_spoke("KEYBOARD")
> +
> + # Now verify we are on the right screen.
> + self.check_window_displayed("KEYBOARD")
> +
> + # And now we can check everything else on the screen.
> + self.check_options_dialog()
> + self.check_num_layouts()
> + self.check_layout_buttons_initial()
> + self.check_add_layout_dialog()
> +
> + # Once a layout has been selected in the view, we can test these other buttons.
> + self.check_layout_buttons_after_click()
> + self.check_preview_dialog()
> +
> + # And then we click the Done button to go back to the hub, verifying
> + # that's where we ended up.
> + self.exit_spoke()
> diff --git a/tests/gui/inside/network.py b/tests/gui/inside/network.py
> new file mode 100644
> index 0000000..2112c94
> --- /dev/null
> +++ b/tests/gui/inside/network.py
> @@ -0,0 +1,49 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from . import UITestCase
> +
> +class LiveCDNetworkTestCase(UITestCase):
> + def check_hostname_entry(self):
> + # Only the live install hint and hostname box should be visible.
> + self.assertTrue(self.ana.childLabelled("Please use the live desktop environment's tools for customizing your network configuration. You can set the hostname here.").showing)
> +
> + box = self.find("Network Config Box")
> + self.assertIsNotNone(box, "Network Config box not found")
> + self.assertFalse(box.showing, msg="Network Config box should not be displayed")
> +
> + box = self.find("More Network Config Box")
> + self.assertIsNotNone(box, "More Network Config box not found")
> + self.assertFalse(box.showing, msg="More Network Config box should not be displayed")
> +
> + entry = self.find("Hostname", "textentry")
> + self.assertIsNotNone(entry , "Hostname entry not found")
> + self.assertTrue(entry.showing, msg="Hostname entry should be displayed")
> +
> + def _run(self):
> + # First, we need to click on the network spoke selector.
> + self.enter_spoke("NETWORK & HOSTNAME")
> +
> + # Now verify we are on the right screen.
> + self.check_window_displayed("NETWORK & HOSTNAME")
> +
> + # And now we can check everything else on the screen.
> + self.check_hostname_entry()
> +
> + # And then we click the Done button to go back to the hub, verifying
> + # that's where we ended up.
> + self.exit_spoke()
> diff --git a/tests/gui/inside/progress.py b/tests/gui/inside/progress.py
> new file mode 100644
> index 0000000..8f48b4b
> --- /dev/null
> +++ b/tests/gui/inside/progress.py
> @@ -0,0 +1,122 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +import signal
> +
> +from dogtail.predicate import GenericPredicate
> +from dogtail.utils import doDelay
> +from . import UITestCase
> +
> +class LiveCDProgressTestCase(UITestCase):
> + def check_begin_installation_button(self):
> + button = self.find("Begin Installation", "button")
> + self.assertIsNotNone(button, msg="Begin Installation button does not exist")
> + self.assertTrue(button.sensitive, msg="Begin Installation button should be sensitive")
> +
> + def check_shown_spoke_selectors(self):
> + # FIXME: This forces English.
> + validSelectors = ["ROOT PASSWORD", "USER CREATION"]
> + selectors = self.ana.findChildren(GenericPredicate(roleName="spoke selector"))
> +
> + self.assertEqual(len(selectors), len(validSelectors), msg="Incorrect number of spoke selectors shown")
> +
> + # Validate that only the spoke selectors we expect are shown. At the same time,
> + # we can also validate the status of each selector. This only validates the
> + # initial state of everything. Once we start clicking on spokes, things are
> + # going to change.
> + # FIXME: This encodes default information.
> + for selector in selectors:
> + if selector.name == "ROOT PASSWORD":
> + self.assertEqual(selector.description, "Root password is not set")
> + elif selector.name == "USER CREATION":
> + self.assertEqual(selector.description, "No user will be created")
> + else:
> + self.fail("Invalid spoke selector shown on livecd: %s" % selector.name)
> +
> + def _timer_expired(self):
This should take signum and frame arguments.
(https://docs.python.org/2/library/signal.html#signal.signal)
> + self.fail("anaconda did not finish in 30 minutes")
> +
> + def _run(self):
> + # Before doing anything, verify we are still on the summary hub.
> + self.check_window_displayed("INSTALLATION SUMMARY")
> + # All spokes should have been visited and satisfied now.
> + self.check_no_warning_bar()
> + self.check_begin_installation_button()
> +
> + # Click the begin installation button, wait a moment, and now we should
> + # be on the progress hub.
> + self.click_button("Begin Installation")
> + self.check_window_displayed("CONFIGURATION")
> +
> + self.check_shown_spoke_selectors()
> + self.check_warning_bar()
> +
> + # Now we need to wait for installation to finish. We're doing that two ways:
> + # (1) Set a 30 minute timeout. Should we hit that, anaconda's clearly not
> + # going to finish (or is hung or something) and we should fail.
> + # (2) Poll the UI waiting for the "Complete!" message to pop up. Once
> + # that happens, we know it's time to test the user settings stuff and
> + # the end of the UI.
> + signal.signal(signal.SIGALRM, self._timer_expired)
> + signal.alarm(30*60)
> +
> + while True:
> + label = self.find("Complete!")
> + if label:
> + signal.alarm(0)
> + break
> +
> + doDelay(20)
> +
> + # If we got here, installation completed successfully. Since we've not
> + # done a password or created a user yet, we still have to do that. The
> + # finish configuration button should still be insensitive.
> + button = self.find("Finish configuration", "button")
> + self.assertIsNotNone(button, msg="Finish configuration button not found")
> + self.assertFalse(button.sensitive, msg="Finish Configuration button should not be sensitive")
> +
> +class LiveCDFinishTestCase(UITestCase):
> + def check_finish_config_button(self):
> + # Click the Finish Configuration button.
> + self.click_button("Finish configuration")
> +
> + def _timer_expired(self):
Same here.
> + self.fail("anaconda did not finish in 5 minutes")
> +
> + def _run(self):
> + # Before doing anything, verify we are still on the progress hub.
> + self.check_window_displayed("CONFIGURATION")
> +
> + self.check_finish_config_button()
> + # We've completed configuration, so the warning bar should be gone.
> + self.check_no_warning_bar()
> +
> + # And now we wait for configuration to finish. Then we check the
> + # reboot button, but don't click it. The end of the test case shuts
> + # down the system for us.
> + signal.signal(signal.SIGALRM, self._timer_expired)
> + signal.alarm(5*60)
> +
> + while True:
> + button = self.find("Quit", "button")
> + if button:
> + signal.alarm(0)
> + break
> +
> + doDelay(20)
> +
> + self.click_button("Quit")
> diff --git a/tests/gui/inside/rootpassword.py b/tests/gui/inside/rootpassword.py
> new file mode 100644
> index 0000000..d982ab7
> --- /dev/null
> +++ b/tests/gui/inside/rootpassword.py
> @@ -0,0 +1,66 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from dogtail.utils import doDelay
> +
> +from . import UITestCase
> +
> +class BasicRootPasswordTestCase(UITestCase):
> + def check_enter_password(self):
> + # The warning bar starts off telling us there's no password set.
> + self.check_warning_bar("The password is empty")
> +
> + entry = self.find("Password", "textentry")
> + self.assertIsNotNone(entry, msg="Password entry should be displayed")
> + entry.grabFocus()
> + entry.text = "asdfasdf"
> +
> + # That wasn't a very good password and we haven't confirmed it, so the
> + # bar is still displayed at the bottom.
> + doDelay(1)
> + self.check_warning_bar("it does not contain enough DIFFERENT characters")
> +
> + # Let's confirm that terrible password.
> + entry = self.find("Confirm Password", "textentry")
> + self.assertIsNotNone(entry, msg="Confirm password should be displayed")
> + entry.grabFocus()
> + entry.text = "asdfasdf"
> +
> + # But of course it's still a terrible password, so the bar is still
> + # displayed at the bottom.
> + doDelay(1)
> + self.check_warning_bar("it does not contain enough DIFFERENT characters")
> +
> + def check_click_done(self):
> + # Press the Done button once, which won't take us anywhere but will change the
> + # warning label at the bottom.
> + self.click_button("Done")
> + self.check_warning_bar("Press Done again")
> +
> + # Pressing Done again should take us back to the progress hub.
> + self.exit_spoke(hubName="CONFIGURATION")
> +
> + def _run(self):
> + # First, we need to click on the spoke selector.
> + self.enter_spoke("ROOT PASSWORD")
> +
> + # Now, verify we are on the right screen.
> + self.check_window_displayed("ROOT PASSWORD")
> +
> + # And now we can check everything else on the screen.
> + self.check_enter_password()
> + self.check_click_done()
> diff --git a/tests/gui/inside/storage.py b/tests/gui/inside/storage.py
> new file mode 100644
> index 0000000..3b20cbf
> --- /dev/null
> +++ b/tests/gui/inside/storage.py
> @@ -0,0 +1,101 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from dogtail.predicate import GenericPredicate
> +
> +from . import UITestCase
> +
> +class BasicStorageTestCase(UITestCase):
> + def check_select_disks(self):
> + # FIXME: This is a really roundabout way of determining whether a disk is
> + # selected or not. For some reason when a DiskOverview is selected, its icon
> + # loses the name "Hard Disk". For now, we can use this to check.
> + def _selected(do):
> + return len(do.findChildren(GenericPredicate(name="Hard Disk"))) == 0
> +
> + # A real disk is any disk attached to the VM that's not the special 10 MB
> + # one used for communicating in and out of the VM. Therefore, scan all
> + # children of the DiskOverview looking for one whose name contains that
> + # size.
> + def _real_disk(do):
> + for child in do.findChildren(GenericPredicate()):
> + if child.name.startswith("10."):
> + return False
> +
> + return True
> +
> + # There should be some disks displayed on the screen.
> + overviews = self.ana.findChildren(GenericPredicate(roleName="disk overview"))
> + self.assertGreater(len(overviews), 0, msg="No disks are displayed")
> +
> + if len(overviews) == 1:
> + # Only one disk was given to this test case, so anaconda selected it
> + # by default. Verify. Not sure how this would happen with how
> + # testing is currently done, but it's good to be prepared.
> + self.assertTrue(_selected(overviews[0]))
> + else:
> + # More than one disk was provided, so anaconda did not select any.
> + # Let's select all disks and proceed.
> + for overview in filter(_real_disk, overviews):
> + self.assertFalse(_selected(overview))
> + overview.click()
> + self.assertTrue(_selected(overview))
> +
> + def check_shopping_cart(self):
> + pass
> +
> + def check_storage_options(self):
> + button = self.find("Automatically configure partitioning.", "button")
> + self.assertIsNotNone(button, msg="Autopart button not found")
> + self.assertTrue(button.checked, msg="Autopart should be selected")
> +
> + button = self.find("I would lke to make additional space available.", "button")
TYPO: ^^ like
> + self.assertIsNotNone(button, msg="Reclaim button not found")
> + self.assertFalse(button.checked, msg="Reclaim button should not be selected")
> +
> + button = self.find("Encrypt my data.", "button")
> + self.assertIsNotNone(button, msg="Encrypt button not found")
> + self.assertFalse(button.checked, msg="Encrypt button should not be selected")
> +
> + def _run(self):
> + # First, we need to click on the network spoke selector.
> + self.enter_spoke("INSTALLATION DESTINATION")
> +
> + # Now verify we are on the right screen.
> + self.check_window_displayed("INSTALLATION DESTINATION")
> +
> + # Given that we attach a second disk to the system (for storing the test
> + # suite and results), anaconda will not select disks by default. Thus,
> + # the storage options panel should currently be insensitive.
> + button = self.find("Storage Options", "button")
> + self.assertIsNotNone(button, "Storage Options button not found")
> + self.assertFalse(button.sensitive, msg="Storage options should be insensitive")
> +
> + # Select disk overviews. In the basic case, this means uninitialized
> + # disks that we're going to do autopart on.
> + self.check_select_disks()
> +
> + # And now with disks selected, the storage options should be sensitive.
> + self.assertTrue(button.sensitive, msg="Storage options should be sensitive")
> +
> + self.check_shopping_cart()
> + self.check_storage_options()
> +
> + # And then we click the Done button which should take the user right back to
> + # the hub. There's no need to display any other dialogs given that this is
> + # an install against empty disks and no other options were checked.
> + self.exit_spoke()
> diff --git a/tests/gui/inside/summary.py b/tests/gui/inside/summary.py
> new file mode 100644
> index 0000000..98988cf
> --- /dev/null
> +++ b/tests/gui/inside/summary.py
> @@ -0,0 +1,81 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +from dogtail.predicate import GenericPredicate
> +from dogtail.utils import doDelay
> +
> +from . import UITestCase
> +
> +# This test case handles the livecd case on the summary hub where everything
> +# works as intended. On this spoke, we are testing the following:
> +#
> +# * Clicking the Quit button brings up a dialog asking if you're sure, though
> +# we're not going to test that confirming actually quits.
> +# * The Begin Installation button is insensitive, since no disks have yet
> +# been selected.
> +# * Only the Date & Time, Keyboard, Installation Destination, and Network Config
> +# spoke selectors are visible.
> +
> +class LiveCDSummaryTestCase(UITestCase):
> + def check_quit_button(self):
> + self.click_button("Quit")
> + self.check_dialog_displayed("Quit")
> + self.click_button("No")
> +
> + def check_begin_installation_button(self):
> + button = self.find("Begin Installation", "button")
> + self.assertIsNotNone(button, msg="Begin Installation button not found")
> + self.assertTrue(button.showing, msg="Begin Installation button should be displayed")
> + self.assertFalse(button.sensitive, msg="Begin Installation button should not be sensitive")
> +
> + def check_shown_spoke_selectors(self):
> + # FIXME: This forces English.
> + validSelectors = ["DATE & TIME", "KEYBOARD", "INSTALLATION DESTINATION", "NETWORK & HOSTNAME"]
How does those ampersands work with the & entities used in the
previous patches?
> + selectors = self.ana.findChildren(GenericPredicate(roleName="spoke selector"))
> +
> + self.assertEqual(len(selectors), len(validSelectors), msg="Incorrect number of spoke selectors shown")
> +
> + # Validate that only the spoke selectors we expect are shown. At the same time,
> + # we can also validate the status of each selector. This only validates the
> + # initial state of everything. Once we start clicking on spokes, things are
> + # going to change.
> + # FIXME: This encodes default information.
> + for selector in selectors:
> + if selector.name == "DATE & TIME":
> + self.assertEqual(selector.description, "Americas/New York timezone")
> + elif selector.name == "KEYBOARD":
> + self.assertEqual(selector.description, "English (US)")
> + elif selector.name == "INSTALLATION DESTINATION":
> + # We don't know how many disks are going to be involved - if there's
> + # just one, anaconda selects it by default. If there's more than
> + # one, it selects none.
> + self.assertIn(selector.description, ["Automatic partitioning selected",
> + "No disks selected"])
> + elif selector.name == "NETWORK & HOSTNAME":
> + self.assertRegexpMatches(selector.description, "Wired (.+) connected")
> + else:
> + self.fail("Invalid spoke selector shown on livecd: %s" % selector.name)
> +
> + def _run(self):
> + # Before doing anything, verify we are on the right screen.
> + doDelay(5)
> + self.check_window_displayed("INSTALLATION SUMMARY")
> +
> + # And now we can check everything else on the screen.
> + self.check_quit_button()
> + self.check_begin_installation_button()
> + self.check_shown_spoke_selectors()
> + self.check_warning_bar()
> diff --git a/tests/gui/inside/user.py b/tests/gui/inside/user.py
> new file mode 100644
> index 0000000..70f2744
> --- /dev/null
> +++ b/tests/gui/inside/user.py
> @@ -0,0 +1,74 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from . import UITestCase
> +
> +class BasicUserTestCase(UITestCase):
> + def check_enter_name(self):
> + entry = self.find("Full Name", "textentry")
> + self.assertIsNotNone(entry, msg="Full name entry not found")
> + entry.grabFocus()
> + entry.text = "Bobby Good User"
> +
> + # We base the username on the full name entered
> + entry = self.find("Username", "textentry")
> + self.assertIsNotNone(entry, msg="Username entry not found")
> + self.assertEqual(entry.text, "buser", msg="Generated username does not match expectation")
> +
> + def check_enter_password(self):
> + # The warning bar starts off telling us there's no password set.
> + self.check_warning_bar("The password is empty")
> +
> + entry = self.find("Password", "textentry")
> + self.assertIsNotNone(entry, msg="Password entry should be displayed")
> + entry.grabFocus()
> + entry.text = "asdfasdf"
> +
> + # That wasn't a very good password and we haven't confirmed it, so the
> + # bar is still displayed at the bottom.
> + self.check_warning_bar("it does not contain enough DIFFERENT characters")
> +
> + # Let's confirm that terrible password.
> + entry = self.find("Confirm Password", "textentry")
> + self.assertIsNotNone(entry, msg="Confirm password should be displayed")
> + entry.grabFocus()
> + entry.text = "asdfasdf"
> +
> + # But of course it's still a terrible password, so the bar is still
> + # displayed at the bottom.
> + self.check_warning_bar("it does not contain enough DIFFERENT characters")
> +
> + def check_click_done(self):
> + # Press the Done button once, which won't take us anywhere but will change the
> + # warning label at the bottom.
> + self.click_button("Done")
> + self.check_warning_bar("Press Done again")
> +
> + # Pressing Done again should take us back to the progress hub.
> + self.exit_spoke(hubName="CONFIGURATION")
> +
> + def _run(self):
> + # First, we need to click on the spoke selector.
> + self.enter_spoke("USER CREATION")
> +
> + # Now, verify we are on the right screen.
> + self.check_window_displayed("CREATE USER")
> +
> + # And now we can check everything else on the screen.
> + self.check_enter_name()
> + self.check_enter_password()
> + self.check_click_done()
> diff --git a/tests/gui/inside/welcome.py b/tests/gui/inside/welcome.py
> new file mode 100644
> index 0000000..bbe7185
> --- /dev/null
> +++ b/tests/gui/inside/welcome.py
> @@ -0,0 +1,64 @@
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +from . import UITestCase
> +
> +# This test case handles the basic case on the welcome language spoke where
> +# everything works as intended. On this spoke, we are testing the following:
> +#
> +# * The default language and locale are selected and displayed at the top
> +# of their views.
> +# * Clicking the Quit button brings up a dialog asking if you're sure, though
> +# we're not going to test that confirming actually quits.
> +# * Clicking the Continue button brings up the betanag dialog, though we're
> +# not going to test the quit button there either.
> +#
> +# TODO:
> +# * Entering text into the search box should result in narrowing down the
> +# contents of the left hand view.
> +
> +class BasicWelcomeTestCase(UITestCase):
> + def check_lang_locale_views(self):
> + # FIXME: This encodes default information.
> + lang = "English"
> + locale = "English (United States)"
> +
> + return
> +# self.assertEqual(ldtp.verifytablecell("frmWelcome", "tblLanguages", 0, 0, lang), 1,
> +# msg="Default language (%s) is not selected" % lang)
> +# self.assertEqual(ldtp.verifytablecell("frmWelcome", "tblLocales", 0, 0, locale), 1,
> +# msg="Default locale (%s) is not selected")
> +
> + def check_quit_button(self):
> + self.click_button("Quit")
> + self.check_dialog_displayed("Quit")
> + self.click_button("No")
> +
> + def check_continue_button(self):
> + self.click_button("Continue")
> + self.check_dialog_displayed("Beta Warn")
> + self.click_button("I accept my fate.")
> +
> + def _run(self):
> + # Before doing anything, verify we are on the right screen.
> + self.check_window_displayed("WELCOME")
> +
> + # And now we can check everything else on the screen.
> + self.check_keyboard_layout_indicator("us")
> + self.check_lang_locale_views()
> + self.check_quit_button()
> + self.check_continue_button()
> diff --git a/tests/gui/outside/__init__.py b/tests/gui/outside/__init__.py
> new file mode 100644
> index 0000000..6d5265b
> --- /dev/null
> +++ b/tests/gui/outside/__init__.py
> @@ -0,0 +1,280 @@
> +#!/usr/bin/python
> +#
> +# Copyright (C) 2014 Red Hat, Inc.
> +#
> +# This program is free software; you can redistribute it and/or modify
> +# it under the terms of the GNU Lesser General Public License as published
> +# by the Free Software Foundation; either version 2.1 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +#
> +# Author: Chris Lumens <clumens at redhat.com>
> +
> +__all__ = ["Creator", "OutsideMixin"]
> +
> +# Can't import from a directory with a dash in its name. This will have to do.
> +execfile("/usr/share/doc/python-blivet/examples/common.py")
> +
> +import blivet
> +
> +from contextlib import contextmanager
> +import os
> +import shutil
> +import subprocess
> +import tempfile
> +import unittest
> +
> +class Creator(object):
> + """A Creator subclass defines all the parameters for making a VM to run a
> + test against as well as handles creating and running that VM, inspecting
> + results, and managing temporary data.
> +
> + Most Creator subclasses will only need to define the following four
> + attributes:
> +
> + drives -- A list of tuples describing disk images to create. Each
> + tuple is the name and size, in GB.
> + name -- A unique string that names a Creator. This name will
> + be used in creating the results directory (and perhaps
> + other places in the future) so make sure it doesn't
> + conflict with another object.
> + reqMemory -- The amount of memory this VM needs, in MB. The default
> + is 2048, which seems to be the minimum required to
> + make things run quickly. Redefine if you need more.
> + tests -- A list of tuples describing which test cases make up
> + this test. Each tuple is the name of the module
> + containing the test case (minus the leading "inside."
> + and the name of the test case class. Tests will be
> + run in the order provided.
> + """
> + drives = []
> + name = "Creator"
> + reqMemory = 2048
> + tests = []
> +
> + def __init__(self):
> + self._drivePaths = {}
> + self._mountpoint = None
> + self._proc = None
> + self._tempdir = None
> +
> + def archive(self):
> + """Copy all log files and other test results to a subdirectory of the
> + given resultsdir. If logs are no longer available, this method
> + does nothing. It is up to the caller to make sure logs are available
> + beforehand and clean up afterwards.
> + """
> + from testconfig import config
> +
> + if not os.path.ismount(self.mountpoint):
> + return
> +
> + shutil.copytree(self.mountpoint + "/result", config["resultsdir"] + "/" + self.name)
> +
> + def cleanup(self):
> + """Remove all disk images used during this test case and the temporary
> + directory they were stored in.
> + """
> + shutil.rmtree(self.tempdir, ignore_errors=True)
> + os.unlink(self._drivePaths["suite"])
> +
> + def die(self):
> + """Kill any running qemu process previously started by this test."""
> + if self._proc:
> + self._proc.kill()
> + self._proc = None
> +
> + def makeDrives(self):
> + """Create all hard drive images associated with this test. Images
> + must be listed in Creator.drives and will be stored in a temporary
> + directory this method creates. It is up to the caller to remove
> + everything later by calling Creator.cleanup.
> + """
> + for (drive, size) in self.drives:
> + (fd, diskimage) = tempfile.mkstemp(dir=self.tempdir)
> + os.close(fd)
> +
> + subprocess.call(["/usr/bin/qemu-img", "create", "-f", "qcow2", diskimage, "%sG" % size],
> + stdout=open("/dev/null", "w"))
> + self._drivePaths[drive] = diskimage
> +
> + @property
> + def template(self):
> + with open("outside/template.py", "r") as f:
> + return f.read()
> +
> + def makeSuite(self):
> + """The suite is a small disk image attached to every test VM automatically
> + by the test framework. It includes all the inside/ stuff, a special
> + suite.py file that will be automatically run by the live CD (and is
> + what actually runs the test), and a directory structure for reporting
> + results.
> +
> + It is mounted under Creator.mountpoint as needed.
> +
> + This method creates the suite image and adds it to the internal list of
> + images associated with this test.
> +
> + Note that because this image is attached to the VM, anaconda will always
> + see two hard drives and thus will never automatically select disks.
> + Note also that this means tests must be careful to not select this
> + disk.
> + """
> + from testconfig import config
> +
> + # First, create a disk image and put a filesystem on it.
> + b = blivet.Blivet()
> +
> + # pylint: disable-msg=E0602
> + disk1_path = create_sparse_file(b, "suite", blivet.size.Size(spec="11Mb"))
I guess it means the same in blivet, but this should probably be 11 MB^^
> + b.config.diskImages["suite"] = disk1_path
> +
> + b.reset()
> +
> + try:
> + disk1 = b.devicetree.getDeviceByName("suite")
> + b.initializeDisk(disk1)
> +
> + part = b.newPartition(size=blivet.size.Size(spec="10Mb"), parents=[disk1])
Same on this line.
--
Vratislav Podzimek
Anaconda Rider | Red Hat, Inc. | Brno - Czech Republic
More information about the anaconda-patches
mailing list