./0000755000004100000410000000000014002063567011246 5ustar www-datawww-data./autopilot/0000755000004100000410000000000014002063567013266 5ustar www-datawww-data./autopilot/display/0000755000004100000410000000000014002063564014730 5ustar www-datawww-data./autopilot/display/_screenshot.py0000644000004100000410000001135514002063564017623 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """This module contains support for capturing screenshots.""" import logging import os import subprocess import time import tempfile from io import BytesIO from PIL import Image import autopilot._glib logger = logging.getLogger(__name__) def get_screenshot_data(display_type): """Return a BytesIO object of the png data for the screenshot image. *display_type* is the display server type. supported values are: - "X11" - "MIR" :raises RuntimeError: If attempting to capture an image on an unsupported display server. :raises RuntimeError: If saving image data to file-object fails. """ if display_type == "MIR": return _get_screenshot_mir() elif display_type == "X11": return _get_screenshot_x11() else: raise RuntimeError( "Don't know how to take screen shot for this display server: {}" .format(display_type) ) def _get_screenshot_x11(): """Capture screenshot from an X11 display. :raises RuntimeError: If saving pixbuf to fileobject fails. """ pixbuf_data = _get_x11_pixbuf_data() return _save_gdk_pixbuf_to_fileobject(pixbuf_data) def _get_x11_pixbuf_data(): Gdk = autopilot._glib._import_gdk() window = Gdk.get_default_root_window() x, y, width, height = window.get_geometry() return Gdk.pixbuf_get_from_window(window, x, y, width, height) def _save_gdk_pixbuf_to_fileobject(pixbuf): image_data = pixbuf.save_to_bufferv("png", [], []) if image_data[0] is True: image_datafile = BytesIO() image_datafile.write(image_data[1]) image_datafile.seek(0) return image_datafile logger.error("Unable to write image data.") raise RuntimeError("Failed to save image data to file object.") def _get_screenshot_mir(): """Capture screenshot from Mir display. :raises FileNotFoundError: If the mirscreencast utility is not found. :raises CalledProcessError: If the mirscreencast utility errors while taking a screenshot. :raises ValueError: If the PNG conversion step fails. """ from autopilot.display import Display display_resolution = Display.create().get_screen_geometry(0)[2:] screenshot_filepath = _take_mirscreencast_screenshot() try: png_data_file = _get_png_from_rgba_file( screenshot_filepath, display_resolution ) finally: os.remove(screenshot_filepath) return png_data_file def _take_mirscreencast_screenshot(): """Takes a single frame capture of the screen using mirscreencast. Return the path to the resulting rgba file. :raises FileNotFoundError: If the mirscreencast utility is not found. :raises CalledProcessError: If the mirscreencast utility errors while taking a screenshot. """ timestamp = int(time.time()) filename = "ap-screenshot-data-{ts}.rgba".format(ts=timestamp) filepath = os.path.join(tempfile.gettempdir(), filename) try: subprocess.check_call([ "mirscreencast", "-m", "/run/mir_socket", "-n", "1", "-f", filepath ]) except FileNotFoundError as e: e.args += ("The utility 'mirscreencast' is not available.", ) raise except subprocess.CalledProcessError as e: e.args += ("Failed to take screenshot.", ) raise return filepath # This currently uses PIL but I'm investigating using something # quicker/lighter-weight. def _get_png_from_rgba_file(filepath, image_size): """Convert an rgba file to a png file stored in a filelike object. Returns a BytesIO object containing the png data. """ image_data = _image_data_from_file(filepath, image_size) bio = BytesIO() image_data.save(bio, format="png") bio.seek(0) return bio def _image_data_from_file(filepath, image_size): with open(filepath, "rb") as f: image_data = Image.frombuffer( "RGBA", image_size, f.read(), "raw", "RGBA", 0, 1 ) return image_data ./autopilot/display/_upa.py0000644000004100000410000000625014002063564016231 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os import subprocess from autopilot.display import Display as DisplayBase from autopilot.platform import get_display_server, image_codename DISPLAY_SERVER_X11 = 'X11' DISPLAY_SERVER_MIR = 'MIR' ENV_MIR_SOCKET = 'MIR_SERVER_HOST_SOCKET' def query_resolution(): display_server = get_display_server() if display_server == DISPLAY_SERVER_X11: return _get_resolution_from_xrandr() elif display_server == DISPLAY_SERVER_MIR: return _get_resolution_from_mirout() else: return _get_hardcoded_resolution() def _get_hardcoded_resolution(): name = image_codename() resolutions = { "Aquaris_M10_HD": (800, 1280), "Desktop": (1920, 1080) } if name not in resolutions: raise NotImplementedError( 'Device "{}" is not supported by Autopilot.'.format(name)) return resolutions[name] def _get_stdout_for_command(command, *args): full_command = [command] full_command.extend(args) return subprocess.check_output( full_command, universal_newlines=True, stderr=subprocess.DEVNULL, ).split('\n') def _get_resolution(server_output): relevant_line = list(filter(lambda line: '*' in line, server_output))[0] if relevant_line: return tuple([int(i) for i in relevant_line.split()[0].split('x')]) raise ValueError( 'Failed to get display resolution, is a display connected?' ) def _get_resolution_from_xrandr(): return _get_resolution(_get_stdout_for_command('xrandr', '--current')) def _get_resolution_from_mirout(): return _get_resolution( _get_stdout_for_command('mirout', os.environ.get(ENV_MIR_SOCKET)) ) class Display(DisplayBase): """The base class/inteface for the display devices""" def __init__(self): super(Display, self).__init__() self._X, self._Y = query_resolution() def get_num_screens(self): """Get the number of screens attached to the PC.""" return 1 def get_primary_screen(self): """Returns an integer of which screen is considered the primary""" return 0 def get_screen_width(self): return self._X def get_screen_height(self): return self._Y def get_screen_geometry(self, screen_number): """Get the geometry for a particular screen. :return: Tuple containing (x, y, width, height). """ return 0, 0, self._X, self._Y ./autopilot/display/__init__.py0000644000004100000410000001122714002063564017044 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """The display module contaions support for getting screen information.""" from collections import OrderedDict from autopilot.utilities import _pick_backend from autopilot.input import Mouse from autopilot.display._screenshot import get_screenshot_data __all__ = [ "Display", "get_screenshot_data", "is_rect_on_screen", "is_point_on_screen", "is_point_on_any_screen", "move_mouse_to_screen", ] def is_rect_on_screen(screen_number, rect): """Return True if *rect* is **entirely** on the specified screen, with no overlap.""" x, y, w, h = rect mx, my, mw, mh = Display.create().get_screen_geometry(screen_number) return x >= mx and x + w <= mx + mw and y >= my and y + h <= my + mh def is_point_on_screen(screen_number, point): """Return True if *point* is on the specified screen. *point* must be an iterable type with two elements: (x, y) """ x, y = point mx, my, mw, mh = Display.create().get_screen_geometry(screen_number) return mx <= x < mx + mw and my <= y < my + mh def is_point_on_any_screen(point): """Return true if *point* is on any currently configured screen.""" return any([is_point_on_screen(m, point) for m in range(Display.create().get_num_screens())]) def move_mouse_to_screen(screen_number): """Move the mouse to the center of the specified screen.""" geo = Display.create().get_screen_geometry(screen_number) x = geo[0] + (geo[2] / 2) y = geo[1] + (geo[3] / 2) # dont animate this or it might not get there due to barriers Mouse.create().move(x, y, False) class Display(object): """The base class/inteface for the display devices.""" @staticmethod def create(preferred_backend=''): """Get an instance of the Display class. For more infomration on picking specific backends, see :ref:`tut-picking-backends` :param preferred_backend: A string containing a hint as to which backend you would like. possible backends are: * ``X11`` - Get display information from X11. * ``UPA`` - Get display information from the ubuntu platform API. :raises: RuntimeError if autopilot cannot instantate any of the possible backends. :raises: RuntimeError if the preferred_backend is specified and is not one of the possible backends for this device class. :raises: :class:`~autopilot.BackendException` if the preferred_backend is set, but that backend could not be instantiated. :returns: Instance of Display with appropriate backend. """ def get_x11_display(): from autopilot.display._X11 import Display return Display() def get_upa_display(): from autopilot.display._upa import Display return Display() backends = OrderedDict() backends['X11'] = get_x11_display backends['UPA'] = get_upa_display return _pick_backend(backends, preferred_backend) class BlacklistedDriverError(RuntimeError): """Cannot set primary monitor when running drivers listed in the driver blacklist.""" def get_num_screens(self): """Get the number of screens attached to the PC.""" raise NotImplementedError("You cannot use this class directly.") def get_primary_screen(self): raise NotImplementedError("You cannot use this class directly.") def get_screen_width(self, screen_number=0): raise NotImplementedError("You cannot use this class directly.") def get_screen_height(self, screen_number=0): raise NotImplementedError("You cannot use this class directly.") def get_screen_geometry(self, monitor_number): """Get the geometry for a particular monitor. :return: Tuple containing (x, y, width, height). """ raise NotImplementedError("You cannot use this class directly.") ./autopilot/display/_X11.py0000644000004100000410000000500614002063564016013 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import autopilot._glib from autopilot.display import Display as DisplayBase class Display(DisplayBase): def __init__(self): # Note: MUST import these here, rather than at the top of the file. # Why? Because sphinx imports these modules to build the API # documentation, which in turn tries to import Gdk, which in turn # fails because there's no DISPLAY environment set in the package # builder. Gdk = autopilot._glib._import_gdk() self._default_screen = Gdk.Screen.get_default() if self._default_screen is None: raise RuntimeError( "Unable to determine default screen information") self._blacklisted_drivers = ["NVIDIA"] def get_num_screens(self): """Get the number of screens attached to the PC. :returns: int indicating number of screens attached. """ return self._default_screen.get_n_monitors() def get_primary_screen(self): """Return an integer of which screen is considered the primary.""" return self._default_screen.get_primary_monitor() def get_screen_width(self, screen_number=0): return self.get_screen_geometry(screen_number)[2] def get_screen_height(self, screen_number=0): return self.get_screen_geometry(screen_number)[3] def get_screen_geometry(self, screen_number): """Get the geometry for a particular screen. :return: Tuple containing (x, y, width, height). """ if screen_number < 0 or screen_number >= self.get_num_screens(): raise ValueError('Specified screen number is out of range.') rect = self._default_screen.get_monitor_geometry(screen_number) return (rect.x, rect.y, rect.width, rect.height) ./autopilot/_timeout.py0000644000004100000410000000666014002063564015472 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Autopilot timeout functions. Autopilot frequently needs to repeatedly run code within a certain timeout period. Historically this was done with a simple ``for`` loop and a ``sleep`` statement, like this:: for i in range(10): # do some polling code here time.sleep(1) The problem with this approach is that we hard-code both the absolute timeout value (10 seconds in this case), as well as the time to sleep after each poll. When Autopilot runs on certain platforms, we need to globally increase the timeout period. We'd also like to be able to avoid the common pitfall of forgetting to call ``time.sleep``. Finally, we support mocking out the ``sleep`` call, so autopilot tests can run quickly and verify the polling behavior of low-level function calls. """ from autopilot.utilities import sleep from autopilot.globals import ( get_default_timeout_period, get_long_timeout_period, ) class Timeout(object): """Class for starting different timeout loops. This class contains two static methods. Each method is a generator, and provides a timeout for a different period of time. For example, to generate a short polling timeout, the code would look like this:: for elapsed_time in Timeout.default(): # polling code goes here the ``elapsed_time`` variable will contain the amount of time elapsed, in seconds, since the beginning of the loop, although this is not guaranteed to be accurate. """ @staticmethod def default(): """Start a polling loop with the default timeout. This is the polling loop that should be used by default (hence the name) unless the operation is known to take a very long time, especially on slow or virtualised hardware. """ timeout = float(get_default_timeout_period()) # Once we only support py3.3, replace this with # yield from _do_timeout(timeout) for i in _do_timeout(timeout): yield i @staticmethod def long(): """Start a polling loop with a long timeout. This is the polling loop that should be used for operations that are known to take extra long on slow, or virtualised hardware. """ timeout = float(get_long_timeout_period()) # Once we only support py3.3, replace this with # yield from _do_timeout(timeout) for i in _do_timeout(timeout): yield i def _do_timeout(timeout): time_elapsed = 0.0 while timeout - time_elapsed > 0.0: yield time_elapsed time_to_sleep = min(timeout - time_elapsed, 1.0) sleep(time_to_sleep) time_elapsed += time_to_sleep yield time_elapsed ./autopilot/exceptions.py0000644000004100000410000000675314002063567016034 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Autopilot Exceptions. This module contains exceptions that autopilot may raise in various conditions. Each exception is documented with when it is raised: a generic description in this module, as well as a detailed description in the function or method that raises it. """ class BackendException(RuntimeError): """An error occured while trying to initialise an autopilot backend.""" def __init__(self, original_exception): super(BackendException, self).__init__( "Error while initialising backend. Original exception was: " + str(original_exception)) self.original_exception = original_exception class ProcessSearchError(RuntimeError): """Object introspection error occured.""" pass class StateNotFoundError(RuntimeError): """Raised when a piece of state information is not found. This exception is commonly raised when the application has destroyed (or not yet created) the object you are trying to access in autopilot. This typically happens for a number of possible reasons: * The UI widget you are trying to access with :py:meth:`~autopilot.introspection.ProxyBase.select_single` or :py:meth:`~autopilot.introspection.ProxyBase.wait_select_single` or :py:meth:`~autopilot.introspection.ProxyBase.select_many` does not exist yet. * The UI widget you are trying to access has been destroyed by the application. """ def __init__(self, class_name=None, **filters): """Construct a StateNotFoundError. :raises ValueError: if neither the class name not keyword arguments are specified. """ if class_name is None and not filters: raise ValueError("Must specify either class name or filters.") if class_name is None: self._message = \ "Object not found with properties {}.".format( repr(filters) ) elif not filters: self._message = "Object not found with name '{}'.".format( class_name ) else: self._message = \ "Object not found with name '{}' and properties {}.".format( class_name, repr(filters) ) _troubleshoot_url_message = ( 'Tips on minimizing the occurrence of this failure ' 'are available here: ' 'https://developer.ubuntu.com/api/autopilot/python/1.6.1/' 'faq-troubleshooting/' ) def __str__(self): return '{}\n\n{}'.format( self._message, self._troubleshoot_url_message ) class InvalidXPathQuery(ValueError): """Raised when an XPathselect query is invalid or unsupported.""" ./autopilot/run.py0000644000004100000410000006261514002063564014453 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from argparse import ArgumentParser, Action, REMAINDER from codecs import open from collections import OrderedDict import cProfile from datetime import datetime from imp import find_module import logging import os import os.path from platform import node from random import shuffle import subprocess import sys from unittest import TestLoader, TestSuite from testtools import iterate_tests from autopilot import get_version_string, have_vis import autopilot.globals from autopilot import _config as test_config from autopilot._debug import ( get_all_debug_profiles, get_default_debug_profile, ) from autopilot import _video from autopilot.testresult import get_default_format, get_output_formats from autopilot.utilities import DebugLogFilter, LogFormatter from autopilot.application._launcher import ( _get_app_env_from_string_hint, get_application_launcher_wrapper, launch_process, ) def _get_parser(): """Return a parser object for handling command line arguments.""" common_arguments = ArgumentParser(add_help=False) common_arguments.add_argument( '--enable-profile', required=False, default=False, action="store_true", help="Enable collection of profile data for " "autopilot itself. If enabled, profile data will be stored in " "'autopilot_.profile' in the current working directory." ) parser = ArgumentParser( description="Autopilot test tool.", epilog="Each command (run, list, launch etc.) has additional help that" " can be viewed by passing the '-h' flag to the command. For " "example: 'autopilot run -h' displays further help for the " "'run' command." ) parser.add_argument('-v', '--version', action='version', version=get_version_string(), help="Display autopilot version and exit.") subparsers = parser.add_subparsers(help='Run modes', dest="mode") parser_run = subparsers.add_parser( 'run', help="Run autopilot tests", parents=[common_arguments] ) parser_run.add_argument('-o', "--output", required=False, help='Write test result report to file.\ Defaults to stdout.\ If given a directory instead of a file will \ write to a file in that directory named: \ _.log') available_formats = get_output_formats().keys() parser_run.add_argument('-f', "--format", choices=available_formats, default=get_default_format(), required=False, help='Specify desired output format. \ Default is "text".') parser_run.add_argument("-ff", "--failfast", action='store_true', required=False, default=False, help="Stop the test run on the first error \ or failure.") parser_run.add_argument('-r', '--record', action='store_true', default=False, required=False, help="Record failing tests. Required \ 'recordmydesktop' app to be installed.\ Videos are stored in /tmp/autopilot if not \ specified with -rd option.") parser_run.add_argument("-rd", "--record-directory", required=False, type=str, help="Directory to put recorded tests") parser_run.add_argument("--record-options", required=False, type=str, help="Comma separated list of options \ to pass to recordmydesktop") parser_run.add_argument("-ro", "--random-order", action='store_true', required=False, default=False, help="Run the tests in random order") parser_run.add_argument( '-v', '--verbose', default=False, required=False, action='count', help="If set, autopilot will output test log data to stderr during a " "test run. Set twice (i.e. -vv) to also log debug level messages. " "(This can be useful for debugging autopilot itself.)") parser_run.add_argument( "--debug-profile", choices=[p.name for p in get_all_debug_profiles()], default=get_default_debug_profile().name, help="Select a profile for what additional debugging information " "should be attached to failed test results." ) parser_run.add_argument( "--timeout-profile", choices=['normal', 'long'], default='normal', help="Alter the timeout values Autopilot uses. Selecting 'long' will " "make autopilot use longer timeouts for various polling loops. This " "can be useful if autopilot is running on very slow hardware" ) parser_run.add_argument( "-c", "--config", default="", help="If set, specifies configuration " "for the test suite being run. Value should be a comma-separated list " "of values, where each value is either of the form 'key', or " "'key=value'.", dest="test_config" ) parser_run.add_argument( "--test-timeout", default=0, type=int, help="If set, autopilot will " "attempt to abort tests that have run longer than " "seconds. This is not guaranteed to succeed - several scenarios exist " "which make it impossible to abort a test case. Tests aborted will " "raise a 'TimeoutException' error." ) parser_run.add_argument("suite", nargs="+", help="Specify test suite(s) to run.") parser_list = subparsers.add_parser( 'list', help="List autopilot tests", parents=[common_arguments] ) parser_list.add_argument( "-ro", "--run-order", required=False, default=False, action="store_true", help="List tests in run order, rather than alphabetical order (the " "default).") parser_list.add_argument( "--suites", required=False, action='store_true', help="Lists only available suites, not tests contained within the " "suite.") parser_list.add_argument("suite", nargs="+", help="Specify test suite(s) to run.") if have_vis(): parser_vis = subparsers.add_parser( 'vis', help="Open the Autopilot visualiser tool", parents=[common_arguments] ) parser_vis.add_argument( '-v', '--verbose', required=False, default=False, action='count', help="Show autopilot log messages. Set twice to also log data " "useful for debugging autopilot itself.") parser_vis.add_argument( '-testability', required=False, default=False, action='store_true', help="Start the vis tool in testability " "mode. Used for self-tests only." ) parser_launch = subparsers.add_parser( 'launch', help="Launch an application with introspection enabled", parents=[common_arguments] ) parser_launch.add_argument( '-i', '--interface', choices=('Gtk', 'Qt', 'Auto'), default='Auto', help="Specify which introspection interface to load. The default" "('Auto') uses ldd to try and detect which interface to load.") parser_launch.add_argument( '-v', '--verbose', required=False, default=False, action='count', help="Show autopilot log messages. Set twice to also log data useful " "for debugging autopilot itself.") parser_launch.add_argument( 'application', action=_OneOrMoreArgumentStoreAction, type=str, nargs=REMAINDER, help="The application to launch. Can be a full path, or just an " "application name (in which case Autopilot will search for it in " "$PATH).") return parser def _parse_arguments(argv=None): """Parse command-line arguments, and return an argparse arguments object. """ parser = _get_parser() args = parser.parse_args(args=argv) # TR - 2013-11-27 - a bug in python3.3 means argparse doesn't fail # correctly when no commands are specified. # http://bugs.python.org/issue16308 if args.mode is None: parser.error("too few arguments") if 'suite' in args: args.suite = [suite.rstrip('/') for suite in args.suite] return args class _OneOrMoreArgumentStoreAction(Action): def __call__(self, parser, namespace, values, option_string=None): if len(values) == 0: parser.error( "Must specify at least one argument to the 'launch' command") setattr(namespace, self.dest, values) def setup_logging(verbose): """Configure the root logger and verbose logging to stderr.""" root_logger = get_root_logger() root_logger.setLevel(logging.INFO) if verbose == 0: set_null_log_handler(root_logger) if verbose >= 1: autopilot.globals.set_log_verbose(True) set_stderr_stream_handler(root_logger) if verbose >= 2: root_logger.setLevel(logging.DEBUG) enable_debug_log_messages() def log_autopilot_version(): root_logger = get_root_logger() root_logger.info(get_version_string()) def get_root_logger(): return logging.getLogger() def set_null_log_handler(root_logger): root_logger.addHandler(logging.NullHandler()) def set_stderr_stream_handler(root_logger): formatter = LogFormatter() stderr_handler = logging.StreamHandler(stream=sys.stderr) stderr_handler.setFormatter(formatter) root_logger.addHandler(stderr_handler) def enable_debug_log_messages(): DebugLogFilter.debug_log_enabled = True def construct_test_result(args): formats = get_output_formats() return formats[args.format]( stream=get_output_stream(args.format, args.output), failfast=args.failfast, ) def get_output_stream(format, path): """Get an output stream pointing to 'path' that's appropriate for format 'format'. :param format: A string that describes one of the output formats supported by autopilot. :param path: A path to the file you wish to write, or None to write to stdout. """ if path: log_file = _get_log_file_path(path) if format == 'xml': return _get_text_mode_file_stream(log_file) elif format == 'text': return _get_binary_mode_file_stream(log_file) else: return _get_raw_binary_mode_file_stream(log_file) else: return sys.stdout def _get_text_mode_file_stream(log_file): return open( log_file, 'w', encoding='utf-8', ) def _get_binary_mode_file_stream(log_file): return open( log_file, 'w', encoding='utf-8', errors='backslashreplace' ) def _get_raw_binary_mode_file_stream(log_file): return open( log_file, 'wb' ) def _get_log_file_path(requested_path): dirname = os.path.dirname(requested_path) if dirname != '' and not os.path.exists(dirname): os.makedirs(dirname) if os.path.isdir(requested_path): return _get_default_log_filename(requested_path) return requested_path def _get_default_log_filename(target_directory): """Return a filename that's likely to be unique to this test run.""" default_log_filename = "%s_%s.log" % ( node(), datetime.now().strftime("%d.%m.%y-%H%M%S") ) _print_default_log_path(default_log_filename) log_file = os.path.join(target_directory, default_log_filename) return log_file def _print_default_log_path(default_log_filename): print("Using default log filename: %s" % default_log_filename) def get_package_location(import_name): """Get the on-disk location of a package from a test id name. :raises ImportError: if the name could not be found. :returns: path as a string """ top_level_pkg = import_name.split('.')[0] _, path, _ = find_module(top_level_pkg, [os.getcwd()] + sys.path) return os.path.abspath( os.path.join( path, '..' ) ) def _is_testing_autopilot_module(test_names): return ( os.path.basename(sys.argv[0]) == 'autopilot' and any(t.startswith('autopilot') for t in test_names) ) def _reexecute_autopilot_using_module(): autopilot_command = [sys.executable, '-m', 'autopilot.run'] + sys.argv[1:] try: subprocess.check_call(autopilot_command) except subprocess.CalledProcessError as e: return e.returncode return 0 def _discover_test(test_name): """Return tuple of (TestSuite of found test, top_level_dir of test). :raises ImportError: if test_name isn't a valid module or test name """ loader = TestLoader() top_level_dir = get_package_location(test_name) # no easy way to figure out if test_name is a module or a test, so we # try to do the discovery first=... try: test = loader.discover( start_dir=test_name, top_level_dir=top_level_dir ) except ImportError: # and if that fails, we try it as a test id. test = loader.loadTestsFromName(test_name) return (test, top_level_dir) def _discover_requested_tests(test_names): """Return a collection of tests that are under test_names. returns a tuple containig a TestSuite of tests found and a boolean depicting wherether any difficulties were encountered while searching (namely un-importable module names). """ all_tests = [] test_package_locations = [] error_occured = False for name in test_names: try: test, top_level_dir = _discover_test(name) all_tests.append(test) test_package_locations.append(top_level_dir) except ImportError as e: _handle_discovery_error(name, e) error_occured = True _show_test_locations(test_package_locations) return (TestSuite(all_tests), error_occured) def _handle_discovery_error(test_name, exception): print("could not import package %s: %s" % (test_name, str(exception))) def _filter_tests(all_tests, test_names): """Filter a given TestSuite for tests starting with any name contained within test_names. """ requested_tests = {} for test in iterate_tests(all_tests): # The test loader returns tests that start with 'unittest.loader' if # for whatever reason the test failed to load. We run the tests without # the built-in exception catching turned on, so we can get at the # raised exception, which we print, so the user knows that something in # their tests is broken. if test.id().startswith('unittest.loader'): test_id = test._testMethodName try: test.debug() except Exception as e: print(e) else: test_id = test.id() if any([test_id.startswith(name) for name in test_names]): requested_tests[test_id] = test return requested_tests def load_test_suite_from_name(test_names): """Return a test suite object given a dotted test names. Returns a tuple containing the TestSuite and a boolean indicating wherever any issues where encountered during the loading process. """ # The 'autopilot' program cannot be used to run the autopilot test suite, # since setuptools needs to import 'autopilot.run', and that grabs the # system autopilot package. After that point, the module is loaded and # cached in sys.modules, and there's no way to unload a module in python # once it's been loaded. # # The solution is to detect whether we've been started with the 'autopilot' # application, *and* whether we're running the autopilot test suite itself, # and ≡ that's the case, we re-call autopilot using the standard # autopilot.run entry method, and exit with the sub-process' return code. if _is_testing_autopilot_module(test_names): exit(_reexecute_autopilot_using_module()) if isinstance(test_names, str): test_names = [test_names] elif not isinstance(test_names, list): raise TypeError("test_names must be either a string or list, not %r" % (type(test_names))) all_tests, error_occured = _discover_requested_tests(test_names) filtered_tests = _filter_tests(all_tests, test_names) return (TestSuite(filtered_tests.values()), error_occured) def _show_test_locations(test_directories): """Print the test directories tests have been loaded from.""" print("Loading tests from: %s\n" % ",".join(sorted(test_directories))) def _configure_debug_profile(args): for fixture_class in get_all_debug_profiles(): if args.debug_profile == fixture_class.name: autopilot.globals.set_debug_profile_fixture(fixture_class) break def _configure_timeout_profile(args): if args.timeout_profile == 'long': autopilot.globals.set_default_timeout_period(20.0) autopilot.globals.set_long_timeout_period(30.0) def _configure_test_timeout(args): autopilot.globals.set_test_timeout(args.test_timeout) def _prepare_application_for_launch(application, interface): app_path, app_arguments = _get_application_path_and_arguments(application) return _prepare_launcher_environment( interface, app_path, app_arguments ) def _get_application_path_and_arguments(application): app_name, app_arguments = _get_app_name_and_args(application) try: app_path = _get_applications_full_path(app_name) except ValueError as e: raise RuntimeError(str(e)) return app_path, app_arguments def _get_app_name_and_args(argument_list): """Return a tuple of (app_name, [app_args]).""" return argument_list[0], argument_list[1:] def _get_applications_full_path(app_name): if not _application_name_is_full_path(app_name): try: app_name = subprocess.check_output( ["which", app_name], universal_newlines=True ).strip() except subprocess.CalledProcessError: raise ValueError( "Cannot find application '%s'" % (app_name) ) return app_name def _application_name_is_full_path(app_name): return os.path.isabs(app_name) or os.path.exists(app_name) def _prepare_launcher_environment(interface, app_path, app_arguments): launcher_env = _get_application_launcher_env(interface, app_path) _raise_if_launcher_is_none(launcher_env, app_path) return launcher_env.prepare_environment(app_path, app_arguments) def _raise_if_launcher_is_none(launcher_env, app_path): if launcher_env is None: raise RuntimeError( "Could not determine introspection type to use for " "application '{app_path}'.\n" "(Perhaps use the '-i' argument to specify an interface.)".format( app_path=app_path ) ) def _get_application_launcher_env(interface, application_path): launcher_env = None if interface == 'Auto': launcher_env = _try_determine_launcher_env_or_raise(application_path) else: launcher_env = _get_app_env_from_string_hint(interface) return launcher_env def _try_determine_launcher_env_or_raise(app_name): try: return get_application_launcher_wrapper(app_name) except RuntimeError as e: # Re-format the runtime error to be more user friendly. raise RuntimeError( "Error detecting launcher: {err}\n" "(Perhaps use the '-i' argument to specify an interface.)".format( err=str(e) ) ) def _print_message_and_exit_error(msg): print(msg) exit(1) def _run_with_profiling(callable, output_file=None): if output_file is None: output_file = 'autopilot_%d.profile' % (os.getpid()) cProfile.runctx( 'callable()', globals(), locals(), filename=output_file, ) class TestProgram(object): def __init__(self, defined_args=None): """Create a new TestProgram instance. :param defined_args: If specified, must be an object representing command line arguments, as returned by the ``_parse_arguments`` function. Passing in arguments prevents argparse from parsing sys.argv. Used in testing. """ self.args = defined_args or _parse_arguments() def run(self): setup_logging(getattr(self.args, 'verbose', False)) log_autopilot_version() action = None if self.args.mode == 'list': action = self.list_tests elif self.args.mode == 'run': action = self.run_tests elif self.args.mode == 'vis': action = self.run_vis elif self.args.mode == 'launch': action = self.launch_app if action is not None: if getattr(self.args, 'enable_profile', False): _run_with_profiling(action) else: action() def run_vis(self): # importing this requires that DISPLAY is set. Since we don't always # want that requirement, do the import here: from autopilot.vis import vis_main # XXX - in quantal, overlay scrollbars make this process consume 100% # of the CPU. It's a known bug: # # bugs.launchpad.net/ubuntu/quantal/+source/qt4-x11/+bug/1005677 # # Once that's been fixed we can remove the following line: # os.putenv('LIBOVERLAY_SCROLLBAR', '0') args = ['-testability'] if self.args.testability else [] vis_main(args) def launch_app(self): """Launch an application, with introspection support.""" try: app_path, app_arguments = _prepare_application_for_launch( self.args.application, self.args.interface ) launch_process( app_path, app_arguments, capture_output=False ) except RuntimeError as e: _print_message_and_exit_error("Error: " + str(e)) def run_tests(self): """Run tests, using input from `args`.""" _configure_debug_profile(self.args) _configure_timeout_profile(self.args) _configure_test_timeout(self.args) try: _video.configure_video_recording(self.args) except RuntimeError as e: print("Error: %s" % str(e)) exit(1) test_config.set_configuration_string(self.args.test_config) test_suite, error_encountered = load_test_suite_from_name( self.args.suite ) if not test_suite.countTestCases(): raise RuntimeError('Did not find any tests') if self.args.random_order: shuffle(test_suite._tests) print("Running tests in random order") result = construct_test_result(self.args) result.startTestRun() try: test_result = test_suite.run(result) finally: result.stopTestRun() if not test_result.wasSuccessful() or error_encountered: exit(1) def list_tests(self): """Print a list of tests we find inside autopilot.tests.""" num_tests = 0 total_title = "tests" test_suite, error_encountered = load_test_suite_from_name( self.args.suite ) if self.args.run_order: test_list_fn = lambda: iterate_tests(test_suite) else: test_list_fn = lambda: sorted(iterate_tests(test_suite), key=id) # only show test suites, not test cases. TODO: Check if this is still # a requirement. if self.args.suites: suite_names = ["%s.%s" % (t.__module__, t.__class__.__name__) for t in test_list_fn()] unique_suite_names = list(OrderedDict.fromkeys(suite_names).keys()) num_tests = len(unique_suite_names) total_title = "suites" print(" %s" % ("\n ".join(unique_suite_names))) else: for test in test_list_fn(): has_scenarios = (hasattr(test, "scenarios") and type(test.scenarios) is list) if has_scenarios: num_tests += len(test.scenarios) print(" *%d %s" % (len(test.scenarios), test.id())) else: num_tests += 1 print(" " + test.id()) print("\n\n %d total %s." % (num_tests, total_title)) if error_encountered: exit(1) def main(): test_app = TestProgram() try: test_app.run() except RuntimeError as e: print(e) exit(1) if __name__ == "__main__": main() ./autopilot/matchers/0000755000004100000410000000000014002063564015071 5ustar www-datawww-data./autopilot/matchers/__init__.py0000644000004100000410000001216414002063564017206 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Autopilot-specific testtools matchers.""" from functools import partial from testtools.matchers import Matcher, Mismatch from autopilot.utilities import sleep class Eventually(Matcher): """Asserts that a value will eventually equal a given Matcher object. This matcher wraps another testtools matcher object. It makes that other matcher work with a timeout. This is necessary for several reasons: 1. Since most actions in a GUI applicaton take some time to complete, the test may need to wait for the application to enter the expected state. 2. Since the test is running in a separate process to the application under test, test authors cannot make any assumptions about when the application under test will recieve CPU time to update to the expected state. There are two main ways of using the Eventually matcher: **Attributes from the application**:: self.assertThat(window.maximized, Eventually(Equals(True))) Here, ``window`` is an object generated by autopilot from the applications state. This pattern of usage will cover 90% (or more) of the assertions in an autopilot test. Note that any matcher can be used - either from testtools or any custom matcher that implements the matcher API:: self.assertThat(window.height, Eventually(GreaterThan(200))) **Callable Objects**:: self.assertThat( autopilot.platform.model, Eventually(Equals("Galaxy Nexus"))) In this example we're using the :func:`autopilot.platform.model` function as a callable. In this form, Eventually matches against the return value of the callable. This can also be used to use a regular python property inside an Eventually matcher:: self.assertThat(lambda: self.mouse.x, Eventually(LessThan(10))) .. note:: Using this form generally makes your tests less readable, and should be used with great care. It also relies the test author to have knowledge about the implementation of the object being matched against. In this example, if ``self.mouse.x`` were ever to change to be a regular python attribute, this test would likely break. **Timeout** By default timeout period is ten seconds. This can be altered by passing the timeout keyword:: self.assertThat(foo.bar, Eventually(Equals(123), timeout=30)) .. warning:: The Eventually matcher does not work with any other matcher that expects a callable argument (such as testtools' 'Raises' matcher) """ def __init__(self, matcher, **kwargs): super(Eventually, self).__init__() self.timeout = kwargs.pop('timeout', 10) if kwargs: raise ValueError( "Unknown keyword arguments: %s" % ', '.join(kwargs.keys())) match_fun = getattr(matcher, 'match', None) if match_fun is None or not callable(match_fun): raise TypeError( "Eventually must be called with a testtools matcher argument.") self.matcher = matcher def match(self, value): if callable(value): wait_fun = partial(_callable_wait_for, value) else: wait_fun = getattr(value, 'wait_for', None) if wait_fun is None or not callable(wait_fun): raise TypeError( "Eventually is only usable with attributes that have a " "wait_for function or callable objects.") try: wait_fun(self.matcher, self.timeout) except AssertionError as e: return Mismatch(str(e)) return None def __str__(self): return "Eventually " + str(self.matcher) def _callable_wait_for(refresh_fn, matcher, timeout): """Like the patched :meth:`wait_for method`, but for callable objects instead of patched variables. """ time_left = timeout while True: new_value = refresh_fn() mismatch = matcher.match(new_value) if mismatch: failure_msg = mismatch.describe() else: return if time_left >= 1: sleep(1) time_left -= 1 else: sleep(time_left) break # can't give a very descriptive message here, especially as refresh_fn # is likely to be a lambda. raise AssertionError( "After %.1f seconds test failed: %s" % (timeout, failure_msg)) ./autopilot/application/0000755000004100000410000000000014002063567015571 5ustar www-datawww-data./autopilot/application/__init__.py0000644000004100000410000000204314002063567017701 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Base package for application launching and environment management.""" from autopilot.application._launcher import ( get_application_launcher_wrapper, NormalApplicationLauncher, ) __all__ = [ 'NormalApplicationLauncher', 'get_application_launcher_wrapper', ] ./autopilot/application/_launcher.py0000644000004100000410000002714714002063567020116 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013,2017 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Base module for application launchers.""" import fixtures from gi.repository import GLib import json import logging import os import psutil import subprocess import signal from systemd import journal from autopilot.utilities import safe_text_content from autopilot._timeout import Timeout from autopilot._fixtures import FixtureWithDirectAddDetail from autopilot.application._environment import ( GtkApplicationEnvironment, QtApplicationEnvironment, ) from autopilot.introspection import ( get_proxy_object_for_existing_process, ) _logger = logging.getLogger(__name__) class ApplicationLauncher(FixtureWithDirectAddDetail): """A class that knows how to launch an application with a certain type of introspection enabled. :keyword case_addDetail: addDetail method to use. :keyword proxy_base: custom proxy base class to use, defaults to None :keyword dbus_bus: dbus bus to use, if set to something other than the default ('session') the environment will be patched """ def __init__(self, case_addDetail=None, emulator_base=None, dbus_bus='session'): super().__init__(case_addDetail) self.proxy_base = emulator_base self.dbus_bus = dbus_bus def setUp(self): super().setUp() if self.dbus_bus != 'session': self.useFixture( fixtures.EnvironmentVariable( "DBUS_SESSION_BUS_ADDRESS", self.dbus_bus ) ) def launch(self, *arguments): raise NotImplementedError("Sub-classes must implement this method.") class NormalApplicationLauncher(ApplicationLauncher): """Fixture to manage launching an application.""" __doc__ += ApplicationLauncher.__doc__ def launch(self, application, arguments=[], app_type=None, launch_dir=None, capture_output=True): """Launch an application and return a proxy object. Use this method to launch an application and start testing it. The arguments passed in ``arguments`` are used as arguments to the application to launch. Additional keyword arguments are used to control the manner in which the application is launched. This fixture is designed to be flexible enough to launch all supported types of applications. Autopilot can automatically determine how to enable introspection support for dynamically linked binary applications. For example, to launch a binary Gtk application, a test might start with:: from autopilot.application import NormalApplicationLauncher launcher = NormalApplicationLauncher() launcher.setUp() app_proxy = launcher.launch('gedit') For use within a testcase, use useFixture: from autopilot.application import NormalApplicationLauncher launcher = self.useFixture(NormalApplicationLauncher()) app_proxy = launcher.launch('gedit') Applications can be given command line arguments by supplying an ``arguments`` argument to this method. For example, if we want to launch ``gedit`` with a certain document loaded, we might do this:: app_proxy = launcher.launch( 'gedit', arguments=['/tmp/test-document.txt']) ... a Qt5 Qml application is launched in a similar fashion:: app_proxy = launcher.launch( 'qmlscene', arguments=['my_scene.qml']) If you wish to launch an application that is not a dynamically linked binary, you must specify the application type. For example, a Qt4 python application might be launched like this:: app_proxy = launcher.launch( 'my_qt_app.py', app_type='qt') Similarly, a python/Gtk application is launched like so:: app_proxy = launcher.launch( 'my_gtk_app.py', app_type='gtk') :param application: The application to launch. The application can be specified as: * A full, absolute path to an executable file. (``/usr/bin/gedit``) * A relative path to an executable file. (``./build/my_app``) * An app name, which will be searched for in $PATH (``my_app``) :keyword arguments: If set, the list of arguments is passed to the launched app. :keyword app_type: If set, provides a hint to autopilot as to which kind of introspection to enable. This is needed when the application you wish to launch is *not* a dynamically linked binary. Valid values are 'gtk' or 'qt'. These strings are case insensitive. :keyword launch_dir: If set to a directory that exists the process will be launched from that directory. :keyword capture_output: If set to True (the default), the process output will be captured and attached to the test as test detail. :return: A proxy object that represents the application. Introspection data is retrievable via this object. """ _logger.info( "Attempting to launch application '%s' with arguments '%s' as a " "normal process", application, ' '.join(arguments) ) app_path = _get_application_path(application) app_path, arguments = self._setup_environment( app_path, app_type, arguments) process = self._launch_application_process( app_path, capture_output, launch_dir, arguments) proxy_object = get_proxy_object_for_existing_process( dbus_bus=self.dbus_bus, emulator_base=self.proxy_base, process=process, pid=process.pid ) proxy_object.set_process(process) return proxy_object def _setup_environment(self, app_path, app_type, arguments): app_env = self.useFixture( _get_application_environment(app_type, app_path) ) return app_env.prepare_environment( app_path, list(arguments), ) def _launch_application_process(self, app_path, capture_output, cwd, arguments): process = launch_process( app_path, arguments, capture_output, cwd=cwd, ) self.addCleanup(self._kill_process_and_attach_logs, process, app_path) return process def _kill_process_and_attach_logs(self, process, app_path): stdout, stderr, return_code = _kill_process(process) self.caseAddDetail( 'process-return-code (%s)' % app_path, safe_text_content(str(return_code)) ) self.caseAddDetail( 'process-stdout (%s)' % app_path, safe_text_content(stdout) ) self.caseAddDetail( 'process-stderr (%s)' % app_path, safe_text_content(stderr) ) def launch_process(application, args, capture_output=False, **kwargs): """Launch an autopilot-enabled process and return the process object.""" commandline = [application] commandline.extend(args) _logger.info("Launching process: %r", commandline) cap_mode = None if capture_output: cap_mode = subprocess.PIPE process = subprocess.Popen( commandline, stdin=subprocess.PIPE, stdout=cap_mode, stderr=cap_mode, close_fds=True, preexec_fn=os.setsid, universal_newlines=True, **kwargs ) return process def _get_application_environment(app_type=None, app_path=None): if app_type is None and app_path is None: raise ValueError("Must specify either app_type or app_path.") try: if app_type is not None: return _get_app_env_from_string_hint(app_type) else: return get_application_launcher_wrapper(app_path) except (RuntimeError, ValueError) as e: _logger.error(str(e)) raise RuntimeError( "Autopilot could not determine the correct introspection type " "to use. You can specify this by providing app_type." ) def get_application_launcher_wrapper(app_path): """Return an instance of :class:`ApplicationLauncher` that knows how to launch the application at 'app_path'. """ # TODO: this is a teeny bit hacky - we call ldd to check whether this # application links to certain library. We're assuming that linking to # libQt* or libGtk* means the application is introspectable. This excludes # any non-dynamically linked executables, which we may need to fix further # down the line. try: ldd_output = subprocess.check_output( ["ldd", app_path], universal_newlines=True ).strip().lower() except subprocess.CalledProcessError as e: raise RuntimeError(str(e)) if 'libqtcore' in ldd_output or 'libqt5core' in ldd_output: return QtApplicationEnvironment() elif 'libgtk' in ldd_output: return GtkApplicationEnvironment() return None def _get_application_path(application): try: return subprocess.check_output( ['which', application], universal_newlines=True ).strip() except subprocess.CalledProcessError as e: raise ValueError( "Unable to find path for application {app}: {reason}" .format(app=application, reason=str(e)) ) def _get_app_env_from_string_hint(hint): lower_hint = hint.lower() if lower_hint == 'qt': return QtApplicationEnvironment() elif lower_hint == 'gtk': return GtkApplicationEnvironment() raise ValueError("Unknown hint string: {hint}".format(hint=hint)) def _kill_process(process): """Kill the process, and return the stdout, stderr and return code.""" stdout_parts = [] stderr_parts = [] _logger.info("waiting for process to exit.") _attempt_kill_pid(process.pid) for _ in Timeout.default(): tmp_out, tmp_err = process.communicate() if isinstance(tmp_out, bytes): tmp_out = tmp_out.decode('utf-8', errors='replace') if isinstance(tmp_err, bytes): tmp_err = tmp_err.decode('utf-8', errors='replace') stdout_parts.append(tmp_out) stderr_parts.append(tmp_err) if not _is_process_running(process.pid): break else: _logger.info( "Killing process group, since it hasn't exited after " "10 seconds." ) _attempt_kill_pid(process.pid, signal.SIGKILL) return ''.join(stdout_parts), ''.join(stderr_parts), process.returncode def _attempt_kill_pid(pid, sig=signal.SIGTERM): try: _logger.info("Killing process %d", pid) os.killpg(pid, sig) except OSError: _logger.info("Appears process has already exited.") def _is_process_running(pid): return psutil.pid_exists(pid) ./autopilot/application/_environment.py0000644000004100000410000000452414002063564020650 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Base module or application environment setup.""" import fixtures import os class ApplicationEnvironment(fixtures.Fixture): def prepare_environment(self, app_path, arguments): """Prepare the application, or environment to launch with autopilot-support. The method *must* return a tuple of (*app_path*, *arguments*). Either of these can be altered by this method. """ raise NotImplementedError("Sub-classes must implement this method.") class GtkApplicationEnvironment(ApplicationEnvironment): def prepare_environment(self, app_path, arguments): """Prepare the application, or environment to launch with autopilot-support. :returns: unmodified app_path and arguments """ modules = os.getenv('GTK_MODULES', '').split(':') if 'autopilot' not in modules: modules.append('autopilot') os.putenv('GTK_MODULES', ':'.join(modules)) return app_path, arguments class QtApplicationEnvironment(ApplicationEnvironment): def prepare_environment(self, app_path, arguments): """Prepare the application, or environment to launch with autopilot-support. :returns: unmodified app_path and arguments """ if '-testability' not in arguments: insert_pos = 0 for pos, argument in enumerate(arguments): if argument.startswith("-qt="): insert_pos = pos + 1 break arguments.insert(insert_pos, '-testability') return app_path, arguments ./autopilot/keybindings.py0000644000004100000410000002643114002063564016151 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Utility functions to get shortcut keybindings for various parts of Unity. Inside Autopilot we deal with keybindings by naming them with unique names. For example, instead of hard-coding the fact that 'Alt+F2' opens the command lens, we might call: >>> keybindings.get('lens_reveal/command') 'Alt+F2' Keybindings come from two different places: 1) Keybindings from compiz. We can get these if we have the plugin name and setting name. 2) Elsewhere. Right now we're hard-coding these in a separate dictionary. """ import logging import re from autopilot.input import Keyboard from autopilot.utilities import Silence _logger = logging.getLogger(__name__) # # Fill this dictionary with keybindings we want to store. # # If keybindings are from compizconfig, the value should be a 2-value tuple # containging (plugin_name, setting_name). # # If keybindings are elsewhere, just store the keybinding string. _keys = { # Launcher: "launcher/reveal": ('unityshell', 'show_launcher'), "launcher/keynav": ('unityshell', 'keyboard_focus'), "launcher/keynav/next": "Down", "launcher/keynav/prev": "Up", "launcher/keynav/activate": "Enter", "launcher/keynav/exit": "Escape", "launcher/keynav/open-quicklist": "Right", "launcher/keynav/close-quicklist": "Left", "launcher/switcher": ('unityshell', 'launcher_switcher_forward'), "launcher/switcher/exit": "Escape", "launcher/switcher/next": "Tab", "launcher/switcher/prev": "Shift+Tab", "launcher/switcher/down": "Down", "launcher/switcher/up": "Up", # Quicklist: "quicklist/keynav/first": "Home", "quicklist/keynav/last": "End", "quicklist/keynav/next": "Down", "quicklist/keynav/prev": "Up", "quicklist/keynav/activate": "Enter", "quicklist/keynav/exit": "Escape", # Panel: "panel/show_menus": "Alt", "panel/open_first_menu": ('unityshell', 'panel_first_menu'), "panel/next_indicator": "Right", "panel/prev_indicator": "Left", # Dash: "dash/reveal": "Super", "dash/lens/next": "Ctrl+Tab", "dash/lens/prev": "Ctrl+Shift+Tab", # Lenses: "lens_reveal/command": ("unityshell", "execute_command"), "lens_reveal/apps": "Super+a", "lens_reveal/files": "Super+f", "lens_reveal/music": "Super+m", "lens_reveal/video": "Super+v", # Hud: "hud/reveal": ("unityshell", "show_hud"), # Switcher: "switcher/reveal_normal": ("unityshell", "alt_tab_forward"), "switcher/reveal_impropper": "Alt+Right", "switcher/reveal_details": "Alt+`", "switcher/reveal_all": ("unityshell", "alt_tab_forward_all"), "switcher/cancel": "Escape", # Shortcut Hint: "shortcuthint/reveal": ('unityshell', 'show_launcher'), "shortcuthint/cancel": "Escape", # These are in compiz as 'Alt+Right' and 'Alt+Left', but the fact that it # lists the Alt key won't work for us, so I'm defining them manually. "switcher/next": "Tab", "switcher/prev": "Shift+Tab", "switcher/right": "Right", "switcher/left": "Left", "switcher/detail_start": "Down", "switcher/detail_stop": "Up", "switcher/detail_next": "`", "switcher/detail_prev": "`", # Workspace switcher (wall): "workspace/move_left": ("wall", "left_key"), "workspace/move_right": ("wall", "right_key"), "workspace/move_up": ("wall", "up_key"), "workspace/move_down": ("wall", "down_key"), # Window management: "window/show_desktop": ("unityshell", "show_desktop_key"), "window/minimize": ("core", "minimize_window_key"), "window/maximize": ("core", "maximize_window_key"), "window/left_maximize": ("unityshell", "window_left_maximize"), "window/right_maximize": ("unityshell", "window_right_maximize"), "window/restore": ("core", "unmaximize_or_minimize_window_key"), "window/close": ("core", "close_window_key"), # expo plugin: "expo/start": ("expo", "expo_key"), "expo/cancel": "Escape", # spread (scale) plugin: "spread/start": ("scale", "initiate_key"), "spread/cancel": "Escape", } def get(binding_name): """Get a keybinding, given its well-known name. :param string binding_name: :raises TypeError: if binding_name is not a string :raises ValueError: if binding_name cannot be found in the bindings dictionaries. :returns: string for keybinding """ if not isinstance(binding_name, str): raise TypeError("binding_name must be a string.") if binding_name not in _keys: raise ValueError("Unknown binding name '%s'." % (binding_name)) v = _keys[binding_name] if isinstance(v, str): return v else: return _get_compiz_keybinding(v) def get_hold_part(binding_name): """Return the part of a keybinding that must be held permanently. Use this function to split bindings like "Alt+Tab" into the part that must be held down. See :meth:`get_tap_part` for the part that must be tapped. :raises ValueError: if the binding specified does not have multiple parts. """ binding = get(binding_name) parts = binding.split('+') if len(parts) == 1: _logger.warning( "Key binding '%s' does not have a hold part.", binding_name) return parts[0] return '+'.join(parts[:-1]) def get_tap_part(binding_name): """Return the part of a keybinding that must be tapped. Use this function to split bindings like "Alt+Tab" into the part that must be held tapped. See :meth:`get_hold_part` for the part that must be held down. :raises ValueError: if the binding specified does not have multiple parts. """ binding = get(binding_name) parts = binding.split('+') if len(parts) == 1: _logger.warning( "Key binding '%s' does not have a tap part.", binding_name) return parts[0] return parts[-1] def _get_compiz_keybinding(compiz_tuple): """Given a keybinding name, get the keybinding string from the compiz option. :raises ValueError: if the compiz setting described does not hold a keybinding. :raises RuntimeError: if the compiz keybinding has been disabled. :returns: compiz keybinding """ plugin_name, setting_name = compiz_tuple plugin = _get_compiz_plugin(plugin_name) setting = _get_compiz_setting(plugin_name, setting_name) if setting.Type != 'Key': raise ValueError( "Key binding maps to a compiz option that does not hold a " "keybinding.") if not plugin.Enabled: _logger.warning( "Returning keybinding for '%s' which is in un-enabled plugin '%s'", setting.ShortDesc, plugin.ShortDesc) if setting.Value == "Disabled": raise RuntimeError( "Keybinding '%s' in compiz plugin '%s' has been disabled." % (setting.ShortDesc, plugin.ShortDesc)) return _translate_compiz_keystroke_string(setting.Value) def _translate_compiz_keystroke_string(keystroke_string): """Get a string representing the keystroke stored in *keystroke_string*. The returned value is suitable for passing into the Keyboard emulator. :param string keystroke_string: A compizconfig-style keystroke string. """ if not isinstance(keystroke_string, str): raise TypeError("keystroke string must be a string.") translations = { 'Control': 'Ctrl', 'Primary': 'Ctrl', } regex = re.compile('[<>]') parts = regex.split(keystroke_string) result = [] for part in parts: part = part.strip() if part != "" and not part.isspace(): translated = translations.get(part, part) if translated not in result: result.append(translated) return '+'.join(result) class KeybindingsHelper(object): """A helper class that makes it easier to use Unity keybindings.""" @property def _keyboard(self): return Keyboard.create() def keybinding(self, binding_name, delay=None): """Press and release the keybinding with the given name. If set, the delay parameter will override the default delay set by the keyboard emulator. """ if delay is not None and type(delay) != float: raise TypeError( "delay parameter must be a float if it is defined.") if delay: self._keyboard.press_and_release(get(binding_name), delay) else: self._keyboard.press_and_release(get(binding_name)) def keybinding_hold(self, binding_name): """Hold down the hold-part of a keybinding.""" self._keyboard.press(get_hold_part(binding_name)) def keybinding_release(self, binding_name): """Release the hold-part of a keybinding.""" self._keyboard.release(get_hold_part(binding_name)) def keybinding_tap(self, binding_name): """Tap the tap-part of a keybinding.""" self._keyboard.press_and_release(get_tap_part(binding_name)) def keybinding_hold_part_then_tap(self, binding_name): self.keybinding_hold(binding_name) self.keybinding_tap(binding_name) # Functions that wrap compizconfig to avoid some unpleasantness in that module. # Local to the this keybindings for now until their removal in the very near # future. _global_compiz_context = None def _get_global_compiz_context(): """Get the compizconfig global context object. :returns: global compiz context, either already defined or from compiz config """ global _global_compiz_context if _global_compiz_context is None: with Silence(): from compizconfig import Context _global_compiz_context = Context() return _global_compiz_context def _get_compiz_plugin(plugin_name): """Get a compizconfig plugin with the specified name. :raises KeyError: if the plugin named does not exist. :returns: compizconfig plugin """ ctx = _get_global_compiz_context() with Silence(): try: return ctx.Plugins[plugin_name] except KeyError: raise KeyError( "Compiz plugin '%s' does not exist." % (plugin_name)) def _get_compiz_setting(plugin_name, setting_name): """Get a compizconfig setting object, given a plugin name and setting name. :raises KeyError: if the plugin or setting is not found. :returns: compiz setting object """ plugin = _get_compiz_plugin(plugin_name) with Silence(): try: return plugin.Screen[setting_name] except KeyError: raise KeyError( "Compiz setting '%s' does not exist in plugin '%s'." % (setting_name, plugin_name)) ./autopilot/_logging.py0000644000004100000410000000402414002063564015422 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Autopilot's logging code.""" import logging from io import StringIO from autopilot._fixtures import FixtureWithDirectAddDetail from autopilot.utilities import ( LogFormatter, safe_text_content, ) class TestCaseLoggingFixture(FixtureWithDirectAddDetail): """A fixture that adds the log to the test case as a detail object.""" def __init__(self, test_id, *args, **kwargs): super().__init__(*args, **kwargs) self._test_id = test_id def setUp(self): super().setUp() logging.info("*" * 60) logging.info("Starting test %s", self._test_id) self._log_buffer = StringIO() root_logger = logging.getLogger() formatter = LogFormatter() self._log_handler = logging.StreamHandler(stream=self._log_buffer) self._log_handler.setFormatter(formatter) root_logger.addHandler(self._log_handler) self.addCleanup(self._tearDownLogging) def _tearDownLogging(self): root_logger = logging.getLogger() self._log_handler.flush() self._log_buffer.seek(0) self.caseAddDetail( 'test-log', safe_text_content(self._log_buffer.getvalue()) ) root_logger.removeHandler(self._log_handler) self._log_buffer = None ./autopilot/_config.py0000644000004100000410000000615614002063564015251 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import collections.abc _test_config_string = "" def set_configuration_string(config_string): """Set the test configuration string. This must be a text string that specifies the test configuration. The string is a comma separated list of 'key=value' or 'key' tokens. """ global _test_config_string _test_config_string = config_string def get_test_configuration(): """Get the test configuration dictionary. Tests can be configured from the command line when the ``autopilot`` tool is invoked. Typical use cases involve configuring the test suite to use a particular binary (perhaps a locally built binary or one installed to the system), or configuring which external services are faked. This dictionary is populated from the ``--config`` option to the ``autopilot run`` command. For example: ``autopilot run --config use_local some.test.id`` Will result in a dictionary where the key ``use_local`` is present, and evaluates to true, e.g.-:: from autopilot import get_test_configuration if get_test_configuration['use_local']: print("Using local binary") Values can also be specified. The following command: ``autopilot run --config fake_services=login some.test.id`` ...will result in the key 'fake_services' having the value 'login'. Autopilot itself does nothing with the conents of this dictionary. It is entirely up to test authors to populate it, and to use the values as they see fit. """ return ConfigDict(_test_config_string) class ConfigDict(collections.abc.Mapping): def __init__(self, config_string): self._data = {} config_items = (item for item in config_string.split(',') if item) for item in config_items: parts = item.split('=', 1) safe_key = parts[0].lstrip() if len(parts) == 1 and safe_key != '': self._data[safe_key] = '1' elif len(parts) == 2 and safe_key != '': self._data[safe_key] = parts[1] else: raise ValueError( "Invalid configuration string '{}'".format(config_string) ) def __getitem__(self, key): return self._data.__getitem__(key) def __iter__(self): return self._data.__iter__() def __len__(self): return self._data.__len__() ./autopilot/process/0000755000004100000410000000000014002063564014741 5ustar www-datawww-data./autopilot/process/__init__.py0000644000004100000410000003540314002063564017057 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from collections import OrderedDict from autopilot.utilities import _pick_backend class ProcessManager(object): """A simple process manager class. The process manager is used to handle processes, windows and applications. This class should not be instantiated directly however. To get an instance of the keyboard class, call :py:meth:`create` instead. """ KNOWN_APPS = { 'Character Map': { 'desktop-file': 'gucharmap.desktop', 'process-name': 'gucharmap', }, 'Calculator': { 'desktop-file': 'gcalctool.desktop', 'process-name': 'gnome-calculator', }, 'Mahjongg': { 'desktop-file': 'gnome-mahjongg.desktop', 'process-name': 'gnome-mahjongg', }, 'Remmina': { 'desktop-file': 'remmina.desktop', 'process-name': 'remmina', }, 'System Settings': { 'desktop-file': 'unity-control-center.desktop', 'process-name': 'unity-control-center', }, 'Text Editor': { 'desktop-file': 'gedit.desktop', 'process-name': 'gedit', }, 'Terminal': { 'desktop-file': 'gnome-terminal.desktop', 'process-name': 'gnome-terminal', }, } @staticmethod def create(preferred_backend=""): """Get an instance of the :py:class:`ProcessManager` class. For more infomration on picking specific backends, see :ref:`tut-picking-backends` :param preferred_backend: A string containing a hint as to which backend you would like. Possible backends are: * ``BAMF`` - Get process information using the BAMF Application Matching Framework. :raises: RuntimeError if autopilot cannot instantate any of the possible backends. :raises: RuntimeError if the preferred_backend is specified and is not one of the possible backends for this device class. :raises: :class:`~autopilot.BackendException` if the preferred_backend is set, but that backend could not be instantiated. """ def get_bamf_pm(): from autopilot.process._bamf import ProcessManager return ProcessManager() backends = OrderedDict() backends['BAMF'] = get_bamf_pm return _pick_backend(backends, preferred_backend) @classmethod def register_known_application(cls, name, desktop_file, process_name): """Register an application with autopilot. After calling this method, you may call :meth:`start_app` or :meth:`start_app_window` with the `name` parameter to start this application. You need only call this once within a test run - the application will remain registerred until the test run ends. :param name: The name to be used when launching the application. :param desktop_file: The filename (without path component) of the desktop file used to launch the application. :param process_name: The name of the executable process that gets run. :raises: **KeyError** if application has been registered already """ if name in cls.KNOWN_APPS: raise KeyError("Application has been registered already") else: cls.KNOWN_APPS[name] = { "desktop-file": desktop_file, "process-name": process_name } @classmethod def unregister_known_application(cls, name): """Unregister an application with the known_apps dictionary. :param name: The name to be used when launching the application. :raises: **KeyError** if the application has not been registered. """ if name in cls.KNOWN_APPS: del cls.KNOWN_APPS[name] else: raise KeyError("Application has not been registered") def start_app(self, app_name, files=[], locale=None): """Start one of the known applications, and kill it on tear down. .. warning:: This method will clear all instances of this application on tearDown, not just the one opened by this method! We recommend that you use the :meth:`start_app_window` method instead, as it is generally safer. :param app_name: The application name. *This name must either already be registered as one of the built-in applications that are supported by autopilot, or must have been registered using* :meth:`register_known_application` *beforehand.* :param files: (Optional) A list of paths to open with the given application. *Not all applications support opening files in this way.* :param locale: (Optional) The locale will to set when the application is launched. *If you want to launch an application without any localisation being applied, set this parameter to 'C'.* :returns: A :class:`~autopilot.process.Application` instance. """ raise NotImplementedError("You cannot use this class directly.") def start_app_window(self, app_name, files=[], locale=None): """Open a single window for one of the known applications, and close it at the end of the test. :param app_name: The application name. *This name must either already be registered as one of the built-in applications that are supported by autopilot, or must have been registered with* :meth:`register_known_application` *beforehand.* :param files: (Optional) Should be a list of paths to open with the given application. *Not all applications support opening files in this way.* :param locale: (Optional) The locale will to set when the application is launched. *If you want to launch an application without any localisation being applied, set this parameter to 'C'.* :raises: **AssertionError** if no window was opened, or more than one window was opened. :returns: A :class:`~autopilot.process.Window` instance. """ raise NotImplementedError("You cannot use this class directly.") def get_open_windows_by_application(self, app_name): """Get a list of ~autopilot.process.Window` instances for the given application name. :param app_name: The name of one of the well-known applications. :returns: A list of :class:`~autopilot.process.Window` instances. """ raise NotImplementedError("You cannot use this class directly.") def close_all_app(self, app_name): raise NotImplementedError("You cannot use this class directly.") def get_app_instances(self, app_name): raise NotImplementedError("You cannot use this class directly.") def app_is_running(self, app_name): raise NotImplementedError("You cannot use this class directly.") def get_running_applications(self, user_visible_only=True): """Get a list of the currently running applications. If user_visible_only is True (the default), only applications visible to the user in the switcher will be returned. """ raise NotImplementedError("You cannot use this class directly.") def get_running_applications_by_desktop_file(self, desktop_file): """Return a list of applications with the desktop file *desktop_file*. This method will return an empty list if no applications are found with the specified desktop file. """ raise NotImplementedError("You cannot use this class directly.") def get_open_windows(self, user_visible_only=True): """Get a list of currently open windows. If *user_visible_only* is True (the default), only applications visible to the user in the switcher will be returned. The result is sorted to be in stacking order. """ raise NotImplementedError("You cannot use this class directly.") def wait_until_application_is_running(self, desktop_file, timeout): """Wait until a given application is running. :param string desktop_file: The name of the application desktop file. :param integer timeout: The maximum time to wait, in seconds. *If set to something less than 0, this method will wait forever.* :return: true once the application is found, or false if the application was not found until the timeout was reached. """ raise NotImplementedError("You cannot use this class directly.") def launch_application(self, desktop_file, files=[], wait=True): """Launch an application by specifying a desktop file. :param files: List of files to pass to the application. *Not all apps support this.* :type files: List of strings .. note:: If `wait` is True, this method will wait up to 10 seconds for the application to appear. :raises: **TypeError** on invalid *files* parameter. :return: The Gobject process object. """ raise NotImplementedError("You cannot use this class directly.") class Application(object): @property def desktop_file(self): """Get the application desktop file. This returns just the filename, not the full path. If the application no longer exists, this returns an empty string. """ raise NotImplementedError("You cannot use this class directly.") @property def name(self): """Get the application name. .. note:: This may change according to the current locale. If you want a unique string to match applications against, use desktop_file instead. """ raise NotImplementedError("You cannot use this class directly.") @property def icon(self): """Get the application icon. :return: The name of the icon. """ raise NotImplementedError("You cannot use this class directly.") @property def is_active(self): """Is the application active (i.e. has keyboard focus)?""" raise NotImplementedError("You cannot use this class directly.") @property def is_urgent(self): """Is the application currently signalling urgency?""" raise NotImplementedError("You cannot use this class directly.") @property def user_visible(self): """Is this application visible to the user? .. note:: Some applications (such as the panel) are hidden to the user but may still be returned. """ raise NotImplementedError("You cannot use this class directly.") def get_windows(self): """Get a list of the application windows.""" raise NotImplementedError("You cannot use this class directly.") class Window(object): @property def x_id(self): """Get the X11 Window Id.""" raise NotImplementedError("You cannot use this class directly.") @property def x_win(self): """Get the X11 window object of the underlying window.""" raise NotImplementedError("You cannot use this class directly.") @property def get_wm_state(self): """Get the state of the underlying window.""" raise NotImplementedError("You cannot use this class directly.") @property def name(self): """Get the window name. .. note:: This may change according to the current locale. If you want a unique string to match windows against, use the x_id instead. """ raise NotImplementedError("You cannot use this class directly.") @property def title(self): """Get the window title. This may be different from the application name. .. note:: This may change depending on the current locale. """ raise NotImplementedError("You cannot use this class directly.") @property def geometry(self): """Get the geometry for this window. :return: Tuple containing (x, y, width, height). """ raise NotImplementedError("You cannot use this class directly.") @property def is_maximized(self): """Is the window maximized? Maximized in this case means both maximized vertically and horizontally. If a window is only maximized in one direction it is not considered maximized. """ raise NotImplementedError("You cannot use this class directly.") @property def application(self): """Get the application that owns this window. This method may return None if the window does not have an associated application. The 'desktop' window is one such example. """ raise NotImplementedError("You cannot use this class directly.") @property def user_visible(self): """Is this window visible to the user in the switcher?""" raise NotImplementedError("You cannot use this class directly.") @property def is_hidden(self): """Is this window hidden? Windows are hidden when the 'Show Desktop' mode is activated. """ raise NotImplementedError("You cannot use this class directly.") @property def is_focused(self): """Is this window focused?""" raise NotImplementedError("You cannot use this class directly.") @property def is_valid(self): """Is this window object valid? Invalid windows are caused by windows closing during the construction of this object instance. """ raise NotImplementedError("You cannot use this class directly.") @property def monitor(self): """Returns the monitor to which the windows belongs to""" raise NotImplementedError("You cannot use this class directly.") @property def closed(self): """Returns True if the window has been closed""" raise NotImplementedError("You cannot use this class directly.") def close(self): """Close the window.""" raise NotImplementedError("You cannot use this class directly.") def set_focus(self): raise NotImplementedError("You cannot use this class directly.") ./autopilot/process/_bamf.py0000644000004100000410000005376514002063564016377 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """BAMF implementation of the Process Management""" import dbus import dbus.glib from gi.repository import Gio from gi.repository import GLib import logging import os from Xlib import display, X, protocol from subprocess import check_output, CalledProcessError, call import autopilot._glib from autopilot._timeout import Timeout from autopilot.dbus_handler import get_session_bus from autopilot.process import ( ProcessManager as ProcessManagerBase, Application as ApplicationBase, Window as WindowBase ) from autopilot.utilities import ( addCleanup, Silence, ) _BAMF_BUS_NAME = 'org.ayatana.bamf' _X_DISPLAY = None _logger = logging.getLogger(__name__) def get_display(): """Create an Xlib display object (silently) and return it.""" global _X_DISPLAY if _X_DISPLAY is None: with Silence(): _X_DISPLAY = display.Display() return _X_DISPLAY def _filter_user_visible(win): """Filter out non-user-visible objects. In some cases the DBus method we need to call hasn't been registered yet, in which case we do the safe thing and return False. """ try: return win.user_visible except dbus.DBusException: return False class ProcessManager(ProcessManagerBase): """High-level class for interacting with Bamf from within a test. Use this class to inspect the state of running applications and open windows. """ def __init__(self): matcher_path = '/org/ayatana/bamf/matcher' self.matcher_interface_name = 'org.ayatana.bamf.matcher' self.matcher_proxy = get_session_bus().get_object( _BAMF_BUS_NAME, matcher_path) self.matcher_interface = dbus.Interface( self.matcher_proxy, self.matcher_interface_name) def start_app(self, app_name, files=[], locale=None): """Start one of the known applications, and kill it on tear down. .. warning:: This method will clear all instances of this application on tearDown, not just the one opened by this method! We recommend that you use the :meth:`start_app_window` method instead, as it is generally safer. :param app_name: The application name. *This name must either already be registered as one of the built-in applications that are supported by autopilot, or must have been registered using* :meth:`register_known_application` *beforehand.* :param files: (Optional) A list of paths to open with the given application. *Not all applications support opening files in this way.* :param locale: (Optional) The locale will to set when the application is launched. *If you want to launch an application without any localisation being applied, set this parameter to 'C'.* :returns: A :class:`~autopilot.process.Application` instance. """ window = self._open_window(app_name, files, locale) if window: addCleanup(self.close_all_app, app_name) return window.application raise AssertionError("No new application window was opened.") def start_app_window(self, app_name, files=[], locale=None): """Open a single window for one of the known applications, and close it at the end of the test. :param app_name: The application name. *This name must either already be registered as one of the built-in applications that are supported by autopilot, or must have been registered with* :meth:`register_known_application` *beforehand.* :param files: (Optional) Should be a list of paths to open with the given application. *Not all applications support opening files in this way.* :param locale: (Optional) The locale will to set when the application is launched. *If you want to launch an application without any localisation being applied, set this parameter to 'C'.* :raises: **AssertionError** if no window was opened, or more than one window was opened. :returns: A :class:`~autopilot.process.Window` instance. """ window = self._open_window(app_name, files, locale) if window: addCleanup(window.close) return window raise AssertionError("No window was opened.") def _open_window(self, app_name, files, locale): """Open a new 'app_name' window, returning the window instance or None. Raises an AssertionError if this creates more than one window. """ existing_windows = self.get_open_windows_by_application(app_name) if locale: os.putenv("LC_ALL", locale) addCleanup(os.unsetenv, "LC_ALL") _logger.info( "Starting application '%s' with files %r in locale %s", app_name, files, locale) else: _logger.info( "Starting application '%s' with files %r", app_name, files) app = self.KNOWN_APPS[app_name] self._launch_application(app['desktop-file'], files) apps = self.get_running_applications_by_desktop_file( app['desktop-file']) for _ in Timeout.default(): try: new_windows = [] [new_windows.extend(a.get_windows()) for a in apps] filter_fn = lambda w: w.x_id not in [ c.x_id for c in existing_windows] new_wins = list(filter(filter_fn, new_windows)) if new_wins: assert len(new_wins) == 1 return new_wins[0] except dbus.DBusException: pass return None def get_open_windows_by_application(self, app_name): """Get a list of ~autopilot.process.Window` instances for the given application name. :param app_name: The name of one of the well-known applications. :returns: A list of :class:`~autopilot.process.Window` instances. """ existing_windows = [] [existing_windows.extend(a.get_windows()) for a in self.get_app_instances(app_name)] return existing_windows def close_all_app(self, app_name): """Close all instances of the application 'app_name'.""" app = self.KNOWN_APPS[app_name] try: pids = check_output(["pidof", app['process-name']]).split() if len(pids): call(["kill"] + pids) except CalledProcessError: _logger.warning( "Tried to close applicaton '%s' but it wasn't running.", app_name) def get_app_instances(self, app_name): """Get `~autopilot.process.Application` instances for app_name.""" desktop_file = self.KNOWN_APPS[app_name]['desktop-file'] return self.get_running_applications_by_desktop_file(desktop_file) def app_is_running(self, app_name): """Return true if an instance of the application is running.""" apps = self.get_app_instances(app_name) return len(apps) > 0 def get_running_applications(self, user_visible_only=True): """Get a list of the currently running applications. If user_visible_only is True (the default), only applications visible to the user in the switcher will be returned. """ apps = [Application(p) for p in self.matcher_interface.RunningApplications()] if user_visible_only: return list(filter(_filter_user_visible, apps)) return apps def get_running_applications_by_desktop_file(self, desktop_file): """Return a list of applications with the desktop file *desktop_file*. This method will return an empty list if no applications are found with the specified desktop file. """ apps = [] for a in self.get_running_applications(): try: if a.desktop_file == desktop_file: apps.append(a) except dbus.DBusException: pass return apps def get_open_windows(self, user_visible_only=True): """Get a list of currently open windows. If *user_visible_only* is True (the default), only applications visible to the user in the switcher will be returned. The result is sorted to be in stacking order. """ windows = [Window(w) for w in self.matcher_interface.WindowStackForMonitor(-1)] if user_visible_only: windows = list(filter(_filter_user_visible, windows)) # Now sort on stacking order. # We explicitly convert to a list from an iterator since tests # frequently try and use len() on return values from these methods. return list(reversed(windows)) def wait_until_application_is_running(self, desktop_file, timeout): """Wait until a given application is running. :param string desktop_file: The name of the application desktop file. :param integer timeout: The maximum time to wait, in seconds. *If set to something less than 0, this method will wait forever.* :return: true once the application is found, or false if the application was not found until the timeout was reached. """ desktop_file = os.path.split(desktop_file)[1] # python workaround since you can't assign to variables in the # enclosing scope: see on_timeout_reached below... found_app = [True] # maybe the app is running already? running_applications = self.get_running_applications_by_desktop_file( desktop_file) if len(running_applications) == 0: wait_forever = timeout < 0 gobject_loop = GLib.MainLoop() # No, so define a callback to watch the ViewOpened signal: def on_view_added(bamf_path, name): if bamf_path.split('/')[-2].startswith('application'): app = Application(bamf_path) if desktop_file == os.path.split(app.desktop_file)[1]: gobject_loop.quit() # ...and one for when the user-defined timeout has been reached: def on_timeout_reached(): gobject_loop.quit() found_app[0] = False return False # need a timeout? if so, connect it: if not wait_forever: GLib.timeout_add(timeout * 1000, on_timeout_reached) # connect signal handler: get_session_bus().add_signal_receiver(on_view_added, 'ViewOpened') # pump the gobject main loop until either the correct signal is # emitted, or the timeout happens. gobject_loop.run() return found_app[0] def _launch_application(self, desktop_file, files=[], wait=True): """Launch an application by specifying a desktop file. :param files: List of files to pass to the application. *Not all apps support this.* :type files: List of strings .. note:: If `wait` is True, this method will wait up to 10 seconds for the application to appear in the BAMF model. :raises: **TypeError** on invalid *files* parameter. :return: The Gobject process object. """ if type(files) is not list: raise TypeError("files must be a list.") proc = _launch_application(desktop_file, files) if wait: self.wait_until_application_is_running(desktop_file, 10) return proc def _launch_application(desktop_file, files): proc = Gio.DesktopAppInfo.new(desktop_file) # simple launch_uris() uses GLib.SpawnFlags.SEARCH_PATH by default only, # but this inherits stdout; we don't want that as it hangs when tee'ing # autopilot output into a file. # Instead of depending on a newer version of gir/glib attempt to use the # newer verison (i.e. launch_uris_as_manager works) and fall back on using # the simple launch_uris try: proc.launch_uris_as_manager( files, None, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.STDOUT_TO_DEV_NULL, None, None, None, None) except TypeError: proc.launch_uris(files, None) class Application(ApplicationBase): """Represents an application, with information as returned by Bamf. .. important:: Don't instantiate this class yourself. instead, use the methods as provided by the Bamf class. :raises: **dbus.DBusException** in the case of a DBus error. """ def __init__(self, bamf_app_path): self.bamf_app_path = bamf_app_path try: self._app_proxy = get_session_bus().get_object( _BAMF_BUS_NAME, bamf_app_path) self._view_iface = dbus.Interface( self._app_proxy, 'org.ayatana.bamf.view') self._app_iface = dbus.Interface( self._app_proxy, 'org.ayatana.bamf.application') except dbus.DBusException as e: e.args += ('bamf_app_path=%r' % (bamf_app_path),) raise @property def desktop_file(self): """Get the application desktop file. This just returns the filename, not the full path. If the application no longer exists, this returns an empty string. """ try: return os.path.split(self._app_iface.DesktopFile())[1] except dbus.DBusException: return "" @property def name(self): """Get the application name. .. note:: This may change according to the current locale. If you want a unique string to match applications against, use the desktop_file instead. """ return self._view_iface.Name() @property def icon(self): """Get the application icon. :return: The name of the icon. """ return self._view_iface.Icon() @property def is_active(self): """Is the application active (i.e.- has keyboard focus)?""" return self._view_iface.IsActive() @property def is_urgent(self): """Is the application currently signalling urgency?""" return self._view_iface.IsUrgent() @property def user_visible(self): """Is this application visible to the user? .. note:: Some applications (such as the panel) are hidden to the user but will still be returned by bamf. """ return self._view_iface.UserVisible() def get_windows(self): """Get a list of the application windows.""" return [Window(w) for w in self._view_iface.Children()] def __repr__(self): return "" % (self.name) def __eq__(self, other): if other is None: return False return self.desktop_file == other.desktop_file class Window(WindowBase): """Represents an application window, as returned by Bamf. .. important:: Don't instantiate this class yourself. Instead, use the appropriate methods in Application. """ def __init__(self, window_path): self._bamf_win_path = window_path self._app_proxy = get_session_bus().get_object( _BAMF_BUS_NAME, window_path) self._window_iface = dbus.Interface( self._app_proxy, 'org.ayatana.bamf.window') self._view_iface = dbus.Interface( self._app_proxy, 'org.ayatana.bamf.view') self._xid = int(self._window_iface.GetXid()) self._x_root_win = get_display().screen().root self._x_win = get_display().create_resource_object( 'window', self._xid) @property def x_id(self): """Get the X11 Window Id.""" return self._xid @property def x_win(self): """Get the X11 window object of the underlying window.""" return self._x_win @property def name(self): """Get the window name. .. note:: This may change according to the current locale. If you want a unique string to match windows against, use the x_id instead. """ return self._view_iface.Name() @property def title(self): """Get the window title. This may be different from the application name. .. note:: This may change depending on the current locale. """ return self._getProperty('_NET_WM_NAME') @property def geometry(self): """Get the geometry for this window. :return: Tuple containing (x, y, width, height). """ # Note: MUST import these here, rather than at the top of the file. # Why? Because sphinx imports these modules to build the API # documentation, which in turn tries to import Gdk, which in turn # fails because there's no DISPlAY environment set in the package # builder. Gdk = autopilot._glib._import_gdk() GdkX11 = autopilot._glib._import_gdkx11() # FIXME: We need to use the gdk window here to get the real coordinates geometry = self._x_win.get_geometry() origin = GdkX11.X11Window.foreign_new_for_display( Gdk.Display().get_default(), self._xid).get_origin() return (origin[1], origin[2], geometry.width, geometry.height) @property def is_maximized(self): """Is the window maximized? Maximized in this case means both maximized vertically and horizontally. If a window is only maximized in one direction it is not considered maximized. """ win_state = self._get_window_states() return '_NET_WM_STATE_MAXIMIZED_VERT' in win_state and \ '_NET_WM_STATE_MAXIMIZED_HORZ' in win_state @property def application(self): """Get the application that owns this window. This method may return None if the window does not have an associated application. The 'desktop' window is one such example. """ # BAMF returns a list of parents since some windows don't have an # associated application. For these windows we return none. parents = self._view_iface.Parents() if parents: return Application(parents[0]) else: return None @property def user_visible(self): """Is this window visible to the user in the switcher?""" return self._view_iface.UserVisible() @property def is_hidden(self): """Is this window hidden? Windows are hidden when the 'Show Desktop' mode is activated. """ win_state = self._get_window_states() return '_NET_WM_STATE_HIDDEN' in win_state @property def is_focused(self): """Is this window focused?""" win_state = self._get_window_states() return '_NET_WM_STATE_FOCUSED' in win_state @property def is_valid(self): """Is this window object valid? Invalid windows are caused by windows closing during the construction of this object instance. """ return self._x_win is not None @property def monitor(self): """Returns the monitor to which the windows belongs to""" return self._window_iface.Monitor() @property def closed(self): """Returns True if the window has been closed""" # This will return False when the window is closed and then removed # from BUS. try: return (self._window_iface.GetXid() != self.x_id) except: return True def close(self): """Close the window.""" self._setProperty('_NET_CLOSE_WINDOW', [0, 0]) def set_focus(self): self._x_win.set_input_focus(X.RevertToParent, X.CurrentTime) self._x_win.configure(stack_mode=X.Above) def __repr__(self): return "" % ( self.title if self._x_win else '', self.x_id) def _getProperty(self, _type): """Get an X11 property. _type is a string naming the property type. win is the X11 window object. """ atom = self._x_win.get_full_property( get_display().get_atom(_type), X.AnyPropertyType) if atom: return atom.value def _setProperty(self, _type, data, mask=None): if type(data) is str: dataSize = 8 else: # data length must be 5 - pad with 0's if it's short, truncate # otherwise. data = (data + [0] * (5 - len(data)))[:5] dataSize = 32 ev = protocol.event.ClientMessage( window=self._x_win, client_type=get_display().get_atom(_type), data=(dataSize, data)) if not mask: mask = (X.SubstructureRedirectMask | X.SubstructureNotifyMask) self._x_root_win.send_event(ev, event_mask=mask) get_display().sync() def _get_window_states(self): """Return a list of strings representing the current window state.""" get_display().sync() return [get_display().get_atom_name(p) for p in self._getProperty('_NET_WM_STATE')] def resize(self, width, height): """Resize the window. :param width: The new width for the window. :param height: The new height for the window. """ self.x_win.configure(width=width, height=height) self.x_win.change_attributes( win_gravity=X.NorthWestGravity, bit_gravity=X.StaticGravity) # A call to get the window geometry commits the changes. self.geometry ./autopilot/logging.py0000644000004100000410000000332614002063564015267 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Logging helpers for Autopilot tests.""" import pprint from functools import wraps def log_action(log_func): """Decorator to log the call of an action method.""" def middle(f): @wraps(f) def inner(instance, *args, **kwargs): class_name = str(instance.__class__.__name__) docstring = f.__doc__ if docstring: docstring = docstring.split('\n')[0].strip() else: docstring = f.__name__ # Strip the ending periods of the docstring, if present, so only # one will remain after using the log line format. docstring = docstring.rstrip('.') log_line = '%s: %s. Arguments %s. Keyword arguments: %s.' log_func( log_line, class_name, docstring, pprint.pformat(args), pprint.pformat(kwargs)) return f(instance, *args, **kwargs) return inner return middle ./autopilot/_video.py0000644000004100000410000001303414002063564015103 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import fixtures import glob from functools import partial import logging import os import signal import subprocess from testtools.matchers import NotEquals from autopilot.matchers import Eventually from autopilot.utilities import safe_text_content logger = logging.getLogger(__name__) class RMDVideoLogFixture(fixtures.Fixture): """Video capture autopilot tests, saving the results if the test failed.""" _recording_app = '/usr/bin/recordmydesktop' _recording_opts = ['--no-sound', '--no-frame', '-o'] def __init__(self, recording_directory, test_instance): super().__init__() self.recording_directory = recording_directory self.test_instance = test_instance def setUp(self): super().setUp() self._test_passed = True if not self._have_recording_app(): logger.warning( "Disabling video capture since '%s' is not present", self._recording_app) self.test_instance.addOnException(self._on_test_failed) self.test_instance.addCleanup( self._stop_video_capture, self.test_instance ) self._start_video_capture(self.test_instance.shortDescription()) def _have_recording_app(self): return os.path.exists(self._recording_app) def _start_video_capture(self, test_id): args = self.get_capture_command_line() self._capture_file = os.path.join( self.recording_directory, '%s.ogv' % (test_id) ) _ensure_directory_exists_but_not_file(self._capture_file) args.append(self._capture_file) video_session_pattern = '/tmp/rMD-session*' orig_sessions = glob.glob(video_session_pattern) logger.debug("Starting: %r", args) self._capture_process = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True ) # wait until rmd session directory is created Eventually(NotEquals(orig_sessions)).match( lambda: glob.glob(video_session_pattern) ) def _stop_video_capture(self, test_instance): """Stop the video capture. If the test failed, save the resulting file.""" if self._test_passed: # SIGABRT terminates the program and removes # the specified output file. self._capture_process.send_signal(signal.SIGABRT) self._capture_process.wait() else: self._capture_process.terminate() self._capture_process.wait() if self._capture_process.returncode != 0: test_instance.addDetail( 'video capture log', safe_text_content(self._capture_process.stdout.read())) self._capture_process = None self._currently_recording_description = None def _on_test_failed(self, ex_info): """Called when a test fails.""" from unittest.case import SkipTest failure_class_type = ex_info[0] if failure_class_type is not SkipTest: self._test_passed = False def get_capture_command_line(self): return [self._recording_app] + self._recording_opts def set_recording_dir(self, dir): self.recording_directory = dir def _have_video_recording_facilities(): call_ret_code = subprocess.call( ['which', 'recordmydesktop'], stdout=subprocess.PIPE ) return call_ret_code == 0 def _ensure_directory_exists_but_not_file(file_path): dirpath = os.path.dirname(file_path) if not os.path.exists(dirpath): os.makedirs(dirpath) elif os.path.exists(file_path): logger.warning( "Video capture file '%s' already exists, deleting.", file_path) os.remove(file_path) class DoNothingFixture(fixtures.Fixture): def __init__(self, arg): pass VideoLogFixture = DoNothingFixture def configure_video_recording(args): """Configure video recording based on contents of ``args``. :raises RuntimeError: If the user asked for video recording, but the system does not support video recording. """ global VideoLogFixture if args.record_directory: args.record = True if not args.record: # blank fixture when recording is not enabled VideoLogFixture = DoNothingFixture else: if not args.record_directory: args.record_directory = '/tmp/autopilot' if not _have_video_recording_facilities(): raise RuntimeError( "The application 'recordmydesktop' needs to be installed to " "record failing jobs." ) VideoLogFixture = partial(RMDVideoLogFixture, args.record_directory) def get_video_recording_fixture(): return VideoLogFixture ./autopilot/platform.py0000644000004100000410000001402014002063564015456 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os """ Platform identification utilities for Autopilot. ================================================ This module provides functions that give test authors hints as to which platform their tests are currently running on. This is useful when a test needs to test slight different behavior depending on the system it's running on. For example:: from autopilot import platform ... def test_something(self): if platform.model() == "Galaxy Nexus": # do something elif platform.model() == "Desktop": # do something else def test_something_else(self): if platform.is_tablet(): # run a tablet test else: # run a non-tablet test Skipping tests based on Platform ++++++++++++++++++++++++++++++++ Sometimes you want a test to not run on certain platforms, or only run on certain platforms. This can be easily achieved with a combination of the functions in this module and the ``skipIf`` and ``skipUnless`` decorators. For example, to define a test that only runs on the galaxy nexus device, write this:: from testtools import skipUnless ... @skipUnless( platform.model() == 'Galaxy Nexus', "Test is only for Galaxy Nexus" ) def test_something(self): # test things! The inverse is possible as well. To define a test that will run on every device except the Galaxy Nexus, write this:: from testtools import skipIf ... @skipIf( platform.model() == 'Galaxy Nexus', "Test not available for Galaxy Nexus" ) def test_something(self): # test things! Tuples of values can be used as well, to select more than one platform. For example:: @skipIf( platform.model() in ('Model One', 'Model Two'), "Test not available for Models One and Two" ) def test_something(self): # test things! """ def model(): """Get the model name of the current platform. For desktop / laptop installations, this will return "Desktop". Otherwise, the current hardware model will be returned. For example:: platform.model() ... "Galaxy Nexus" """ return _PlatformDetector.create().model def image_codename(): """Get the image codename. For desktop / laptop installations this will return "Desktop". Otherwise, the codename of the image that was installed will be returned. For example: platform.image_codename() ... "maguro" """ return _PlatformDetector.create().image_codename def is_tablet(): """Indicate whether system is a tablet. The 'ro.build.characteristics' property is checked for 'tablet'. For example: platform.tablet() ... True :returns: boolean indicating whether this is a tablet """ return _PlatformDetector.create().is_tablet def get_display_server(): """Returns display server type. :returns: string indicating display server type. """ return os.environ.get('XDG_SESSION_TYPE', 'UNKNOWN').upper() # Different vers. of psutil across Trusty and Utopic have name as either a # string or a method. def _get_process_name(proc): if callable(proc): return proc() elif isinstance(proc, str): return proc else: raise ValueError("Unknown process name format.") class _PlatformDetector(object): _cached_detector = None @staticmethod def create(): """Create a platform detector object, or return one we baked earlier.""" if _PlatformDetector._cached_detector is None: _PlatformDetector._cached_detector = _PlatformDetector() return _PlatformDetector._cached_detector def __init__(self): self.model = "Desktop" self.image_codename = "Desktop" self.is_tablet = False property_file = _get_property_file() if property_file is not None: self.update_values_from_build_file(property_file) def update_values_from_build_file(self, property_file): """Read build.prop file and parse it.""" properties = _parse_build_properties_file(property_file) self.model = properties.get('ro.product.model', "Desktop") self.image_codename = properties.get('ro.product.name', "Desktop") self.is_tablet = ('ro.build.characteristics' in properties and 'tablet' in properties['ro.build.characteristics']) def _get_property_file_path(): return '/system/build.prop' def _get_property_file(): """Return a file-like object that contains the contents of the build properties file, if it exists, or None. """ path = _get_property_file_path() try: return open(path) except IOError: return None def _parse_build_properties_file(property_file): """Parse 'property_file', which must be a file-like object containing the system build properties. Returns a dictionary of key,value pairs. """ properties = {} for line in property_file: line = line.strip() if not line or line.startswith('#'): continue split_location = line.find('=') if split_location == -1: continue key = line[:split_location] value = line[split_location + 1:] properties[key] = value return properties ./autopilot/_info.py0000644000004100000410000000475414002063567014744 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Provide version and package availibility information for autopilot.""" import subprocess __all__ = [ 'get_version_string', 'have_vis', 'version', ] version = '1.6.1' def have_vis(): """Return true if the vis package is installed.""" try: from autopilot.vis import vis_main # flake8: noqa return True except ImportError: return False def get_version_string(): """Return the autopilot source and package versions.""" version_string = "Autopilot Source Version: " + _get_source_version() pkg_version = _get_package_version() if pkg_version: version_string += "\nAutopilot Package Version: " + pkg_version return version_string def _get_source_version(): return version def _get_package_version(): """Get the version of the currently installed package version, or None. Only returns the package version if the package is installed, *and* we seem to be running the system-wide installed code. """ if _running_in_system(): return _get_package_installed_version() return None def _running_in_system(): """Return True if we're running autopilot from the system installation dir.""" return __file__.startswith('/usr/') def _get_package_installed_version(): """Get the version string of the system-wide installed package, or None if it is not installed. """ try: return subprocess.check_output( [ "dpkg-query", "--showformat", "${Version}", "--show", "python3-autopilot", ], universal_newlines=True ).strip() except subprocess.CalledProcessError: return None ./autopilot/utilities.py0000644000004100000410000004642114002063564015657 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Various utility classes and functions that are useful when running tests.""" from contextlib import contextmanager from decorator import decorator import functools import inspect import logging import os import psutil import time import timeit from testtools.content import text_content from unittest.mock import Mock from functools import wraps from autopilot.exceptions import BackendException logger = logging.getLogger(__name__) def safe_text_content(text): """Return testtools.content.Content object. Safe in the sense that it will catch any attempt to attach NoneType objects. :raises ValueError: If `text` is not a text-type object. """ if not isinstance(text, str): raise TypeError( 'text argument must be string not {}'.format( type(text).__name__ ) ) return text_content(text) def _pick_backend(backends, preferred_backend): """Pick a backend and return an instance of it.""" possible_backends = list(backends.keys()) get_debug_logger().debug( "Possible backends: %s", ','.join(possible_backends)) if preferred_backend: if preferred_backend in possible_backends: # make preferred_backend the first list item possible_backends.remove(preferred_backend) possible_backends.insert(0, preferred_backend) else: raise RuntimeError("Unknown backend '%s'" % (preferred_backend)) failure_reasons = [] for be in possible_backends: try: return backends[be]() except Exception as e: get_debug_logger().warning("Can't create backend %s: %r", be, e) failure_reasons.append('%s: %r' % (be, e)) if preferred_backend != '': raise BackendException(e) raise RuntimeError( "Unable to instantiate any backends\n%s" % '\n'.join(failure_reasons)) # Taken from http://ur1.ca/eqapv # licensed under the MIT license. class Silence(object): """Context manager which uses low-level file descriptors to suppress output to stdout/stderr, optionally redirecting to the named file(s). Example:: with Silence(): # do something that prints to stdout or stderr """ def __init__(self, stdout=os.devnull, stderr=os.devnull, mode='wb'): self.outfiles = stdout, stderr self.combine = (stdout == stderr) self.mode = mode def __enter__(self): import sys self.sys = sys # save previous stdout/stderr self.saved_streams = saved_streams = sys.__stdout__, sys.__stderr__ self.fds = fds = [s.fileno() for s in saved_streams] self.saved_fds = map(os.dup, fds) # flush any pending output for s in saved_streams: s.flush() # open surrogate files if self.combine: null_streams = [open(self.outfiles[0], self.mode, 0)] * 2 if self.outfiles[0] != os.devnull: # disable buffering so output is merged immediately sys.stdout, sys.stderr = map(os.fdopen, fds, ['w']*2, [0]*2) else: null_streams = [open(f, self.mode, 0) for f in self.outfiles] self.null_fds = null_fds = [s.fileno() for s in null_streams] self.null_streams = null_streams # overwrite file objects and low-level file descriptors map(os.dup2, null_fds, fds) def __exit__(self, *args): sys = self.sys # flush any pending output for s in self.saved_streams: s.flush() # restore original streams and file descriptors map(os.dup2, self.saved_fds, self.fds) sys.stdout, sys.stderr = self.saved_streams # clean up for s in self.null_streams: s.close() for fd in self.saved_fds: os.close(fd) return False class LogFormatter(logging.Formatter): # this is the default format to use for logging log_format = ( "%(asctime)s %(levelname)s %(module)s:%(lineno)d - %(message)s") def __init__(self): super(LogFormatter, self).__init__(self.log_format) def formatTime(self, record, datefmt=None): ct = self.converter(record.created) if datefmt: s = time.strftime(datefmt, ct) else: t = time.strftime("%H:%M:%S", ct) s = "%s.%03d" % (t, record.msecs) return s class Timer(object): """A context-manager that times a block of code, writing the results to the log.""" def __init__(self, code_name, log_level=logging.DEBUG): self.code_name = code_name self.log_level = log_level self.start = 0 self.logger = get_debug_logger() def __enter__(self): self.start = timeit.default_timer() def __exit__(self, *args): self.end = timeit.default_timer() elapsed = self.end - self.start self.logger.log( self.log_level, "'%s' took %.3fs", self.code_name, elapsed) class StagnantStateDetector(object): """Detect when the state of something doesn't change over many iterations. Example of use:: state_check = StagnantStateDetector(threshold=5) x, y = get_current_position() while not at_position(target_x, target_y): move_toward_position(target_x, target_y) x, y = get_current_position() try: # this will raise an exception after the current position # hasn't changed on the 6th time the check is performed. loop_detector.check_state(x, y) except StagnantStateDetector.StagnantState as e: e.args = ("Position has not moved.", ) raise """ class StagnantState(Exception): pass def __init__(self, threshold): """ :param threshold: Amount of times the updated state can fail to differ consecutively before raising an exception. :raises ValueError: if *threshold* isn't a positive integer. """ if type(threshold) is not int or threshold <= 0: raise ValueError("Threshold must be a positive integer.") self._threshold = threshold self._stagnant_count = 0 self._previous_state_hash = -1 def check_state(self, *state): """Check if there is a difference between the previous state and state. :param state: Hashable state argument to compare against the previous iteration :raises TypeError: when state is unhashable """ state_hash = hash(state) if state_hash == self._previous_state_hash: self._stagnant_count += 1 if self._stagnant_count >= self._threshold: raise StagnantStateDetector.StagnantState( "State has been the same for %d iterations" % self._threshold ) else: self._stagnant_count = 0 self._previous_state_hash = state_hash def get_debug_logger(): """Get a logging object to be used as a debug logger only. :returns: logger object from logging module """ logger = logging.getLogger("autopilot.debug") logger.addFilter(DebugLogFilter()) return logger class DebugLogFilter(object): """A filter class for the logging framework that allows us to turn off the debug log. """ debug_log_enabled = False def filter(self, record): return int(self.debug_log_enabled) def deprecated(alternative): """Write a deprecation warning to the logging framework.""" def fdec(fn): @wraps(fn) def wrapped(*args, **kwargs): outerframe_details = inspect.getouterframes( inspect.currentframe())[1] filename, line_number, function_name = outerframe_details[1:4] logger.warning( "WARNING: in file \"{0}\", line {1} in {2}\n" "This function is deprecated. Please use '{3}' instead.\n" .format(filename, line_number, function_name, alternative) ) return fn(*args, **kwargs) return wrapped return fdec class _CleanupWrapper(object): """Support for calling 'addCleanup' outside the test case.""" def __init__(self): self._test_instance = None def __call__(self, callable, *args, **kwargs): if self._test_instance is None: raise RuntimeError( "Out-of-test addCleanup can only be called while an autopilot " "test case is running!") self._test_instance.addCleanup(callable, *args, **kwargs) def set_test_instance(self, test_instance): self._test_instance = test_instance test_instance.addCleanup(self._on_test_ended) def _on_test_ended(self): self._test_instance = None addCleanup = _CleanupWrapper() _cleanup_objects = [] class _TestCleanupMeta(type): """Metaclass to inject the object into on test start/end functionality.""" def __new__(cls, classname, bases, classdict): class EmptyStaticMethod(object): """Class used to give us 'default classmethods' for those that don't provide them. """ def __get__(self, obj, klass=None): if klass is None: klass = type(obj) def place_holder_method(*args): pass return place_holder_method default_methods = { 'on_test_start': EmptyStaticMethod(), 'on_test_end': EmptyStaticMethod(), } default_methods.update(classdict) class_object = type.__new__(cls, classname, bases, default_methods) _cleanup_objects.append(class_object) return class_object CleanupRegistered = _TestCleanupMeta('CleanupRegistered', (object,), {}) def action_on_test_start(test_instance): import sys for obj in _cleanup_objects: try: obj.on_test_start(test_instance) except KeyboardInterrupt: raise except: test_instance._report_traceback(sys.exc_info()) def action_on_test_end(test_instance): import sys for obj in _cleanup_objects: try: obj.on_test_end(test_instance) except KeyboardInterrupt: raise except: test_instance._report_traceback(sys.exc_info()) def on_test_started(test_case_instance): test_case_instance.addCleanup(action_on_test_end, test_case_instance) action_on_test_start(test_case_instance) addCleanup.set_test_instance(test_case_instance) class MockableSleep(object): """Delay execution for a certain number of seconds. Functionally identical to `time.sleep`, except we can replace it during unit tests. To delay execution for 10 seconds, use it like this:: from autopilot.utilities import sleep sleep(10) To mock out all calls to sleep, one might do this instead:: from autopilot.utilities import sleep with sleep.mocked() as mock_sleep: sleep(10) # actually does nothing! self.assertEqual(mock_sleep.total_time_slept(), 10.0) """ def __init__(self): self._mock_count = 0.0 self._mocked = False def __call__(self, t): if not self._mocked: time.sleep(t) else: self._mock_count += t @contextmanager def mocked(self): self.enable_mock() try: yield self finally: self.disable_mock() def enable_mock(self): self._mocked = True self._mock_count = 0.0 def disable_mock(self): self._mocked = False def total_time_slept(self): return self._mock_count sleep = MockableSleep() @decorator def compatible_repr(f, *args, **kwargs): result = f(*args, **kwargs) if not isinstance(result, str): return result.decode('utf-8') return result def _raise_on_unknown_kwargs(kwargs): """Raise ValueError on unknown keyword arguments. The standard use case is to warn callers that they've passed an unknown keyword argument. For example:: def my_function(**kwargs): known_option = kwargs.pop('known_option') _raise_on_unknown_kwargs(kwargs) Given the code above, this will not raise any exceptions:: my_function(known_option=123) ...but this code *will* raise a ValueError:: my_function(known_option=123, other_option=456) """ if kwargs: arglist = [repr(k) for k in kwargs.keys()] arglist.sort() raise ValueError( "Unknown keyword arguments: %s." % (', '.join(arglist)) ) class cached_result(object): """A simple caching decorator. This class is deliberately simple. It does not handle unhashable types, keyword arguments, and has no built-in size control. """ def __init__(self, f): functools.update_wrapper(self, f) self.f = f self._cache = {} def __call__(self, *args): try: return self._cache[args] except KeyError: result = self.f(*args) self._cache[args] = result return result except TypeError: raise TypeError( "The '%r' function can only be called with hashable arguments." ) def reset_cache(self): self._cache.clear() class EventDelay(object): """Delay execution of a subsequent event for a certain period of time. To delay the execution of a subsequent event for two seconds use it like this:: from autopilot.utilities import EventDelay event_delayer = EventDelay() def print_something(): event_delayer.delay(2) print("Hi! I am an event.") print_something() # It will take 2 seconds for second print() # to happen. print_something() """ def __init__(self): self._last_event = 0.0 @contextmanager def mocked(self): """Enable mocking for the EventDelay class Also mocks all calls to autopilot.utilities.sleep. One my use it like:: from autopilot.utilities import EventDelay event_delayer = EventDelay() with event_delayer.mocked() as mocked_delay: event_delayer.delay(3) # This call will return instantly as the sleep # is mocked, just updating the _last_event variable. event_delayer.delay(10) self.assertThat(mocked_delay._last_event, GreaterThan(0.0)) """ sleep.enable_mock() try: yield self finally: sleep.disable_mock() def last_event_time(self): """return the time when delay() was last called.""" return self._last_event def delay(self, duration, current_time=time.monotonic): """Delay the next event for a given amount of time. To humanize events, so that if a certain action is repeated continuously, there is a delay between each subsequent action. :param duration: Time interval between events. :param current_time: Specify the block of time to use as relative time. It is a float, representing time with precision of microseconds. Only for testing purpose. Default value is the monotonic time. 0.1 is the tenth part of a second. :raises ValueError: If the time stopped or went back since last event. """ monotime = current_time() _raise_if_time_delta_not_sane(monotime, self._last_event) time_slept = 0.0 if monotime < (self._last_event + duration): time_slept = _sleep_for_calculated_delta( monotime, self._last_event, duration ) self._last_event = monotime + time_slept def _raise_if_time_delta_not_sane(current_time, last_event_time): """Will raise a ValueError exception if current_time is before the last event or equal to it. """ if current_time == last_event_time: raise ValueError( 'current_time must be more than the last event time.' ) elif current_time < last_event_time: raise ValueError( 'current_time must not be behind the last event time.' ) def _sleep_for_calculated_delta(current_time, last_event_time, gap_duration): """Sleep for the remaining time between the last event time and duration. Given a duration in fractional seconds, ensure that at least that given amount of time occurs since the last event time. e.g. If 4 seconds have elapsed since the last event and the requested gap duration was 10 seconds, sleep for 6 seconds. :param float current_timestamp: Current monotonic time, in fractional seconds, used to calculate the time delta since last event. :param float last_event_timestamp: The last timestamp that the previous delay occured. :param float gap_duration: Maximum time, in fractional seconds, to be slept between two events. :return: Time, in fractional seconds, for which sleep happened. :raises ValueError: If last_event_time equals current_time or is ahead of current_time. """ _raise_if_time_delta_not_sane(current_time, last_event_time) time_delta = (last_event_time + gap_duration) - current_time if time_delta > 0.0: sleep(time_delta) return time_delta else: return 0.0 class MockableProcessIter: def __init__(self): self._mocked = False self._fake_processes = [] def __call__(self): if not self._mocked: return psutil.process_iter() else: return self.mocked_process_iter() @contextmanager def mocked(self, fake_processes): self.enable_mock(fake_processes) try: yield self finally: self.disable_mock() def enable_mock(self, fake_processes): self._mocked = True self._fake_processes = fake_processes def disable_mock(self): self._mocked = False self._fake_processes = [] def create_mock_process(self, name, pid): mock_process = Mock() mock_process.name = lambda: name mock_process.pid = pid return mock_process def mocked_process_iter(self): for process in self._fake_processes: yield self.create_mock_process( process.get('name'), process.get('pid') ) process_iter = MockableProcessIter() ./autopilot/__init__.py0000644000004100000410000000175514002063564015404 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot._info import get_version_string, have_vis, version from autopilot._config import get_test_configuration __all__ = [ 'get_test_configuration', 'get_version_string', 'have_vis', 'version', ] ./autopilot/testresult.py0000644000004100000410000001153514002063564016060 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Autopilot test result classes""" import logging from testtools import ( ExtendedToOriginalDecorator, ExtendedToStreamDecorator, TestResultDecorator, TextTestResult, try_import, ) from autopilot.globals import get_log_verbose from autopilot.utilities import _raise_on_unknown_kwargs class LoggedTestResultDecorator(TestResultDecorator): """A decorator that logs messages to python's logging system.""" def _log(self, level, message): """Perform the actual message logging.""" if get_log_verbose(): logging.getLogger().log(level, message) def _log_details(self, level, test): """Log the relavent test details.""" if hasattr(test, "getDetails"): details = test.getDetails() for detail in details: # Skip the test-log as it was logged while the test executed if detail == "test-log": continue detail_content = details[detail] if detail_content.content_type.type == "text": text = "%s: {{{\n%s}}}" % ( detail, detail_content.as_text() ) else: text = "Binary attachment: \"%s\" (%s)" % ( detail, detail_content.content_type ) self._log(level, text) def addSuccess(self, test, details=None): self._log(logging.INFO, "OK: %s" % (test.id())) return super().addSuccess(test, details) def addError(self, test, err=None, details=None): self._log(logging.ERROR, "ERROR: %s" % (test.id())) self._log_details(logging.ERROR, test) return super().addError(test, err, details) def addFailure(self, test, err=None, details=None): """Called for a test which failed an assert.""" self._log(logging.ERROR, "FAIL: %s" % (test.id())) self._log_details(logging.ERROR, test) return super().addFailure(test, err, details) def addSkip(self, test, reason=None, details=None): self._log(logging.INFO, "SKIP: %s" % test.id()) return super().addSkip(test, reason, details) def addUnexpectedSuccess(self, test, details=None): self._log(logging.ERROR, "UNEXPECTED SUCCESS: %s" % test.id()) self._log_details(logging.ERROR, test) return super().addUnexpectedSuccess(test, details) def addExpectedFailure(self, test, err=None, details=None): self._log(logging.INFO, "EXPECTED FAILURE: %s" % test.id()) return super().addExpectedFailure(test, err, details) def get_output_formats(): """Get information regarding the different output formats supported. :returns: dict of supported formats and appropriate construct functions """ supported_formats = {} supported_formats['text'] = _construct_text if try_import('junitxml'): supported_formats['xml'] = _construct_xml if try_import('subunit'): supported_formats['subunit'] = _construct_subunit return supported_formats def get_default_format(): return 'text' def _construct_xml(**kwargs): from junitxml import JUnitXmlResult stream = kwargs.pop('stream') failfast = kwargs.pop('failfast') _raise_on_unknown_kwargs(kwargs) result_object = LoggedTestResultDecorator( ExtendedToOriginalDecorator( JUnitXmlResult(stream) ) ) result_object.failfast = failfast return result_object def _construct_text(**kwargs): stream = kwargs.pop('stream') failfast = kwargs.pop('failfast') _raise_on_unknown_kwargs(kwargs) return LoggedTestResultDecorator(TextTestResult(stream, failfast)) def _construct_subunit(**kwargs): from subunit import StreamResultToBytes stream = kwargs.pop('stream') failfast = kwargs.pop('failfast') _raise_on_unknown_kwargs(kwargs) result_object = LoggedTestResultDecorator( ExtendedToStreamDecorator( StreamResultToBytes(stream) ) ) result_object.failfast = failfast return result_object ./autopilot/dbus_handler.py0000644000004100000410000000347514002063564016300 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Initialise dbus once using glib mainloop.""" from dbus._dbus import BusConnection import dbus from dbus.mainloop.glib import DBusGMainLoop _glib_loop_set = False # DBus has an annoying bug where we need to initialise it with the gobject main # loop *before* it's initialised anywhere else. This module exists so we can # initialise the dbus module once, and once only. def _ensure_glib_loop_set(): global _glib_loop_set if not _glib_loop_set: DBusGMainLoop(set_as_default=True) _glib_loop_set = True def get_session_bus(): """Return a session bus that has had the DBus GLib main loop initialised. """ _ensure_glib_loop_set() return dbus.SessionBus() def get_system_bus(): """Return a system bus that has had the DBus GLib main loop initialised. """ _ensure_glib_loop_set() return dbus.SystemBus() def get_custom_bus(bus_address): """Return a custom bus that has had the DBus GLib main loop initialised. """ _ensure_glib_loop_set() return BusConnection(bus_address) ./autopilot/gestures.py0000644000004100000410000000522114002063564015476 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Gestural support for autopilot. This module contains functions that can generate touch and multi-touch gestures for you. This is a convenience for the test author - there is nothing to prevent you from generating your own gestures! """ from autopilot.input import Touch from autopilot.utilities import sleep def pinch(center, vector_start, vector_end): """Perform a two finger pinch (zoom) gesture. :param center: The coordinates (x,y) of the center of the pinch gesture. :param vector_start: The (x,y) values to move away from the center for the start. :param vector_end: The (x,y) values to move away from the center for the end. The fingers will move in 100 steps between the start and the end points. If start is smaller than end, the gesture will zoom in, otherwise it will zoom out. """ finger_1_start = [center[0] - vector_start[0], center[1] - vector_start[1]] finger_2_start = [center[0] + vector_start[0], center[1] + vector_start[1]] finger_1_end = [center[0] - vector_end[0], center[1] - vector_end[1]] finger_2_end = [center[0] + vector_end[0], center[1] + vector_end[1]] dx = 1.0 * (finger_1_end[0] - finger_1_start[0]) / 100 dy = 1.0 * (finger_1_end[1] - finger_1_start[1]) / 100 finger_1 = Touch.create() finger_2 = Touch.create() finger_1.press(*finger_1_start) finger_2.press(*finger_2_start) finger_1_cur = [finger_1_start[0] + dx, finger_1_start[1] + dy] finger_2_cur = [finger_2_start[0] - dx, finger_2_start[1] - dy] for i in range(0, 100): finger_1.move(*finger_1_cur) finger_2.move(*finger_2_cur) sleep(0.005) finger_1_cur = [finger_1_cur[0] + dx, finger_1_cur[1] + dy] finger_2_cur = [finger_2_cur[0] - dx, finger_2_cur[1] - dy] finger_1.move(*finger_1_end) finger_2.move(*finger_2_end) finger_1.release() finger_2.release() ./autopilot/globals.py0000644000004100000410000000436614002063564015271 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot._debug import DebugProfile import logging logger = logging.getLogger(__name__) _log_verbose = False def get_log_verbose(): """Return true if the user asked for verbose logging.""" global _log_verbose return _log_verbose def set_log_verbose(verbose): """Set whether or not we should log verbosely.""" global _log_verbose if type(verbose) is not bool: raise TypeError("Verbose flag must be a boolean.") _log_verbose = verbose _debug_profile_fixture = DebugProfile def set_debug_profile_fixture(fixture_class): global _debug_profile_fixture _debug_profile_fixture = fixture_class def get_debug_profile_fixture(): global _debug_profile_fixture return _debug_profile_fixture _default_timeout_value = 10 def set_default_timeout_period(new_timeout): global _default_timeout_value _default_timeout_value = new_timeout def get_default_timeout_period(): global _default_timeout_value return _default_timeout_value _long_timeout_value = 30 def set_long_timeout_period(new_timeout): global _long_timeout_value _long_timeout_value = new_timeout def get_long_timeout_period(): global _long_timeout_value return _long_timeout_value # The timeout to apply to each test. 0 means no timeout. Value is in seconds. _test_timeout = 0 def set_test_timeout(new_timeout): global _test_timeout _test_timeout = new_timeout def get_test_timeout(): global _test_timeout return _test_timeout ./autopilot/_debug.py0000644000004100000410000000775214002063564015075 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Support for debug profiles. Debug profiles are used to attach various items of debug information to a test result. Profiles are named, but the names are for human consumption only, and have no other significance. Each piece of debug information is also a fixture, so debug profiles are fixtures of fixtures! """ from autopilot.content import follow_file from autopilot._fixtures import FixtureWithDirectAddDetail class CaseAddDetailToNormalAddDetailDecorator(object): """A decorator object to turn a FixtureWithDirectAddDetail object into an object that supports addDetail. """ def __init__(self, decorated): self.decorated = decorated def addDetail(self, name, content): return self.decorated.caseAddDetail(name, content) def __repr__(self): return '<%s %r>' % (self.__class__.__name__, self.decorated) def __getattr__(self, name): return getattr(self.decorated, name) class DebugProfile(FixtureWithDirectAddDetail): """A debug profile that contains manny debug objects.""" name = "" def __init__(self, caseAddDetail, debug_fixtures=[]): """Create a debug profile. :param caseAddDetail: A closure over the testcase's addDetail method, or a similar substitution method. :param debug_fixtures: a list of fixture class objects, each one will be set up when this debug profile is used. """ super(DebugProfile, self).__init__(caseAddDetail) self.debug_fixtures = debug_fixtures def setUp(self): super(DebugProfile, self).setUp() for FixtureClass in self.debug_fixtures: self.useFixture(FixtureClass(self.caseAddDetail)) class NormalDebugProfile(DebugProfile): name = "normal" def __init__(self, caseAddDetail): super(NormalDebugProfile, self).__init__( caseAddDetail, [ SyslogDebugObject, ], ) class VerboseDebugProfile(DebugProfile): name = "verbose" def __init__(self, caseAddDetail): super(VerboseDebugProfile, self).__init__( caseAddDetail, [ SyslogDebugObject, ], ) def get_default_debug_profile(): return NormalDebugProfile def get_all_debug_profiles(): return { NormalDebugProfile, VerboseDebugProfile, } class DebugObject(FixtureWithDirectAddDetail): """A single piece of debugging information.""" class LogFileDebugObject(DebugObject): """Monitors a log file on disk.""" def __init__(self, caseAddDetail, log_path): """Create a debug object that will monitor the contents of a log file on disk. :param caseAddDetail: A closure over the testcase's addDetail method, or a similar substitution method. :param log_path: The path to monitor. """ super(LogFileDebugObject, self).__init__(caseAddDetail) self.log_path = log_path def setUp(self): super(LogFileDebugObject, self).setUp() follow_file( self.log_path, CaseAddDetailToNormalAddDetailDecorator(self) ) def SyslogDebugObject(caseAddDetail): return LogFileDebugObject(caseAddDetail, "/var/log/syslog") ./autopilot/vis/0000755000004100000410000000000014002063567014067 5ustar www-datawww-data./autopilot/vis/objectproperties.py0000644000004100000410000001644114002063564020027 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Code for introspection tree object properties.""" from PyQt4 import QtGui, QtCore from autopilot.vis.resources import get_qt_icon, dbus_string_rep from autopilot.introspection.qt import QtObjectProxyMixin __all__ = ['TreeNodeDetailWidget'] class TreeNodeDetailWidget(QtGui.QTabWidget): """A widget that shows tree node details.""" def __init__(self, parent): super(TreeNodeDetailWidget, self).__init__(parent) self.views = [] for view_class in ALL_VIEWS: view = view_class() self.views.append(view) def tree_node_changed(self, new_node): """Call when the selected tree node has changed. This method will update the available tabs to reflect those suitable for the new tree node selected. """ for view in self.views: view_tab_idx = self.indexOf(view) if view_tab_idx == -1: # view is not currently shown. if view.is_relevant(new_node): self.addTab(view, view.icon(), view.name()) else: # view is in tab bar already. if not view.is_relevant(new_node): self.removeTab(view_tab_idx) for i in range(self.count()): self.widget(i).new_node_selected(new_node) class AbstractView(QtGui.QWidget): """An abstract class that outlines the methods required to be used in the details view widget. """ def name(self): """Return the name of the view.""" raise NotImplementedError( "This method must be implemented in subclasses!") def icon(self): """Return the icon for the view (optionsla).""" return QtGui.QIcon() def is_relevant(self, node): """Return true if the view can display data about this tree node.""" raise NotImplementedError( "This method must be implemented in subclasses!") def new_node_selected(self, node): raise NotImplementedError( "This method must be implemented in subclasses!") class PropertyView(AbstractView): """A view that displays the basic exported object properties in a table.""" def __init__(self, *args, **kwargs): super(PropertyView, self).__init__(*args, **kwargs) header_titles = ["Name", "Value"] self.details_layout = QtGui.QVBoxLayout(self) self.table_view = QtGui.QTableWidget() self.table_view.setColumnCount(2) self.table_view.verticalHeader().setVisible(False) self.table_view.setAlternatingRowColors(True) self.table_view.setHorizontalHeaderLabels(header_titles) self.table_view.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) self.details_layout.addWidget(self.table_view) def name(self): return "Properties" def is_relevant(self, node): return node is not None def new_node_selected(self, node): self.table_view.setSortingEnabled(False) self.table_view.clearContents() object_details = node.get_properties() # remove the Children property - we don't care about it: object_details.pop("Children", None) self.table_view.setRowCount(len(object_details)) for i, key in enumerate(object_details): details_string = dbus_string_rep(object_details[key]) item_name = QtGui.QTableWidgetItem(key) item_details = QtGui.QTableWidgetItem( details_string) self.table_view.setItem(i, 0, item_name) self.table_view.setItem(i, 1, item_details) self.table_view.setSortingEnabled(True) self.table_view.sortByColumn(0, QtCore.Qt.AscendingOrder) self.table_view.resizeColumnsToContents() class SignalView(AbstractView): """A view that exposes signals available on an object.""" def __init__(self, *args, **kwargs): super(SignalView, self).__init__(*args, **kwargs) self.details_layout = QtGui.QVBoxLayout(self) self.signals_table = QtGui.QTableWidget() self.signals_table.setColumnCount(1) self.signals_table.verticalHeader().setVisible(False) self.signals_table.setAlternatingRowColors(True) self.signals_table.setHorizontalHeaderLabels(["Signal Signature"]) self.signals_table.setEditTriggers( QtGui.QAbstractItemView.NoEditTriggers) self.details_layout.addWidget(self.signals_table) def name(self): return "Signals" def icon(self): return get_qt_icon() def is_relevant(self, node): return node is not None and isinstance(node, QtObjectProxyMixin) def new_node_selected(self, node): self.signals_table.setSortingEnabled(False) self.signals_table.clearContents() signals = node.get_signals() self.signals_table.setRowCount(len(signals)) for i, signal in enumerate(signals): self.signals_table.setItem( i, 0, QtGui.QTableWidgetItem(str(signal))) self.signals_table.setSortingEnabled(True) self.signals_table.sortByColumn(0, QtCore.Qt.AscendingOrder) self.signals_table.resizeColumnsToContents() class SlotView(AbstractView): """A View that exposes slots on an object.""" def __init__(self, *args, **kwargs): super(SlotView, self).__init__(*args, **kwargs) self.details_layout = QtGui.QVBoxLayout(self) self.slots_table = QtGui.QTableWidget() self.slots_table.setColumnCount(1) self.slots_table.verticalHeader().setVisible(False) self.slots_table.setAlternatingRowColors(True) self.slots_table.setHorizontalHeaderLabels(["Slot Signature"]) self.slots_table.setEditTriggers( QtGui.QAbstractItemView.NoEditTriggers) self.details_layout.addWidget(self.slots_table) def name(self): return "Slots" def icon(self): return get_qt_icon() def is_relevant(self, node): return node is not None and isinstance(node, QtObjectProxyMixin) def new_node_selected(self, node): self.slots_table.setSortingEnabled(False) self.slots_table.clearContents() signals = node.get_slots() self.slots_table.setRowCount(len(signals)) for i, signal in enumerate(signals): self.slots_table.setItem(i, 0, QtGui.QTableWidgetItem(str(signal))) self.slots_table.setSortingEnabled(True) self.slots_table.sortByColumn(0, QtCore.Qt.AscendingOrder) self.slots_table.resizeColumnsToContents() ALL_VIEWS = [ PropertyView, SignalView, SlotView, ] ./autopilot/vis/dbus_search.py0000644000004100000410000001030714002063567016724 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging import os from os.path import join try: import lxml.etree as ET except ImportError: from xml.etree import ElementTree as ET _logger = logging.getLogger(__name__) class DBusInspector(object): def __init__(self, bus): self._bus = bus self._xml_processor = None self.p_dbus = self._bus.get_object('org.freedesktop.DBus', '/') def set_xml_processor(self, processor): self._xml_processor = processor def __call__(self, conn_name, obj_name='/'): """Introspects and applies the reply_handler to the dbus object constructed from the provided bus, connection and object name. """ handler = lambda xml: self._xml_processor( conn_name, obj_name, xml ) error_handler = lambda *args: _logger.error("Error occured: %r" % args) obj = self._bus.get_object(conn_name, obj_name) # avoid introspecting our own PID, as that locks up with libdbus try: obj_pid = self.p_dbus.GetConnectionUnixProcessID( conn_name, dbus_interface='org.freedesktop.DBus' ) if obj_pid == os.getpid(): return except: # can't get D-BUS daemon's own pid, ignore pass obj.Introspect( dbus_interface='org.freedesktop.DBus.Introspectable', reply_handler=handler, error_handler=error_handler ) class XmlProcessor(object): def __init__(self): self._dbus_inspector = None self._success_callback = None def set_dbus_inspector(self, inspector): self._dbus_inspector = inspector def set_success_callback(self, callback): """Must be a callable etc.""" self._success_callback = callback def __call__(self, conn_name, obj_name, xml): try: root = ET.fromstring(xml) for child in list(root): try: child_name = join(obj_name, child.attrib['name']) except KeyError: continue # If we found another node, make sure we get called again with # a new XML block. if child.tag == 'node': self._dbus_inspector(conn_name, child_name) # If we found an interface, call our success function with the # interface name elif child.tag == 'interface': iface_name = child_name.split('/')[-1] self._success_callback(conn_name, obj_name, iface_name) except ET.ParseError: _logger.warning( "Unable to parse XML response for %s (%s)" % (conn_name, obj_name) ) def _start_trawl(bus, connection_name, on_success_cb): """Start searching *connection_name* on *bus* for interfaces under org.freedesktop.DBus.Introspectable. on_success_cb gets called when an interface is found. """ if connection_name is None: raise ValueError("Connection name is required.") if not callable(on_success_cb): raise ValueError("on_success_cb needs to be callable.") dbus_inspector = DBusInspector(bus) xml_processor = XmlProcessor() dbus_inspector.set_xml_processor(xml_processor) xml_processor.set_dbus_inspector(dbus_inspector) xml_processor.set_success_callback(on_success_cb) dbus_inspector(connection_name) ./autopilot/vis/resources.py0000644000004100000410000000445314002063564016456 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os.path import dbus from PyQt4 import QtGui def get_qt_icon(): return QtGui.QIcon(":/trolltech/qmessagebox/images/qtlogo-64.png") def get_filter_icon(): return QtGui.QIcon("/usr/share/icons/gnome/32x32/actions/search.png") def get_overlay_icon(): name = "autopilot-toggle-overlay.svg" possible_locations = [ "/usr/share/icons/hicolor/scalable/actions/", os.path.join(os.path.dirname(__file__), '../../icons'), "icons", ] for location in possible_locations: path = os.path.join(location, name) if os.path.exists(path): return QtGui.QIcon(path) return QtGui.QIcon() def dbus_string_rep(dbus_type): """Get a string representation of various dbus types.""" if isinstance(dbus_type, dbus.Boolean): return repr(bool(dbus_type)) if isinstance(dbus_type, dbus.String): return dbus_type.encode('utf-8', errors='ignore').decode('utf-8') if (isinstance(dbus_type, dbus.Int16) or isinstance(dbus_type, dbus.UInt16) or isinstance(dbus_type, dbus.Int32) or isinstance(dbus_type, dbus.UInt32) or isinstance(dbus_type, dbus.Int64) or isinstance(dbus_type, dbus.UInt64)): return repr(int(dbus_type)) if isinstance(dbus_type, dbus.Double): return repr(float(dbus_type)) if (isinstance(dbus_type, dbus.Array) or isinstance(dbus_type, dbus.Struct)): return ', '.join([dbus_string_rep(i) for i in dbus_type]) else: return repr(dbus_type) ./autopilot/vis/main_window.py0000644000004100000410000004736114002063564016764 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import dbus import logging from PyQt4 import QtGui, QtCore from autopilot.exceptions import StateNotFoundError from autopilot.introspection._search import ( _get_dbus_address_object, _make_proxy_object_async ) from autopilot.introspection.constants import AP_INTROSPECTION_IFACE from autopilot.introspection.qt import QtObjectProxyMixin from autopilot.vis.objectproperties import TreeNodeDetailWidget from autopilot.vis.resources import ( get_filter_icon, get_overlay_icon, get_qt_icon, ) _logger = logging.getLogger(__name__) class MainWindow(QtGui.QMainWindow): def __init__(self, dbus_bus): super(MainWindow, self).__init__() self.selectable_interfaces = {} self.initUI() self.readSettings() self._dbus_bus = dbus_bus self.proxy_object = None def readSettings(self): settings = QtCore.QSettings() geometry = settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry.data()) window_state = settings.value("windowState") if window_state is not None: self.restoreState(window_state.data()) try: self.toggle_overlay_action.setChecked( settings.value("overlayChecked", type="bool") ) except TypeError: pass # raised when returned QVariant is invalid - probably on # first boot def closeEvent(self, event): settings = QtCore.QSettings() settings.setValue("geometry", self.saveGeometry()) settings.setValue("windowState", self.saveState()) settings.setValue( "overlayChecked", self.toggle_overlay_action.isChecked() ) self.visual_indicator.close() def initUI(self): self.setWindowTitle("Autopilot Vis") self.statusBar().showMessage('Waiting for first valid dbus connection') self.splitter = QtGui.QSplitter(self) self.splitter.setChildrenCollapsible(False) self.tree_view = ProxyObjectTreeViewWidget(self.splitter) self.detail_widget = TreeNodeDetailWidget(self.splitter) self.splitter.setStretchFactor(0, 0) self.splitter.setStretchFactor(1, 100) self.setCentralWidget(self.splitter) self.connection_list = ConnectionList() self.connection_list.currentIndexChanged.connect( self.conn_list_activated ) self.toolbar = self.addToolBar('Connection') self.toolbar.setObjectName('Connection Toolbar') self.toolbar.addWidget(self.connection_list) self.toolbar.addSeparator() self.filter_widget = FilterPane() self.filter_widget.apply_filter.connect(self.on_filter) self.filter_widget.reset_filter.connect(self.on_reset_filter) self.filter_widget.set_enabled(False) self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.filter_widget) self.toggle_dock_widget_action = self.filter_widget.toggleViewAction() self.toggle_dock_widget_action.setText('Show/Hide Filter Pane') self.toggle_dock_widget_action.setIcon(get_filter_icon()) self.toolbar.addAction(self.toggle_dock_widget_action) self.visual_indicator = VisualComponentPositionIndicator() self.toggle_overlay_action = self.toolbar.addAction( get_overlay_icon(), "Enable/Disable component overlay" ) self.toggle_overlay_action.setCheckable(True) self.toggle_overlay_action.toggled.connect( self.visual_indicator.setEnabled ) # our model object gets created later. self.tree_model = None def on_filter(self, node_name, filters): node_name = str(node_name) if self.proxy_object: p = self.proxy_object.select_many(node_name) self.tree_model.set_tree_roots(p) self.tree_view.set_filtered(True) # applying the filter will always invalidate the current overlay self.visual_indicator.tree_node_changed(None) def on_reset_filter(self): self.tree_model.set_tree_roots([self.proxy_object]) self.tree_view.set_filtered(False) # resetting the filter will always invalidate the current overlay self.visual_indicator.tree_node_changed(None) def on_interface_found(self, conn, obj, iface): if iface == AP_INTROSPECTION_IFACE: self.statusBar().showMessage('Updating connection list') try: dbus_address_instance = _get_dbus_address_object( str(conn), str(obj), self._dbus_bus) _make_proxy_object_async( dbus_address_instance, None, self.on_proxy_object_built, self.on_dbus_error ) except (dbus.DBusException, RuntimeError) as e: _logger.warning("Invalid introspection interface: %s" % str(e)) if self.connection_list.count() == 0: self.statusBar().showMessage('No valid connections exist.') def on_proxy_object_built(self, proxy_object): cls_name = proxy_object.__class__.__name__ if cls_name not in self.selectable_interfaces: self.selectable_interfaces[cls_name] = proxy_object self.update_selectable_interfaces() self.statusBar().clearMessage() def on_dbus_error(*args): print(args) def update_selectable_interfaces(self): selected_text = self.connection_list.currentText() self.connection_list.clear() self.connection_list.addItem("Please select a connection", None) for name, proxy_obj in self.selectable_interfaces.items(): if isinstance(proxy_obj, QtObjectProxyMixin): self.connection_list.addItem( get_qt_icon(), name, proxy_obj ) else: self.connection_list.addItem(name, proxy_obj) prev_selected = self.connection_list.findText(selected_text, QtCore.Qt.MatchExactly) if prev_selected == -1: prev_selected = 0 self.connection_list.setCurrentIndex(prev_selected) def conn_list_activated(self, index): proxy_object = self.connection_list.itemData(index) self.proxy_object = proxy_object if self.proxy_object: self.filter_widget.set_enabled(True) self.tree_model = VisTreeModel(self.proxy_object) self.tree_view.set_model(self.tree_model) self.tree_view.selection_changed.connect(self.tree_item_changed) else: self.filter_widget.set_enabled(False) self.tree_view.set_model(None) self.visual_indicator.tree_node_changed(None) self.detail_widget.tree_node_changed(None) def tree_item_changed(self, current, previous): tree_node = current.internalPointer() proxy = tree_node.dbus_object if tree_node is not None else None self.detail_widget.tree_node_changed(proxy) self.visual_indicator.tree_node_changed(proxy) class VisualComponentPositionIndicator(QtGui.QWidget): def __init__(self, parent=None): super(VisualComponentPositionIndicator, self).__init__(None) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.X11BypassWindowManagerHint | QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint ) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setStyleSheet( """\ QWidget { background-color: rgba(253, 255, 225, 128); } """ ) self.enabled = False self.proxy = None def tree_node_changed(self, proxy): self.proxy = proxy self._maybe_update() def paintEvent(self, paint_evt): opt = QtGui.QStyleOption() opt.init(self) p = QtGui.QPainter(self) self.style().drawPrimitive( QtGui.QStyle.PE_Widget, opt, p, self ) def setEnabled(self, enabled): self.enabled = enabled self._maybe_update() def _maybe_update(self): """Maybe update the visual overlay. Several things need to be taken into account: 1. The state of the UI toggle button, which determines whether the user expects us to be visible or not. Stored in 'self.enabled' 2. The current proxy object set, and whether it has a 'globalRect' attribute (stored in self.proxy) - the proxy object may be None as well. """ position = getattr(self.proxy, 'globalRect', None) should_be_visible = self.enabled and (position is not None) if should_be_visible: self.setGeometry(*position) if should_be_visible != self.isVisible(): self.setVisible(should_be_visible) class ProxyObjectTreeView(QtGui.QTreeView): """A subclass of QTreeView with a few customisations.""" def __init__(self, parent=None): super(ProxyObjectTreeView, self).__init__(parent) self.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) self.header().setResizeMode(QtGui.QHeaderView.ResizeToContents) self.header().setStretchLastSection(False) def scrollTo(self, index, hint=QtGui.QAbstractItemView.EnsureVisible): """Scroll the view to make the node at index visible. Overriden to stop autoScroll from horizontally jumping when selecting nodes, and to make arrow navigation work correctly when scrolling off the bottom of the viewport. :param index: The node to be made visible. :param hint: Where the visible item should be - this is ignored. """ # calculate the visual rect of the item we're scrolling to in viewport # coordinates. The default implementation gives us a rect that ends # in the RHS of the viewport, which isn't what we want. We use a # QFontMetrics instance to calculate the probably width of the text # beign rendered. This may not be totally accurate, but it seems good # enough. visual_rect = self.visualRect(index) fm = self.fontMetrics() text_width = fm.width(index.data()) visual_rect.setRight(visual_rect.left() + text_width) # horizontal scrolling is done per-pixel, with the scrollbar value # being the number of pixels past the RHS of the VP. For some reason # one needs to add 8 pixels - possibly this is for the tree expansion # widget? hbar = self.horizontalScrollBar() if visual_rect.right() + 8 > self.viewport().width(): offset = (visual_rect.right() - self.viewport().width() + hbar.value() + 8) hbar.setValue(offset) if visual_rect.left() < 0: offset = hbar.value() + visual_rect.left() - 8 hbar.setValue(offset) # Vertical scrollbar scrolls in steps equal to the height of each item vbar = self.verticalScrollBar() if visual_rect.bottom() > self.viewport().height(): offset_pixels = (visual_rect.bottom() - self.viewport().height() + vbar.value()) new_position = max( offset_pixels / visual_rect.height(), 1 ) + vbar.value() vbar.setValue(new_position) if visual_rect.top() < 0: new_position = min(visual_rect.top() / visual_rect.height(), -1) vbar.setValue(vbar.value() + new_position) class ProxyObjectTreeViewWidget(QtGui.QWidget): """A Widget that contains a tree view and a few other things.""" selection_changed = QtCore.pyqtSignal('QModelIndex', 'QModelIndex') def __init__(self, parent=None): super(ProxyObjectTreeViewWidget, self).__init__(parent) layout = QtGui.QVBoxLayout(self) self.tree_view = ProxyObjectTreeView() layout.addWidget(self.tree_view) self.status_label = QtGui.QLabel("Showing Filtered Results ONLY") self.status_label.hide() layout.addWidget(self.status_label) self.setLayout(layout) def set_model(self, model): self.tree_view.setModel(model) self.tree_view.selectionModel().currentChanged.connect( self.selection_changed ) self.set_filtered(False) def set_filtered(self, is_filtered): if is_filtered: self.status_label.show() self.tree_view.setStyleSheet("""\ QTreeView { background-color: #fdffe1; } """) else: self.status_label.hide() self.tree_view.setStyleSheet("") class ConnectionList(QtGui.QComboBox): """Used to show a list of applications we can connect to.""" def __init__(self): super(ConnectionList, self).__init__() self.setObjectName("ConnectionList") self.setSizeAdjustPolicy(QtGui.QComboBox.AdjustToContents) @QtCore.pyqtSlot(str) def trySetSelectedItem(self, desired_text): index = self.findText(desired_text) if index != -1: self.setCurrentIndex(index) class TreeNode(object): """Used to represent the tree data structure that is the backend of the treeview. Lazy loads a nodes children instead of waiting to load and store a static snapshot of the apps whole state. """ def __init__(self, parent=None, dbus_object=None): self.parent = parent self.name = dbus_object.__class__.__name__ self.dbus_object = dbus_object self._children = None @property def children(self): if self._children is None: self._children = [] try: for child in self.dbus_object.get_children(): self._children.append(TreeNode(self, child)) except StateNotFoundError: pass return self._children @property def num_children(self): """An optimisation that allows us to get the number of children without actually retrieving them all. This is useful since Qt needs to know if there are children (to draw the drop-down triangle thingie), but doesn't need to know about the details. """ # Thomi - 2014-04-09 - the code below is subtly broken because # libautopilot-qt returns items in the Children property that it never # exports. I'm reverting this optimisation and doing the simple thing # until that gets fixed. # https://bugs.launchpad.net/autopilot-qt/+bug/1286985 return len(self.children) # old code - re-enable once above bug has been fixed. num_children = 0 with self.dbus_object.no_automatic_refreshing(): if hasattr(self.dbus_object, 'Children'): num_children = len(self.dbus_object.Children) return num_children class VisTreeModel(QtCore.QAbstractItemModel): def __init__(self, proxy_object): """Create a new proxy object tree model. :param proxy_object: A DBus proxy object representing the root of the tree to show. """ super(VisTreeModel, self).__init__() self.tree_roots = [ TreeNode(dbus_object=proxy_object), ] def set_tree_roots(self, new_tree_roots): """Call this method to change the root nodes the model shows. :param new_tree_roots: An iterable of dbus proxy objects, each one will be a root node in the model after calling this method. """ self.beginResetModel() self.tree_roots = [TreeNode(dbus_object=r) for r in new_tree_roots] self.endResetModel() def index(self, row, col, parent): if not self.hasIndex(row, col, parent): return QtCore.QModelIndex() # If there's no parent, return the root of our tree: if not parent.isValid(): if row < len(self.tree_roots): return self.createIndex(row, col, self.tree_roots[row]) else: return QtCore.QModelIndex() else: parentItem = parent.internalPointer() try: childItem = parentItem.children[row] return self.createIndex(row, col, childItem) except IndexError: return QtCore.QModelIndex() def parent(self, index): if not index.isValid(): return QtCore.QModelIndex() childItem = index.internalPointer() if not childItem: return QtCore.QModelIndex() parentItem = childItem.parent if parentItem is None: return QtCore.QModelIndex() row = parentItem.children.index(childItem) return self.createIndex(row, 0, parentItem) def rowCount(self, parent): if not parent.isValid(): return len(self.tree_roots) else: return parent.internalPointer().num_children def columnCount(self, parent): return 1 def data(self, index, role): if not index.isValid(): return None if role == QtCore.Qt.DisplayRole: return index.internalPointer().name def headerData(self, column, orientation, role): if (orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole): return "Tree Node" return None class FilterPane(QtGui.QDockWidget): """A widget that provides a filter UI.""" apply_filter = QtCore.pyqtSignal(str, list) reset_filter = QtCore.pyqtSignal() class ControlWidget(QtGui.QWidget): def __init__(self, parent=None): super(FilterPane.ControlWidget, self).__init__(parent) self._layout = QtGui.QFormLayout(self) self.node_name_edit = QtGui.QLineEdit() self._layout.addRow( QtGui.QLabel("Node Name:"), self.node_name_edit ) btn_box = QtGui.QDialogButtonBox() self.apply_btn = btn_box.addButton(QtGui.QDialogButtonBox.Apply) self.apply_btn.setDefault(True) self.reset_btn = btn_box.addButton(QtGui.QDialogButtonBox.Reset) self._layout.addRow(btn_box) self.setLayout(self._layout) def __init__(self, parent=None): super(FilterPane, self).__init__("Filter Tree", parent) self.setObjectName("FilterTreePane") self.control_widget = FilterPane.ControlWidget(self) self.control_widget.node_name_edit.returnPressed.connect( self.on_apply_clicked ) self.control_widget.apply_btn.clicked.connect(self.on_apply_clicked) self.control_widget.reset_btn.clicked.connect(self.reset_filter) self.setWidget(self.control_widget) def on_apply_clicked(self): node_name = self.control_widget.node_name_edit.text() self.apply_filter.emit(node_name, []) def set_enabled(self, enabled): self.control_widget.setEnabled(enabled) ./autopilot/vis/bus_enumerator.py0000644000004100000410000000547614002063564017504 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from collections import defaultdict from autopilot.vis.dbus_search import _start_trawl from PyQt4.QtCore import ( pyqtSignal, QObject, ) class BusEnumerator(QObject): """A simple utility class to support enumeration of all DBus connections, objects, and interfaces. Create an instance of ths class, and connect to the new_interface_found signal. """ new_interface_found = pyqtSignal(str, str, str) def __init__(self, bus): super(BusEnumerator, self).__init__() self._bus = bus self._data = defaultdict(lambda: defaultdict(list)) def get_found_connections(self): """Get a list of found connection names. This may not be up to date.""" return list(self._data.keys()) def get_found_objects(self, connection_string): """Get a list of found objects for a particular connection name. This may be out of date. """ if connection_string not in self._data: raise KeyError("%s not in results" % connection_string) return list(self._data[connection_string].keys()) def get_found_interfaces(self, connection_string, object_path): """Get a list of found interfaces for a particular connection name and object path. This may be out of date. """ if connection_string not in self._data: raise KeyError("connection %s not in results" % connection_string) if object_path not in self._data[connection_string]: raise KeyError( "object %s not in results for connection %s" % (object_path, connection_string)) return self._data[connection_string][object_path] def start_trawl(self): """Start trawling the bus for interfaces.""" for connection in self._bus.list_names(): _start_trawl(self._bus, connection, self._add_hit) def _add_hit(self, conn_name, obj_name, interface_name): self.new_interface_found.emit(conn_name, obj_name, interface_name) self._data[conn_name][obj_name].append(interface_name) ./autopilot/vis/__init__.py0000644000004100000410000000303414002063564016175 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import dbus import sys def vis_main(extra_flags): # To aid in testing only import when we are launching the GUI component from dbus.mainloop.qt import DBusQtMainLoop from PyQt4 import QtGui from autopilot.vis.main_window import MainWindow from autopilot.vis.bus_enumerator import BusEnumerator app = QtGui.QApplication(sys.argv + extra_flags) app.setApplicationName("Autopilot") app.setOrganizationName("Canonical") dbus_loop = DBusQtMainLoop() session_bus = dbus.SessionBus(mainloop=dbus_loop) window = MainWindow(session_bus) bus_enumerator = BusEnumerator(session_bus) bus_enumerator.new_interface_found.connect(window.on_interface_found) bus_enumerator.start_trawl() window.show() sys.exit(app.exec_()) ./autopilot/testcase.py0000644000004100000410000004162114002063567015457 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """ Quick Start =========== The :class:`AutopilotTestCase` is the main class test authors will be interacting with. Every autopilot test case should derive from this class. :class:`AutopilotTestCase` derives from :class:`testtools.TestCase`, so test authors can use all the methods defined in that class as well. **Writing tests** Tests must be named: ``test_``, where ** is the name of the test. Test runners (including autopilot itself) look for methods with this naming convention. It is recommended that you make your test names descriptive of what each test is testing. For example, possible test names include:: test_ctrl_p_opens_print_dialog test_dash_remembers_maximized_state **Launching the Application Under Test** If you are writing a test for an application, you need to use the :meth:`~AutopilotTestCase.launch_test_application` method. This will launch the application, enable introspection, and return a proxy object representing the root of the application introspection tree. """ import logging import fixtures from testscenarios import TestWithScenarios from testtools import TestCase, RunTest from testtools.content import ContentType, content_from_stream from testtools.matchers import Equals from testtools.testcase import _ExpectedFailure from unittest.case import SkipTest from autopilot.application import ( NormalApplicationLauncher, ) from autopilot.display import Display, get_screenshot_data from autopilot.globals import get_debug_profile_fixture, get_test_timeout from autopilot.input import Keyboard, Mouse from autopilot.keybindings import KeybindingsHelper from autopilot.matchers import Eventually from autopilot.platform import get_display_server from autopilot.process import ProcessManager from autopilot.utilities import deprecated, on_test_started from autopilot._fixtures import OSKAlwaysEnabled from autopilot._timeout import Timeout from autopilot._logging import TestCaseLoggingFixture from autopilot._video import get_video_recording_fixture try: from autopilot import tracepoint as tp HAVE_TRACEPOINT = True except ImportError: HAVE_TRACEPOINT = False _logger = logging.getLogger(__name__) try: from testscenarios.scenarios import multiply_scenarios except ImportError: from itertools import product def multiply_scenarios(*scenarios): """Multiply two or more iterables of scenarios. It is safe to pass scenario generators or iterators. :returns: A list of compound scenarios: the cross-product of all scenarios, with the names concatenated and the parameters merged together. """ result = [] scenario_lists = map(list, scenarios) for combination in product(*scenario_lists): names, parameters = zip(*combination) scenario_name = ','.join(names) scenario_parameters = {} for parameter in parameters: scenario_parameters.update(parameter) result.append((scenario_name, scenario_parameters)) return result def _lttng_trace_test_started(test_id): if HAVE_TRACEPOINT: tp.emit_test_started(test_id) else: _logger.warning( "No tracing available - install the python-autopilot-trace " "package!") def _lttng_trace_test_ended(test_id): if HAVE_TRACEPOINT: tp.emit_test_ended(test_id) class _TimedRunTest(RunTest): def run(self, *args, **kwargs): timeout = get_test_timeout() if timeout > 0: with fixtures.Timeout(timeout, True): return super().run(*args, **kwargs) else: return super().run(*args, **kwargs) class AutopilotTestCase(TestWithScenarios, TestCase, KeybindingsHelper): """Wrapper around testtools.TestCase that adds significant functionality. This class should be the base class for all autopilot test case classes. Not using this class as the base class disables several important convenience methods, and also prevents the use of the failed-test recording tools. """ run_tests_with = _TimedRunTest def setUp(self): super(AutopilotTestCase, self).setUp() on_test_started(self) self.useFixture( TestCaseLoggingFixture( self.shortDescription(), self.addDetailUniqueName, ) ) self.useFixture(get_debug_profile_fixture()(self.addDetailUniqueName)) self.useFixture(get_video_recording_fixture()(self)) _lttng_trace_test_started(self.id()) self.addCleanup(_lttng_trace_test_ended, self.id()) self._process_manager = None self._mouse = None self._display = None self._kb = Keyboard.create() # Instatiate this after keyboard creation to ensure it doesn't get # overwritten # Workaround for bug lp:1474444 self.useFixture(OSKAlwaysEnabled()) # Work around for bug lp:1297592. _ensure_uinput_device_created() if get_display_server() == 'X11': try: self._app_snapshot = _get_process_snapshot() self.addCleanup(self._compare_system_with_app_snapshot) except RuntimeError: _logger.warning( "Process manager backend unavailable, application " "snapshot support disabled.") self.addOnException(self._take_screenshot_on_failure) @property def process_manager(self): if self._process_manager is None: self._process_manager = ProcessManager.create() return self._process_manager @property def keyboard(self): return self._kb @property def mouse(self): if self._mouse is None: self._mouse = Mouse.create() return self._mouse @property def display(self): if self._display is None: self._display = Display.create() return self._display def launch_test_application(self, application, *arguments, **kwargs): """Launch ``application`` and return a proxy object for the application. Use this method to launch an application and start testing it. The positional arguments are used as arguments to the application to lanch. Keyword arguments are used to control the manner in which the application is launched. This method is designed to be flexible enough to launch all supported types of applications. Autopilot can automatically determine how to enable introspection support for dynamically linked binary applications. For example, to launch a binary Gtk application, a test might start with:: app_proxy = self.launch_test_application('gedit') Applications can be given command line arguments by supplying positional arguments to this method. For example, if we want to launch ``gedit`` with a certain document loaded, we might do this:: app_proxy = self.launch_test_application( 'gedit', '/tmp/test-document.txt') ... a Qt5 Qml application is launched in a similar fashion:: app_proxy = self.launch_test_application( 'qmlscene', 'my_scene.qml') If you wish to launch an application that is not a dynamically linked binary, you must specify the application type. For example, a Qt4 python application might be launched like this:: app_proxy = self.launch_test_application( 'my_qt_app.py', app_type='qt') Similarly, a python/Gtk application is launched like so:: app_proxy = self.launch_test_application( 'my_gtk_app.py', app_type='gtk') :param application: The application to launch. The application can be specified as: * A full, absolute path to an executable file. (``/usr/bin/gedit``) * A relative path to an executable file. (``./build/my_app``) * An app name, which will be searched for in $PATH (``my_app``) :keyword app_type: If set, provides a hint to autopilot as to which kind of introspection to enable. This is needed when the application you wish to launch is *not* a dynamically linked binary. Valid values are 'gtk' or 'qt'. These strings are case insensitive. :keyword launch_dir: If set to a directory that exists the process will be launched from that directory. :keyword capture_output: If set to True (the default), the process output will be captured and attached to the test as test detail. :keyword emulator_base: If set, specifies the base class to be used for all emulators for this loaded application. :return: A proxy object that represents the application. Introspection data is retrievable via this object. """ launch_args = _get_application_launch_args(kwargs) launcher = self.useFixture( NormalApplicationLauncher( case_addDetail=self.addDetailUniqueName, **kwargs ) ) return launcher.launch(application, arguments, **launch_args) def _compare_system_with_app_snapshot(self): """Compare the currently running application with the last snapshot. This method will raise an AssertionError if there are any new applications currently running that were not running when the snapshot was taken. """ try: _compare_system_with_process_snapshot( _get_process_snapshot, self._app_snapshot ) finally: self._app_snapshot = None def take_screenshot(self, attachment_name): """Take a screenshot of the current screen and adds it to the test as a detail named *attachment_name*. If *attachment_name* already exists as a detail the name will be modified to remove the naming conflict (i.e. using TestCase.addDetailUniqueName). Returns True if the screenshot was taken and attached successfully, False otherwise. """ try: image_content = content_from_stream( get_screenshot_data(get_display_server()), content_type=ContentType('image', 'png'), buffer_now=True ) self.addDetailUniqueName(attachment_name, image_content) return True except Exception as e: logging.error( "Taking screenshot failed: {exception}".format(exception=e) ) return False def _take_screenshot_on_failure(self, ex_info): failure_class_type = ex_info[0] if _considered_failing_test(failure_class_type): self.take_screenshot("FailedTestScreenshot") @deprecated('fixtures.EnvironmentVariable') def patch_environment(self, key, value): """Patch environment using fixture. This function is deprecated and planned for removal in autopilot 1.6. New implementations should use EnvironmenVariable from the fixtures module:: from fixtures import EnvironmentVariable def my_test(AutopilotTestCase): my_patch = EnvironmentVariable('key', 'value') self.useFixture(my_patch) 'key' will be set to 'value'. During tearDown, it will be reset to a previous value, if one is found, or unset if not. """ self.useFixture(fixtures.EnvironmentVariable(key, value)) def assertVisibleWindowStack(self, stack_start): """Check that the visible window stack starts with the windows passed in. .. note:: Minimised windows are skipped. :param stack_start: An iterable of :class:`~autopilot.process.Window` instances. :raises AssertionError: if the top of the window stack does not match the contents of the stack_start parameter. """ stack = [ win for win in self.process_manager.get_open_windows() if not win.is_hidden] for pos, win in enumerate(stack_start): self.assertThat( stack[pos].x_id, Equals(win.x_id), "%r at %d does not equal %r" % (stack[pos], pos, win)) def assertProperty(self, obj, **kwargs): """Assert that *obj* has properties equal to the key/value pairs in kwargs. This method is intended to be used on objects whose attributes do not have the :meth:`wait_for` method (i.e.- objects that do not come from the autopilot DBus interface). For example, from within a test, to assert certain properties on a `~autopilot.process.Window` instance:: self.assertProperty(my_window, is_maximized=True) .. note:: assertProperties is a synonym for this method. :param obj: The object to test. :param kwargs: One or more keyword arguments to match against the attributes of the *obj* parameter. :raises ValueError: if no keyword arguments were given. :raises ValueError: if a named attribute is a callable object. :raises AssertionError: if any of the attribute/value pairs in kwargs do not match the attributes on the object passed in. """ if not kwargs: raise ValueError("At least one keyword argument must be present.") for prop_name, desired_value in kwargs.items(): none_val = object() attr = getattr(obj, prop_name, none_val) if attr == none_val: raise AssertionError( "Object %r does not have an attribute named '%s'" % (obj, prop_name)) if callable(attr): raise ValueError( "Object %r's '%s' attribute is a callable. It must be a " "property." % (obj, prop_name)) self.assertThat( lambda: getattr(obj, prop_name), Eventually(Equals(desired_value))) assertProperties = assertProperty def _get_application_launch_args(kwargs): """Returns a dict containing relevant args and values for launching an application. Removes used arguments from kwargs parameter. """ launch_args = {} launch_arg_list = ['app_type', 'launch_dir', 'capture_output'] for arg in launch_arg_list: if arg in kwargs: launch_args[arg] = kwargs.pop(arg) return launch_args def _get_process_snapshot(): """Return a snapshot of running processes on the system. :returns: a list of running processes. :raises RuntimeError: if the process manager is unsavailble on this platform. """ return ProcessManager.create().get_running_applications() def _compare_system_with_process_snapshot(snapshot_fn, old_snapshot): """Compare an existing process snapshot with current running processes. :param snapshot_fn: A callable that returns the current running process list. :param old_snapshot: A list of processes to compare against. :raises AssertionError: If, after 10 seconds, there are still running processes that were not present in ``old_snapshot``. """ new_apps = [] for _ in Timeout.default(): current_apps = snapshot_fn() new_apps = [app for app in current_apps if app not in old_snapshot] if not new_apps: return raise AssertionError( "The following apps were started during the test and not closed: " "%r" % new_apps) def _ensure_uinput_device_created(): # This exists for a work around for bug lp:1297592. Need to create # an input device before an application launch. try: from autopilot.input._uinput import Touch, _UInputTouchDevice if _UInputTouchDevice._device is None: Touch.create() except Exception as e: _logger.warning( "Failed to create Touch device for bug lp:1297595 workaround: " "%s" % str(e) ) def _considered_failing_test(failure_class_type): return ( not issubclass(failure_class_type, SkipTest) and not issubclass(failure_class_type, _ExpectedFailure) ) ./autopilot/_glib.py0000644000004100000410000000441614002063564014716 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Private module for wrapping Gdk/GdkX11 import workarounds. The Gtk/Gdk/GdkX11 gi.repository bindings have an issue where importing Gdk, running a Gdk method (specifically get_default_root_window) and then importing GdkX11 will cause a different API to be loaded than if one had imported both Gdk and GdkX11 at the same time. This is captured in this bug: https://bugs.launchpad.net/ubuntu/+source/gtk+3.0/+bug/1343069 To work around this we ensure that all the modules are loaded once at the same time. This ensures that the call to _import_gdk will still import GdkX11 before any Gdk calls are made. """ from autopilot.utilities import Silence _Gtk = None _Gdk = None _GdkX11 = None # Need to make sure that both modules are imported at the same time to stop any # API changes happening under the covers. def _import_gdk_modules(): global _Gtk global _Gdk global _GdkX11 version = '3.0' with Silence(): from gi import require_version require_version('Gdk', version) require_version('GdkX11', version) require_version('Gtk', version) from gi.repository import Gtk, Gdk, GdkX11 _Gtk = Gtk _Gdk = Gdk _GdkX11 = GdkX11 def _import_gtk(): global _Gtk if _Gtk is None: _import_gdk_modules() return _Gtk def _import_gdk(): global _Gdk if _Gdk is None: _import_gdk_modules() return _Gdk def _import_gdkx11(): global _GdkX11 if _GdkX11 is None: _import_gdk_modules() return _GdkX11 ./autopilot/tests/0000755000004100000410000000000014002063564014425 5ustar www-datawww-data./autopilot/tests/README0000644000004100000410000000057114002063564015310 0ustar www-datawww-dataThis is the root directory for autopilot tests. The "unit" folder contains unit tests that are run when the autopilot packae builds. They must not have any external dependancies (especially not to, for example X11). The "functional" folder contains larger tests that may depend on external components. Both these folders are packages in the 'python-autopilot-tests' package. ./autopilot/tests/functional/0000755000004100000410000000000014002063567016572 5ustar www-datawww-data./autopilot/tests/functional/test_application_mixin.py0000644000004100000410000000340714002063564023713 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot.testcase import AutopilotTestCase from testtools.matchers import raises class ApplicationSupportTests(AutopilotTestCase): def test_launch_with_bad_types_raises_typeerror(self): """Calling launch_test_application with something other than a string must raise a TypeError""" self.assertThat( lambda: self.launch_test_application(1), raises(TypeError)) self.assertThat( lambda: self.launch_test_application(True), raises(TypeError)) self.assertThat( lambda: self.launch_test_application(1.0), raises(TypeError)) self.assertThat( lambda: self.launch_test_application(object()), raises(TypeError)) self.assertThat( lambda: self.launch_test_application(None), raises(TypeError)) self.assertThat( lambda: self.launch_test_application([]), raises(TypeError)) self.assertThat( lambda: self.launch_test_application((None,)), raises(TypeError)) ./autopilot/tests/functional/test_process_emulator.py0000644000004100000410000001613314002063564023572 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os import sys from subprocess import Popen, call from textwrap import dedent from threading import Thread from time import sleep from timeit import default_timer from testtools import skipIf from testtools.matchers import Equals, GreaterThan, NotEquals, LessThan from autopilot.exceptions import BackendException from autopilot.matchers import Eventually from autopilot.platform import model from autopilot.process import ProcessManager from autopilot.testcase import AutopilotTestCase from autopilot.tests.functional.fixtures import ExecutableScript @skipIf(model() != "Desktop", "Not suitable for device (ProcManager)") class ProcessEmulatorTests(AutopilotTestCase): def ensure_gedit_not_running(self): """Close any open gedit applications.""" apps = self.process_manager.get_running_applications_by_desktop_file( 'gedit.desktop') if apps: # this is a bit brutal, but easier in this context than the # alternative. call(['killall', 'gedit']) def test_wait_for_app_running_works(self): """Make sure we can wait for an application to start.""" def start_gedit(): sleep(5) Popen(['gedit']) self.addCleanup(self.ensure_gedit_not_running) start = default_timer() t = Thread(target=start_gedit()) t.start() ret = self.process_manager.wait_until_application_is_running( 'gedit.desktop', 10) end = default_timer() t.join() self.assertThat(ret, Equals(True)) self.assertThat(end - start, GreaterThan(5)) def test_wait_for_app_running_times_out_correctly(self): """Make sure the bamf emulator times out correctly if no app is started.""" self.ensure_gedit_not_running() start = default_timer() ret = self.process_manager.wait_until_application_is_running( 'gedit.desktop', 5) end = default_timer() self.assertThat(abs(end - start - 5.0), LessThan(1)) self.assertThat(ret, Equals(False)) def test_start_app(self): """Ensure we can start an Application.""" app = self.process_manager.start_app('Calculator') self.assertThat(app, NotEquals(None)) # locale='C' does not work here as this goes through bamf, so we can't # assert the precise name self.assertThat(app.name, NotEquals('')) self.assertThat(app.desktop_file, Equals('gcalctool.desktop')) def test_start_app_window(self): """Ensure we can start an Application Window.""" app = self.process_manager.start_app_window('Calculator', locale='C') self.assertThat(app, NotEquals(None)) self.assertThat(app.name, Equals('Calculator')) @skipIf(model() != 'Desktop', "Bamf only available on desktop (Qt4)") def test_bamf_geometry_gives_reliable_results(self): script = dedent("""\ #!%s from PyQt4.QtGui import QMainWindow, QApplication from sys import argv app = QApplication(argv) win = QMainWindow() win.show() app.exec_() """ % sys.executable) path = self.useFixture(ExecutableScript(script)).path app_proxy = self.launch_test_application(path, app_type='qt') proxy_window = app_proxy.select_single('QMainWindow') pm = ProcessManager.create() window = [ w for w in pm.get_open_windows() if w.name == os.path.basename(path) ][0] self.assertThat(list(window.geometry), Equals(proxy_window.geometry)) @skipIf(model() != "Desktop", "Not suitable for device (ProcManager)") class StartKnowAppsTests(AutopilotTestCase): scenarios = [ (app_name, { 'app_name': app_name, 'desktop_file': ( ProcessManager.KNOWN_APPS[app_name]['desktop-file']) }) for app_name in ProcessManager.KNOWN_APPS ] def test_start_app_window(self): """Ensure we can start all the known applications.""" app = self.process_manager.start_app_window(self.app_name, locale='C') self.assertThat(app, NotEquals(None)) class ProcessManagerApplicationNoCleanupTests(AutopilotTestCase): """Testing the process manager without the automated cleanup that running within as an AutopilotTestCase provides. """ def test_can_close_all_app(self): """Ensure that closing an app actually closes all app instances.""" try: process_manager = ProcessManager.create(preferred_backend="BAMF") except BackendException as e: self.skip("Test is only for BAMF backend ({}).".format(str(e))) process_manager.start_app('Calculator') process_manager.close_all_app('Calculator') # ps/pgrep lists gnome-calculator as gnome-calculato (see lp # bug:1174911) ret_code = call(["pgrep", "-c", "gnome-calculato"]) self.assertThat(ret_code, Equals(1), "Application is still running") @skipIf(model() != "Desktop", "Not suitable for device (X11)") class BAMFResizeWindowTestCase(AutopilotTestCase): """Tests for the BAMF window helpers.""" scenarios = [ ('increase size', dict(delta_width=40, delta_height=40)), ('decrease size', dict(delta_width=-40, delta_height=-40)) ] def start_mock_window(self): self.launch_test_application( 'window-mocker', app_type='qt' ) try: process_manager = ProcessManager.create(preferred_backend='BAMF') except BackendException as e: self.skip('Test is only for BAMF backend ({}).'.format(str(e))) window = [ w for w in process_manager.get_open_windows() if w.name == 'Default Window Title' ][0] return window def test_resize_window_must_update_width_and_height_geometry(self): window = self.start_mock_window() def get_size(): _, _, width, height = window.geometry return width, height initial_width, initial_height = get_size() expected_width = initial_width + self.delta_width expected_height = initial_height + self.delta_height window.resize(width=expected_width, height=expected_height) self.assertThat( get_size, Eventually(Equals((expected_width, expected_height)))) ./autopilot/tests/functional/test_mouse_emulator.py0000644000004100000410000000322414002063564023241 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import skipIf, TestCase from testtools.matchers import Equals from unittest.mock import patch from autopilot import platform from autopilot.input import Pointer, Mouse @skipIf(platform.model() != "Desktop", "Not suitable for device (X11)") class MouseEmulatorTests(TestCase): """Tests for the autopilot mouse emulator.""" def setUp(self): super(MouseEmulatorTests, self).setUp() self.mouse = Pointer(Mouse.create()) def tearDown(self): super(MouseEmulatorTests, self).tearDown() self.mouse = None def test_x_y_properties(self): """x and y properties must simply return values from the position() method.""" with patch.object( self.mouse._device, 'position', return_value=(42, 37)): self.assertThat(self.mouse.x, Equals(42)) self.assertThat(self.mouse.y, Equals(37)) ./autopilot/tests/functional/test_input_stack.py0000644000004100000410000004666114002063564022541 0ustar www-datawww-data # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import json import logging import os from tempfile import mktemp from testtools import TestCase, skipIf from testtools.matchers import ( IsInstance, Equals, raises, GreaterThan ) from testscenarios import TestWithScenarios from textwrap import dedent from time import sleep import timeit from unittest import SkipTest from unittest.mock import patch from autopilot import ( platform, tests ) from autopilot.display import Display from autopilot.gestures import pinch from autopilot.input import Keyboard, Mouse, Pointer, Touch, get_center_point from autopilot.matchers import Eventually from autopilot.testcase import AutopilotTestCase, multiply_scenarios from autopilot.tests.functional import QmlScriptRunnerMixin from autopilot.utilities import on_test_started class ElapsedTimeCounter(object): """A simple utility to count the amount of real time that passes.""" def __enter__(self): self._start_time = timeit.default_timer() return self def __exit__(self, *args): pass @property def elapsed_time(self): return timeit.default_timer() - self._start_time class InputStackKeyboardBase(AutopilotTestCase): scenarios = [ ('UInput', dict(backend='UInput')), ] if platform.model() == "Desktop": scenarios.append(('X11', dict(backend='X11'))) def setUp(self): super(InputStackKeyboardBase, self).setUp() if self.backend == 'UInput' and not os.access('/dev/uinput', os.W_OK): raise SkipTest( "UInput backend currently requires write access to " "/dev/uinput") class InputStackKeyboardCreationTests(InputStackKeyboardBase): def test_can_create_backend(self): keyboard = Keyboard.create(self.backend) self.assertThat(keyboard, IsInstance(Keyboard)) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class InputStackKeyboardTypingTests(InputStackKeyboardBase): scenarios = multiply_scenarios( InputStackKeyboardBase.scenarios, [ ('lower_alpha', dict(input='abcdefghijklmnopqrstuvwxyz')), ('upper_alpha', dict(input='ABCDEFGHIJKLMNOPQRSTUVWXYZ')), ('numeric', dict(input='0123456789')), ('punctuation', dict(input='`~!@#$%^&*()_-+={}[]|\\:;"\'<>,.?/')), ('whitespace', dict(input='\t\n')) ] ) def start_mock_app(self): window_spec_file = mktemp(suffix='.json') window_spec = {"Contents": "TextEdit"} json.dump( window_spec, open(window_spec_file, 'w') ) self.addCleanup(os.remove, window_spec_file) return self.launch_test_application( 'window-mocker', window_spec_file, app_type='qt' ) def test_text_typing(self): """Typing text must produce the correct characters in the target app. """ app_proxy = self.start_mock_app() text_edit = app_proxy.select_single('QTextEdit') # make sure the text edit has keyboard focus: self.mouse.click_object(text_edit) self.assertThat(text_edit.focus, Eventually(Equals(True))) # even though we ensured the textedit has focus, it occasionally # does not yet accept keyboard input, causing this test to fail # intermittently. to remedy this, we just add a sleep. sleep(2) # create keyboard and type the text. keyboard = Keyboard.create(self.backend) keyboard.type(self.input, 0.01) self.assertThat(text_edit.plainText, Eventually(Equals(self.input)), "app shows: " + text_edit.plainText ) def test_typing_with_contextmanager(self): """Typing text must produce the correct characters in the target app. """ app_proxy = self.start_mock_app() text_edit = app_proxy.select_single('QTextEdit') keyboard = Keyboard.create(self.backend) with keyboard.focused_type(text_edit) as kb: kb.type(self.input, 0.01) self.assertThat( text_edit.plainText, Eventually(Equals(self.input)), "app shows: " + text_edit.plainText ) def test_keyboard_keys_are_released(self): """Typing characters must not leave keys pressed.""" app_proxy = self.start_mock_app() text_edit = app_proxy.select_single('QTextEdit') # make sure the text edit has keyboard focus: self.mouse.click_object(text_edit) keyboard = Keyboard.create(self.backend) for character in self.input: self.assertThat(self._get_pressed_keys_list(), Equals([])) keyboard.type(character, 0.01) self.assertThat(self._get_pressed_keys_list(), Equals([])) def _get_pressed_keys_list(self): """Get a list of keys pressed, but not released from the backend we're using. """ if self.backend == 'X11': from autopilot.input._X11 import _PRESSED_KEYS return _PRESSED_KEYS elif self.backend == 'UInput': from autopilot.input import _uinput return _uinput.Keyboard._device._pressed_keys_ecodes else: self.fail( "Don't know how to get pressed keys list for {}".format( self.backend ) ) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class InputStackKeyboardBackspaceTests(InputStackKeyboardBase): def start_mock_app(self): window_spec_file = mktemp(suffix='.json') window_spec = {"Contents": "TextEdit"} json.dump( window_spec, open(window_spec_file, 'w') ) self.addCleanup(os.remove, window_spec_file) return self.launch_test_application( 'window-mocker', window_spec_file, app_type='qt' ) def test_backspace_works(self): app_proxy = self.start_mock_app() text_edit = app_proxy.select_single('QTextEdit') self.mouse.click_object(text_edit) keyboard = Keyboard.create(self.backend) keyboard.type("Hello1") keyboard.press_and_release("Backspace") self.assertThat(text_edit.plainText, Eventually(Equals("Hello")), "app shows: " + text_edit.plainText ) def test_inline_backspace_works(self): app_proxy = self.start_mock_app() text_edit = app_proxy.select_single('QTextEdit') self.mouse.click_object(text_edit) keyboard = Keyboard.create(self.backend) keyboard.type("Hello1\b") self.assertThat(text_edit.plainText, Eventually(Equals("Hello")), "app shows: " + text_edit.plainText ) def osk_backend_available(): try: from autopilot.input._osk import Keyboard # NOQA return True except ImportError: return False class OSKBackendTests(AutopilotTestCase, QmlScriptRunnerMixin): """Testing the Onscreen Keyboard (Ubuntu Keyboard) backend specifically. There are limitations (i.e. on device only, window-mocker doesn't work on the device, can't type all the characters that X11/UInput can.) that necessitate this split into it's own test class. """ scenarios = [ ('lower_alpha', dict(input='abcdefghijklmnopqrstuvwxyz')), ('upper_alpha', dict(input='ABCDEFGHIJKLMNOPQRSTUVWXYZ')), ('numeric', dict(input='0123456789')), # Remove " due to bug: lp:1243501 ('punctuation', dict(input='`~!@#$%^&*()_-+={}[]|\\:;\'<>,.?/')), ] def launch_test_input_area(self): self.app = self._launch_simple_input() text_area = self.app.select_single("QQuickTextInput") return text_area def _launch_simple_input(self): simple_script = dedent(""" import QtQuick 2.0 import Ubuntu.Components 0.1 Rectangle { id: window objectName: "windowRectangle" color: "lightgrey" Text { id: inputLabel text: "OSK Tests" font.pixelSize: units.gu(3) anchors { left: input.left top: parent.top topMargin: 25 bottomMargin: 25 } } TextField { id: input; objectName: "input" anchors { top: inputLabel.bottom horizontalCenter: parent.horizontalCenter topMargin: 10 } inputMethodHints: Qt.ImhNoPredictiveText } } """) return self.start_qml_script(simple_script) @skipIf(platform.model() == 'Desktop', "Only suitable on a device") @skipIf(not osk_backend_available(), "Test requires OSK Backend installed") def test_can_type_string(self): """Typing text must produce the expected characters in the input field. """ text_area = self.launch_test_input_area() keyboard = Keyboard.create('OSK') pointer = Pointer(Touch.create()) pointer.click_object(text_area) keyboard._keyboard.wait_for_keyboard_ready() keyboard.type(self.input) self.assertThat(text_area.text, Eventually(Equals(self.input))) @skipIf(platform.model() == 'Desktop', "Only suitable on a device") @skipIf(not osk_backend_available(), "Test requires OSK Backend installed") def test_focused_typing_contextmanager(self): """Typing text using the 'focused_typing' context manager must not only produce the expected characters in the input field but also cleanup the OSK afterwards too. """ text_area = self.launch_test_input_area() keyboard = Keyboard.create('OSK') with keyboard.focused_type(text_area) as kb: kb.type(self.input) self.assertThat( text_area.text, Eventually(Equals(self.input)) ) self.assertThat( keyboard._keyboard.is_available, Eventually(Equals(False)) ) class MockAppMouseTestBase(AutopilotTestCase): def start_mock_app(self): window_spec_file = mktemp(suffix='.json') window_spec = {"Contents": "MouseTest"} json.dump( window_spec, open(window_spec_file, 'w') ) self.addCleanup(os.remove, window_spec_file) return self.launch_test_application( 'window-mocker', window_spec_file, app_type='qt') class MouseTestCase(AutopilotTestCase, tests.LogHandlerTestCase): @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (Mouse)") def test_move_to_nonint_point(self): """Test mouse does not get stuck when we move to a non-integer point. LP bug #1195499. """ device = Mouse.create() screen_geometry = Display.create().get_screen_geometry(0) target_x = screen_geometry[0] + 10 target_y = screen_geometry[1] + 10.6 device.move(target_x, target_y) self.assertEqual(device.position(), (target_x, int(target_y))) @patch('autopilot.platform.model', new=lambda *args: "Not Desktop", ) def test_mouse_creation_on_device_raises_useful_error(self): """Trying to create a mouse device on the phablet devices must raise an explicit exception. """ expected_exception = RuntimeError( "Cannot create a Mouse on devices where X11 is not available." ) self.assertThat(lambda: Mouse.create(), raises(expected_exception)) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (Mouse)") def test_mouse_move_must_log_final_position_at_debug_level(self): self.root_logger.setLevel(logging.DEBUG) mouse = Mouse.create() mouse.move(10, 10) self.assertLogLevelContains( 'DEBUG', "The mouse is now at position 10,10." ) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class TouchTests(MockAppMouseTestBase): def setUp(self): super(TouchTests, self).setUp() self.device = Touch.create() self.app = self.start_mock_app() self.widget = self.app.select_single('MouseTestWidget') self.button_status = self.app.select_single( 'QLabel', objectName='button_status') def test_tap(self): x, y = get_center_point(self.widget) self.device.tap(x, y) self.assertThat( self.button_status.text, Eventually(Equals("Touch Release"))) def test_press_and_release(self): x, y = get_center_point(self.widget) self.device.press(x, y) self.assertThat( self.button_status.text, Eventually(Equals("Touch Press"))) self.device.release() self.assertThat( self.button_status.text, Eventually(Equals("Touch Release"))) class TouchGesturesTests(AutopilotTestCase, QmlScriptRunnerMixin): def test_pinch_gesture(self): """Ensure that the pinch gesture pinches as expected.""" test_qml = dedent("""\ import QtQuick 2.0 Rectangle { id: colorBox width: 250 height: 250 color: "#00FF00" PinchArea { anchors.fill: parent onPinchFinished: { colorBox.color = "#0000FF" } } } """) # Returned results include the alpha (RGBA) start_green_bg = [0, 255, 0, 255] end_blue_bg = [0, 0, 255, 255] app = self.start_qml_script(test_qml) pinch_widget = app.select_single("QQuickRectangle") widget_bg_colour = lambda: pinch_widget.color self.assertThat(widget_bg_colour, Eventually(Equals(start_green_bg))) x, y = get_center_point(pinch_widget) pinch((x, y), (10, 0), (100, 0)) self.assertThat(widget_bg_colour, Eventually(Equals(end_blue_bg))) class PointerWrapperTests(AutopilotTestCase): def test_can_move_touch_wrapper(self): device = Pointer(Touch.create()) device.move(34, 56) self.assertThat(device.x, Equals(34)) self.assertThat(device.y, Equals(56)) def test_touch_drag_updates_coordinates(self): """The Pointer wrapper must update it's x and y properties when wrapping a touch object and performing a drag operation. """ class FakeTouch(Touch): def __init__(self): self.x = 0 self.y = 0 def drag(self, x1, y1, x2, y2, rate='dummy', time_between_events='dummy'): self.x = x2 self.y = y2 p = Pointer(FakeTouch()) p.drag(0, 0, 100, 123) self.assertThat(p.x, Equals(100)) self.assertThat(p.y, Equals(123)) @skipIf(platform.model() != "Desktop", "Window mocker only available on X11") class InputEventDelayTests(MockAppMouseTestBase, TestWithScenarios): scenarios = [ ('Touch', dict(input_class=Touch)), ('Mouse', dict(input_class=Mouse)), ] def setUp(self): super(InputEventDelayTests, self).setUp() self.device = Pointer(self.input_class.create()) self.widget = self.get_mock_app_main_widget() def get_mock_app_main_widget(self): self.app = self.start_mock_app() return self.app.select_single('MouseTestWidget') def test_subsequent_events_delay(self): with ElapsedTimeCounter() as time_counter: for i in range(3): self.device.click_object(self.widget, time_between_events=0.6) self.assertThat(time_counter.elapsed_time, GreaterThan(1.0)) def test_subsequent_events_default_delay(self): with ElapsedTimeCounter() as time_counter: for i in range(10): self.device.click_object(self.widget) self.assertThat(time_counter.elapsed_time, GreaterThan(0.9)) class InputStackCleanupTests(TestCase): def test_cleanup_called(self): """Derived classes cleanup method must be called when interface cleanup method is called. """ class FakeKeyboard(Keyboard): cleanup_called = False @classmethod def on_test_end(cls, test_instance): FakeKeyboard.cleanup_called = True class FakeTestCase(TestCase): def test_foo(self): on_test_started(self) FakeKeyboard() FakeTestCase("test_foo").run() self.assertThat(FakeKeyboard.cleanup_called, Equals(True)) class InputStackCleanup(AutopilotTestCase): @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (X11)") def test_keyboard_keys_released_X11(self): """Cleanup must release any keys that an X11 keyboard has had pressed.""" class FakeTestCase(AutopilotTestCase): def test_press_key(self): kb = Keyboard.create('X11') kb.press('Shift') test_result = FakeTestCase("test_press_key").run() self.assertThat(test_result.wasSuccessful(), Equals(True)) from autopilot.input._X11 import _PRESSED_KEYS self.assertThat(_PRESSED_KEYS, Equals([])) def test_keyboard_keys_released_UInput(self): """Cleanup must release any keys that an UInput keyboard has had pressed.""" class FakeTestCase(AutopilotTestCase): def test_press_key(self): kb = Keyboard.create('UInput') kb.press('Shift') test_result = FakeTestCase("test_press_key").run() self.assertThat(test_result.wasSuccessful(), Equals(True)) from autopilot.input import _uinput self.assertThat( _uinput.Keyboard._device._pressed_keys_ecodes, Equals([])) @skipIf(platform.model() != "Desktop", "Not suitable for device (X11)") @patch('autopilot.input._X11.fake_input', new=lambda *args: None, ) def test_mouse_button_released(self): """Cleanup must release any mouse buttons that have been pressed.""" class FakeTestCase(AutopilotTestCase): def test_press_button(self): mouse = Mouse.create('X11') mouse.press() test_result = FakeTestCase("test_press_button").run() from autopilot.input._X11 import _PRESSED_MOUSE_BUTTONS self.assertThat(test_result.wasSuccessful(), Equals(True)) self.assertThat(_PRESSED_MOUSE_BUTTONS, Equals([])) ./autopilot/tests/functional/test_dbus_query.py0000644000004100000410000003374114002063564022372 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import json import os import subprocess import signal from timeit import default_timer from tempfile import mktemp from testtools import skipIf from testtools.matchers import Equals, NotEquals, raises, LessThan, GreaterThan from autopilot import platform from autopilot.testcase import AutopilotTestCase from autopilot.exceptions import StateNotFoundError @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class DbusQueryTests(AutopilotTestCase): """A collection of dbus query tests for autopilot.""" def start_fully_featured_app(self): """Create an application that includes menus and other nested elements. """ window_spec = { "Menu": [ { "Title": "File", "Menu": [ "Open", "Save", "Save As", "Quit" ] }, { "Title": "Help", "Menu": [ "Help 1", "Help 2", "Help 3", "Help 4" ] } ], "Contents": "TextEdit" } file_path = mktemp() json.dump(window_spec, open(file_path, 'w')) self.addCleanup(os.remove, file_path) return self.launch_test_application( 'window-mocker', file_path, app_type="qt") def test_select_single_selects_only_available_object(self): """Must be able to select a single unique object.""" app = self.start_fully_featured_app() main_window = app.select_single('QMainWindow') self.assertThat(main_window, NotEquals(None)) def test_can_select_parent_of_root(self): """Must be able to select the parent of the root object.""" root = self.start_fully_featured_app() root_parent = root.get_parent() self.assertThat(root.id, Equals(root_parent.id)) def test_can_select_parent_of_normal_node(self): root = self.start_fully_featured_app() main_window = root.select_single('QMainWindow') window_parent = main_window.get_parent() self.assertThat(window_parent.id, Equals(root.id)) def test_can_select_specific_parent(self): root = self.start_fully_featured_app() action_item = root.select_single('QAction', text='Save') window_parent = action_item.get_parent('window-mocker') self.assertThat(window_parent.id, Equals(root.id)) def test_select_parent_raises_if_node_not_parent(self): root = self.start_fully_featured_app() action_item = root.select_single('QAction', text='Save') match_fn = lambda: action_item.get_parent('QMadeUpType') self.assertThat(match_fn, raises(StateNotFoundError('QMadeUpType'))) def test_select_parent_with_property_only(self): root = self.start_fully_featured_app() action_item = root.select_single('QAction', text='Save') # The ID of parent of a tree is always 1. window_parent = action_item.get_parent(id=1) self.assertThat(window_parent.id, Equals(root.id)) def test_select_parent_raises_if_property_not_match(self): root = self.start_fully_featured_app() action_item = root.select_single('QAction', text='Save') self.assertIsNotNone(action_item.get_parent('QMenu')) match_fn = lambda: action_item.get_parent('QMenu', visible=True) self.assertThat( match_fn, raises(StateNotFoundError('QMenu', visible=True)) ) def test_single_select_on_object(self): """Must be able to select a single unique child of an object.""" app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') menu_bar = main_win.select_single('QMenuBar') self.assertThat(menu_bar, NotEquals(None)) def test_select_multiple_on_object_returns_all(self): """Must be able to select all child objects.""" app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') menu_bar = main_win.select_single('QMenuBar') menus = menu_bar.select_many('QMenu') self.assertThat(len(menus), Equals(2)) def test_select_multiple_on_object_with_parameter(self): """Must be able to select a specific object determined by a parameter. """ app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') menu_bar = main_win.select_single('QMenuBar') help_menu = menu_bar.select_many('QMenu', title='Help') self.assertThat(len(help_menu), Equals(1)) self.assertThat(help_menu[0].title, Equals('Help')) def test_select_single_on_object_with_param(self): """Must only select a single unique object using a parameter.""" app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') menu_bar = main_win.select_single('QMenuBar') help_menu = menu_bar.select_single('QMenu', title='Help') self.assertThat(help_menu, NotEquals(None)) self.assertThat(help_menu.title, Equals('Help')) def test_select_many_uses_unique_object(self): """Given 2 objects of the same type with childen, selection on one will only get its children. """ app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') menu_bar = main_win.select_single('QMenuBar') help_menu = menu_bar.select_single('QMenu', title='Help') actions = help_menu.select_many('QAction') self.assertThat(len(actions), Equals(5)) def test_select_single_no_name_no_parameter_raises_exception(self): app = self.start_fully_featured_app() fn = lambda: app.select_single() self.assertRaises(ValueError, fn) def test_select_single_no_match_raises_exception(self): app = self.start_fully_featured_app() match_fn = lambda: app.select_single("QMadeupType") self.assertThat(match_fn, raises(StateNotFoundError('QMadeupType'))) def test_exception_raised_when_operating_on_dead_app(self): app = self.start_fully_featured_app() main_window = app.select_single('QMainWindow') app.kill_application() self.assertRaises(RuntimeError, main_window.get_parent) def test_exception_message_when_operating_on_dead_app(self): app = self.start_fully_featured_app() app.kill_application() try: app.select_single('QMainWindow') except RuntimeError as e: msg = ("Lost dbus backend communication. It appears the " "application under test exited before the test " "finished!") self.assertEqual(str(e), msg) def test_select_single_parameters_only(self): app = self.start_fully_featured_app() main_win = app.select_single('QMainWindow') titled_help = main_win.select_single(title='Help') self.assertThat(titled_help, NotEquals(None)) self.assertThat(titled_help.title, Equals('Help')) def test_select_single_parameters_no_match_raises_exception(self): app = self.start_fully_featured_app() match_fn = lambda: app.select_single(title="Non-existant object") self.assertThat( match_fn, raises(StateNotFoundError('*', title="Non-existant object")) ) def test_select_single_returning_multiple_raises(self): app = self.start_fully_featured_app() fn = lambda: app.select_single('QMenu') self.assertRaises(ValueError, fn) def test_select_many_no_name_no_parameter_raises_exception(self): app = self.start_fully_featured_app() fn = lambda: app.select_single() self.assertRaises(ValueError, fn) def test_select_many_only_using_parameters(self): app = self.start_fully_featured_app() many_help_menus = app.select_many(title='Help') self.assertThat(len(many_help_menus), Equals(1)) def test_select_many_with_no_parameter_matches_returns_empty_list(self): app = self.start_fully_featured_app() failed_match = app.select_many('QMenu', title='qwerty') self.assertThat(failed_match, Equals([])) def test_select_many_sorted_result(self): app = self.start_fully_featured_app() un_sorted_texts = [item.text for item in app.select_many('QAction')] sorted_texts = [ item.text for item in app.select_many( 'QAction', ap_result_sort_keys=['text'] ) ] self.assertNotEqual(un_sorted_texts, sorted_texts) self.assertEqual(sorted_texts, sorted(un_sorted_texts)) def test_wait_select_single_succeeds_quickly(self): app = self.start_fully_featured_app() start_time = default_timer() main_window = app.wait_select_single('QMainWindow') end_time = default_timer() self.assertThat(main_window, NotEquals(None)) self.assertThat(abs(end_time - start_time), LessThan(1)) def test_wait_select_single_timeout_less_than_ten_seconds(self): app = self.start_fully_featured_app() match_fn = lambda: app.wait_select_single( 'QMadeupType', ap_query_timeout=3 ) start_time = default_timer() self.assertThat(match_fn, raises(StateNotFoundError('QMadeupType'))) end_time = default_timer() self.assertThat(abs(end_time - start_time), GreaterThan(2)) self.assertThat(abs(end_time - start_time), LessThan(4)) def test_wait_select_single_timeout_more_than_ten_seconds(self): app = self.start_fully_featured_app() match_fn = lambda: app.wait_select_single( 'QMadeupType', ap_query_timeout=12 ) start_time = default_timer() self.assertThat(match_fn, raises(StateNotFoundError('QMadeupType'))) end_time = default_timer() self.assertThat(abs(end_time - start_time), GreaterThan(11)) self.assertThat(abs(end_time - start_time), LessThan(13)) def test_wait_select_many_requested_elements_count_not_match_raises(self): app = self.start_fully_featured_app() fn = lambda: app.wait_select_many( 'QMadeupType', ap_query_timeout=4, ap_result_count=2 ) start_time = default_timer() self.assertRaises(ValueError, fn) end_time = default_timer() self.assertThat(abs(end_time - start_time), GreaterThan(3)) self.assertThat(abs(end_time - start_time), LessThan(5)) def test_wait_select_many_requested_elements_count_matches(self): app = self.start_fully_featured_app() start_time = default_timer() menus = app.wait_select_many( 'QMenu', ap_query_timeout=4, ap_result_count=3 ) end_time = default_timer() self.assertThat(len(menus), GreaterThan(2)) self.assertThat(abs(end_time - start_time), LessThan(5)) def test_wait_select_many_sorted_result(self): app = self.start_fully_featured_app() start_time = default_timer() sorted_action_items = app.wait_select_many( 'QAction', ap_query_timeout=4, ap_result_count=10, ap_result_sort_keys=['text'] ) end_time = default_timer() self.assertThat(len(sorted_action_items), GreaterThan(9)) self.assertThat(abs(end_time - start_time), LessThan(5)) unsorted_action_items = app.wait_select_many( 'QAction', ap_query_timeout=4, ap_result_count=10 ) sorted_texts = [item.text for item in sorted_action_items] un_sorted_texts = [item.text for item in unsorted_action_items] self.assertNotEqual(un_sorted_texts, sorted_texts) self.assertEqual(sorted_texts, sorted(un_sorted_texts)) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class DbusCustomBusTests(AutopilotTestCase): """Test the ability to use custom dbus buses during a test.""" def setUp(self): self.dbus_bus_addr = self._enable_custom_dbus_bus() super(DbusCustomBusTests, self).setUp() def _enable_custom_dbus_bus(self): p = subprocess.Popen(['dbus-launch'], stdout=subprocess.PIPE, universal_newlines=True) output = p.communicate() results = output[0].split("\n") dbus_pid = int(results[1].split("=")[1]) dbus_address = results[0].split("=", 1)[1] kill_dbus = lambda pid: os.killpg(pid, signal.SIGTERM) self.addCleanup(kill_dbus, dbus_pid) return dbus_address def _start_mock_app(self, dbus_bus): window_spec = { "Contents": "TextEdit" } file_path = mktemp() json.dump(window_spec, open(file_path, 'w')) self.addCleanup(os.remove, file_path) return self.launch_test_application( 'window-mocker', file_path, app_type="qt", dbus_bus=dbus_bus, ) def test_can_use_custom_dbus_bus(self): app = self._start_mock_app(self.dbus_bus_addr) self.assertThat(app, NotEquals(None)) ./autopilot/tests/functional/test_testcase.py0000644000004100000410000000567114002063564022024 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Contains from testtools.content_type import ContentType from unittest.mock import patch import time from autopilot import testcase class AutopilotTestCaseScreenshotTests(TestCase): def test_screenshot_taken_when_test_fails(self): class InnerTest(testcase.AutopilotTestCase): def test_foo(self): self.fail() test = InnerTest('test_foo') test_run = test.run() self.assertFalse(test_run.wasSuccessful()) screenshot_content = test.getDetails()['FailedTestScreenshot'] self.assertEqual( screenshot_content.content_type, ContentType("image", "png") ) def test_take_screenshot(self): screenshot_name = self.getUniqueString() class InnerTest(testcase.AutopilotTestCase): def test_foo(self): self.take_screenshot(screenshot_name) test = InnerTest('test_foo') test_run = test.run() self.assertTrue(test_run.wasSuccessful()) screenshot_content = test.getDetails()[screenshot_name] self.assertEqual( screenshot_content.content_type, ContentType("image", "png") ) class TimedRunTestTests(TestCase): @patch.object(testcase, 'get_test_timeout', new=lambda: 5) def test_timed_run_test_times_out(self): class TimedTest(testcase.AutopilotTestCase): def test_will_timeout(self): time.sleep(10) # should timeout after 5 seconds test = TimedTest('test_will_timeout') result = test.run() self.assertFalse(result.wasSuccessful()) self.assertEqual(1, len(result.errors)) self.assertThat( result.errors[0][1], Contains('raise TimeoutException()') ) @patch.object(testcase, 'get_test_timeout', new=lambda: 0) def test_untimed_run_test_does_not_time_out(self): class TimedTest(testcase.AutopilotTestCase): def test_wont_timeout(self): time.sleep(10) test = TimedTest('test_wont_timeout') result = test.run() self.assertTrue(result.wasSuccessful()) ./autopilot/tests/functional/__init__.py0000644000004100000410000001360714002063567020712 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from codecs import open import os import os.path import sys import logging from shutil import rmtree import subprocess from tempfile import mkdtemp, mktemp from testtools.content import text_content from autopilot import platform from autopilot.tests.functional.fixtures import TempDesktopFile from autopilot.testcase import AutopilotTestCase def remove_if_exists(path): if os.path.exists(path): if os.path.isdir(path): rmtree(path) else: os.remove(path) logger = logging.getLogger(__name__) class AutopilotRunTestBase(AutopilotTestCase): """The base class for the autopilot functional tests.""" def setUp(self): super(AutopilotRunTestBase, self).setUp() self.base_path = self.create_empty_test_module() def create_empty_test_module(self): """Create an empty temp directory, with an empty test directory inside it. This method handles cleaning up the directory once the test completes. Returns the full path to the temp directory. """ # create the base directory: base_path = mkdtemp() self.addDetail('base path', text_content(base_path)) self.addCleanup(rmtree, base_path) # create the tests directory: os.mkdir( os.path.join(base_path, 'tests') ) # make tests importable: open( os.path.join( base_path, 'tests', '__init__.py'), 'w').write('# Auto-generated file.') return base_path def run_autopilot(self, arguments, pythonpath="", use_script=False): environment_patch = _get_environment_patch(pythonpath) if use_script: arg = [sys.executable, self._write_setup_tools_script()] else: arg = [sys.executable, '-m', 'autopilot.run'] environ = os.environ.copy() environ.update(environment_patch) logger.info("Starting autopilot command with:") logger.info("Autopilot command = %s", ' '.join(arg)) logger.info("Arguments = %s", arguments) logger.info("CWD = %r", self.base_path) arg.extend(arguments) process = subprocess.Popen( arg, cwd=self.base_path, env=environ, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) stdout, stderr = process.communicate() retcode = process.poll() self.addDetail('retcode', text_content(str(retcode))) self.addDetail( 'stdout', text_content(stdout) ) self.addDetail( 'stderr', text_content(stderr) ) return (retcode, stdout, stderr) def create_test_file(self, name, contents): """Create a test file with the given name and contents. 'name' must end in '.py' if it is to be importable. 'contents' must be valid python code. """ open( os.path.join( self.base_path, 'tests', name), 'w', encoding='utf8').write(contents) def _write_setup_tools_script(self): """Creates a python script that contains the setup entry point.""" base_path = mkdtemp() self.addCleanup(rmtree, base_path) script_file = os.path.join(base_path, 'autopilot') open(script_file, 'w').write(load_entry_point_script) return script_file def _get_environment_patch(pythonpath): environment_patch = dict(DISPLAY=':0') ap_base_path = os.path.abspath( os.path.join( os.path.dirname(__file__), '..', '..', '..' ) ) pythonpath_additions = [] if pythonpath: pythonpath_additions.append(pythonpath) if not ap_base_path.startswith('/usr/'): pythonpath_additions.append(ap_base_path) environment_patch['PYTHONPATH'] = ":".join(pythonpath_additions) return environment_patch load_entry_point_script = """\ #!/usr/bin/python __requires__ = 'autopilot==1.6.1' import sys from pkg_resources import load_entry_point if __name__ == '__main__': sys.exit( load_entry_point('autopilot==1.6.1', 'console_scripts', 'autopilot3')() ) """ class QmlScriptRunnerMixin(object): """A Mixin class that knows how to get a proxy object for a qml script.""" def start_qml_script(self, script_contents): """Launch a qml script.""" qml_path = mktemp(suffix='.qml') open(qml_path, 'w').write(script_contents) self.addCleanup(os.remove, qml_path) extra_args = '' if platform.model() != "Desktop": # We need to add the desktop-file-hint desktop_file = self.useFixture( TempDesktopFile() ).get_desktop_file_path() extra_args = '--desktop_file_hint={hint_file}'.format( hint_file=desktop_file ) return self.launch_test_application( "qmlscene", "-qt=qt5", qml_path, extra_args, app_type='qt', ) ./autopilot/tests/functional/test_autopilot_performance.py0000644000004100000410000000516214002063564024605 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from contextlib import contextmanager from timeit import default_timer from textwrap import dedent from testtools import skipIf from testtools.matchers import Equals import logging from autopilot import platform from autopilot.tests.functional import AutopilotRunTestBase logger = logging.getLogger(__name__) @contextmanager def maximum_runtime(max_time): start_time = default_timer() yield total_time = abs(default_timer() - start_time) if total_time >= max_time: raise AssertionError( "Runtime of %f was not within defined " "limit of %f" % (total_time, max_time) ) else: logger.info( "Test completed in %f seconds, which is below the " " threshold of %f.", total_time, max_time ) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class AutopilotPerformanceTests(AutopilotRunTestBase): """A suite of functional tests that will fail if autopilot performance regresses below certain strictly defined limits. Each test must be named after the feature we are benchmarking, and should use the maximum_runtime contextmanager defined above. """ def test_autopilot_launch_test_app(self): self.create_test_file( 'test_something.py', dedent(""" from autopilot.testcase import AutopilotTestCase class LaunchTestAppTests(AutopilotTestCase): def test_launch_test_app(self): app_proxy = self.launch_test_application( 'window-mocker', app_type='qt' ) """) ) with maximum_runtime(5.0): rc, out, err = self.run_autopilot(['run', 'tests']) self.assertThat(rc, Equals(0)) ./autopilot/tests/functional/test_introspection_features.py0000644000004100000410000003454614002063564025012 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import json import logging import os import re import subprocess import tempfile from tempfile import mktemp from testtools import skipIf from testtools.matchers import ( Contains, Equals, GreaterThan, IsInstance, LessThan, MatchesRegex, Not, StartsWith, ) from textwrap import dedent from unittest.mock import patch from io import StringIO from autopilot import platform from autopilot.matchers import Eventually from autopilot.testcase import AutopilotTestCase from autopilot.tests.functional import QmlScriptRunnerMixin from autopilot.tests.functional.fixtures import TempDesktopFile from autopilot.introspection import CustomEmulatorBase from autopilot.introspection import _object_registry as object_registry from autopilot.introspection import _search from autopilot.introspection.qt import QtObjectProxyMixin from autopilot.display import Display logger = logging.getLogger(__name__) class EmulatorBase(CustomEmulatorBase): pass @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (WinMocker)") class IntrospectionFeatureTests(AutopilotTestCase): """Test various features of the introspection code.""" def start_mock_app(self, emulator_base): window_spec_file = mktemp(suffix='.json') window_spec = {"Contents": "MouseTest"} json.dump( window_spec, open(window_spec_file, 'w') ) self.addCleanup(os.remove, window_spec_file) return self.launch_test_application( 'window-mocker', window_spec_file, app_type='qt', emulator_base=emulator_base, ) def test_can_get_custom_proxy_for_app_root(self): """Test two things: 1) We can get a custom proxy object for the root object in the object tree. 2) We can get a custom proxy object for an object in the tree which contains characters which are usually disallowed in python class names. """ class WindowMockerApp(EmulatorBase): @classmethod def validate_dbus_object(cls, path, _state): return path == b'/window-mocker' # verify that the initial proxy object we get back is the correct type: app = self.start_mock_app(EmulatorBase) self.assertThat(type(app), Equals(WindowMockerApp)) # verify that we get the correct type from get_root_instance: self.assertThat( type(app.get_root_instance()), Equals(WindowMockerApp) ) def test_customised_proxy_classes_have_extension_classes(self): class WindowMockerApp(EmulatorBase): @classmethod def validate_dbus_object(cls, path, _state): return path == b'/window-mocker' app = self.start_mock_app(EmulatorBase) self.assertThat(app.__class__.__bases__, Contains(QtObjectProxyMixin)) def test_customised_proxy_classes_have_multiple_extension_classes(self): with object_registry.patch_registry({}): class SecondEmulatorBase(CustomEmulatorBase): pass class WindowMockerApp(EmulatorBase, SecondEmulatorBase): @classmethod def validate_dbus_object(cls, path, _state): return path == b'/window-mocker' app = self.start_mock_app(EmulatorBase) self.assertThat(app.__class__.__bases__, Contains(EmulatorBase)) self.assertThat( app.__class__.__bases__, Contains(SecondEmulatorBase) ) def test_handles_using_app_cpo_base_class(self): # This test replicates an issue found in an application test suite # where using the App CPO caused an exception. with object_registry.patch_registry({}): class WindowMockerApp(CustomEmulatorBase): @classmethod def validate_dbus_object(cls, path, _state): return path == b'/window-mocker' self.start_mock_app(WindowMockerApp) def test_warns_when_using_incorrect_cpo_base_class(self): # Ensure the warning method is called when launching a proxy. with object_registry.patch_registry({}): class TestCPO(CustomEmulatorBase): pass class WindowMockerApp(TestCPO): @classmethod def validate_dbus_object(cls, path, _state): return path == b'/window-mocker' with patch.object(_search, 'logger') as p_logger: self.start_mock_app(WindowMockerApp) self.assertTrue(p_logger.warning.called) def test_can_select_custom_emulators_by_name(self): """Must be able to select a custom emulator type by name.""" class MouseTestWidget(EmulatorBase): pass app = self.start_mock_app(EmulatorBase) test_widget = app.select_single('MouseTestWidget') self.assertThat(type(test_widget), Equals(MouseTestWidget)) def test_can_select_custom_emulators_by_type(self): """Must be able to select a custom emulator type by type.""" class MouseTestWidget(EmulatorBase): pass app = self.start_mock_app(EmulatorBase) test_widget = app.select_single(MouseTestWidget) self.assertThat(type(test_widget), Equals(MouseTestWidget)) def test_can_access_custom_emulator_properties(self): """Must be able to access properties of a custom emulator.""" class MouseTestWidget(EmulatorBase): pass app = self.start_mock_app(EmulatorBase) test_widget = app.select_single(MouseTestWidget) self.assertThat(test_widget.visible, Eventually(Equals(True))) def test_selecting_generic_from_custom_is_not_inherited_from_custom(self): """Selecting a generic proxy object from a custom proxy object must not return an object derived of the custom object type. """ class MouseTestWidget(EmulatorBase): pass app = self.start_mock_app(EmulatorBase) mouse_widget = app.select_single(MouseTestWidget) child_label = mouse_widget.select_many("QLabel")[0] self.assertThat(child_label, Not(IsInstance(MouseTestWidget))) def test_selecting_custom_from_generic_is_not_inherited_from_generic(self): """Selecting a custom proxy object from a generic proxy object must return an object that is of the custom type. """ class MouseTestWidget(EmulatorBase): pass app = self.start_mock_app(EmulatorBase) generic_window = app.select_single("QMainWindow") mouse_widget = generic_window.select_single(MouseTestWidget) self.assertThat( mouse_widget, Not(IsInstance(type(generic_window))) ) def test_print_tree_full(self): """Print tree of full application""" app = self.start_mock_app(EmulatorBase) win = app.select_single("QMainWindow") stream = StringIO() win.print_tree(stream) out = stream.getvalue() # starts with root node self.assertThat( out, StartsWith("== /window-mocker/QMainWindow ==\nChildren:") ) # has root node properties self.assertThat( out, MatchesRegex( ".*windowTitle: [u]?'Default Window Title'.*", re.DOTALL ) ) # has level-1 widgets with expected indent self.assertThat( out, Contains(" == /window-mocker/QMainWindow/QRubberBand ==\n") ) self.assertThat( out, MatchesRegex(".* objectName: [u]?'qt_rubberband'\n", re.DOTALL) ) # has level-2 widgets with expected indent self.assertThat( out, Contains( " == /window-mocker/QMainWindow/QMenuBar/QToolButton ==" ) ) self.assertThat( out, MatchesRegex( ".* objectName: [u]?'qt_menubar_ext_button'.*", re.DOTALL ) ) def test_print_tree_depth_limit(self): """Print depth-limited tree for a widget""" app = self.start_mock_app(EmulatorBase) win = app.select_single("QMainWindow") stream = StringIO() win.print_tree(stream, 1) out = stream.getvalue() # has level-0 (root) node self.assertThat(out, Contains("== /window-mocker/QMainWindow ==")) # has level-1 widgets self.assertThat(out, Contains("/window-mocker/QMainWindow/QMenuBar")) # no level-2 widgets self.assertThat(out, Not(Contains( "/window-mocker/QMainWindow/QMenuBar/QToolButton"))) def test_window_geometry(self): """Window.geometry property Check that all Window geometry properties work and have a plausible range. """ # ensure we have at least one open app window self.start_mock_app(EmulatorBase) display = Display.create() top = left = right = bottom = None # for multi-monitor setups, make sure we examine the full desktop # space: for monitor in range(display.get_num_screens()): sx, sy, swidth, sheight = Display.create().get_screen_geometry( monitor ) logger.info( "Monitor %d geometry is (%d, %d, %d, %d)", monitor, sx, sy, swidth, sheight, ) if left is None or sx < left: left = sx if top is None or sy < top: top = sy if right is None or sx + swidth >= right: right = sx + swidth if bottom is None or sy + sheight >= bottom: bottom = sy + sheight logger.info( "Total desktop geometry is (%d, %d), (%d, %d)", left, top, right, bottom, ) for win in self.process_manager.get_open_windows(): logger.info("Win '%r' geometry is %r", win, win.geometry) geom = win.geometry self.assertThat(len(geom), Equals(4)) self.assertThat(geom[0], GreaterThan(left - 1)) # no GreaterEquals self.assertThat(geom[1], GreaterThan(top - 1)) self.assertThat(geom[2], LessThan(right)) self.assertThat(geom[3], LessThan(bottom)) class QMLCustomEmulatorTestCase(AutopilotTestCase): """Test the introspection of a QML application with a custom emulator.""" def test_can_access_custom_emulator_properties_twice(self): """Must be able to run more than one test with a custom emulator.""" class InnerTestCase(AutopilotTestCase): class QQuickView(EmulatorBase): pass test_qml = dedent("""\ import QtQuick 2.0 Rectangle { } """) def launch_test_qml(self): arch = subprocess.check_output( ["dpkg-architecture", "-qDEB_HOST_MULTIARCH"], universal_newlines=True).strip() qml_path = tempfile.mktemp(suffix='.qml') open(qml_path, 'w').write(self.test_qml) self.addCleanup(os.remove, qml_path) extra_args = '' if platform.model() != "Desktop": # We need to add the desktop-file-hint desktop_file = self.useFixture( TempDesktopFile() ).get_desktop_file_path() extra_args = '--desktop_file_hint={hint_file}'.format( hint_file=desktop_file ) return self.launch_test_application( "/usr/lib/" + arch + "/qt5/bin/qmlscene", qml_path, extra_args, emulator_base=EmulatorBase) def test_custom_emulator(self): app = self.launch_test_qml() test_widget = app.select_single(InnerTestCase.QQuickView) self.assertThat(test_widget.visible, Eventually(Equals(True))) result1 = InnerTestCase('test_custom_emulator').run() self.assertThat( result1.wasSuccessful(), Equals(True), '\n\n'.join( [e[1] for e in result1.decorated.errors] ) ) result2 = InnerTestCase('test_custom_emulator').run() self.assertThat( result2.wasSuccessful(), Equals(True), '\n\n'.join( [e[1] for e in result2.decorated.errors] ) ) class CustomCPOTest(AutopilotTestCase, QmlScriptRunnerMixin): def launch_simple_qml_script(self): simple_script = dedent(""" import QtQuick 2.0 Rectangle { objectName: "ExampleRectangle" } """) return self.start_qml_script(simple_script) def test_cpo_can_be_named_different_to_underlying_type(self): """A CPO with the correct name match method must be matched if the class name is different to the Type name. """ with object_registry.patch_registry({}): class RandomNamedCPORectangle(CustomEmulatorBase): @classmethod def get_type_query_name(cls): return 'QQuickRectangle' app = self.launch_simple_qml_script() rectangle = app.select_single(RandomNamedCPORectangle) self.assertThat(rectangle.objectName, Equals('ExampleRectangle')) ./autopilot/tests/functional/test_open_window.py0000644000004100000410000000453614002063564022540 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os.path from testtools import skipIf from testtools.matchers import Equals from autopilot import platform from autopilot.testcase import AutopilotTestCase from autopilot.process import ProcessManager import logging logger = logging.getLogger(__name__) @skipIf(platform.model() != "Desktop", "Not suitable for device (ProcManager)") class OpenWindowTests(AutopilotTestCase): scenarios = [ ( k, { 'app_name': k, 'app_details': v, } ) for k, v in ProcessManager.KNOWN_APPS.items() ] def test_open_window(self): """self.start_app_window must open a new window of the given app.""" if not os.path.exists( os.path.join( '/usr/share/applications', self.app_details['desktop-file'] ) ): self.skip("Application '%s' is not installed" % self.app_name) existing_apps = self.process_manager.get_app_instances(self.app_name) # if we opened the app, ensure that we close it again to avoid leaking # processes like remmina if not existing_apps: self.addCleanup(self.process_manager.close_all_app, self.app_name) old_wins = [] for app in existing_apps: old_wins.extend(app.get_windows()) logger.debug("Old windows: %r", old_wins) win = self.process_manager.start_app_window(self.app_name) logger.debug("New window: %r", win) is_new = win.x_id not in [w.x_id for w in old_wins] self.assertThat(is_new, Equals(True)) ./autopilot/tests/functional/test_autopilot_functional.py0000644000004100000410000010564414002063564024454 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from fixtures import TempDir import glob import os import os.path import re from tempfile import mktemp from testtools import skipIf from testtools.matchers import Contains, Equals, MatchesRegex, Not, NotEquals from textwrap import dedent from unittest.mock import Mock from autopilot import platform from autopilot.matchers import Eventually from autopilot.tests.functional import AutopilotRunTestBase, remove_if_exists from autopilot._video import RMDVideoLogFixture class AutopilotFunctionalTestsBase(AutopilotRunTestBase): """A collection of functional tests for autopilot.""" def run_autopilot_list(self, list_spec='tests', extra_args=[]): """Run 'autopilot list' in the specified base path. This patches the environment to ensure that it's *this* version of autopilot that's run. returns a tuple containing: (exit_code, stdout, stderr) """ args_list = ["list"] + extra_args + [list_spec] return self.run_autopilot(args_list) def assertTestsInOutput(self, tests, output, total_title='tests'): """Asserts that 'tests' are all present in 'output'. This assertion is intelligent enough to know that tests are not always printed in alphabetical order. 'tests' can either be a list of test ids, or a list of tuples containing (scenario_count, test_id), in the case of scenarios. """ if type(tests) is not list: raise TypeError("tests must be a list, not %r" % type(tests)) if not isinstance(output, str): raise TypeError("output must be a string, not %r" % type(output)) expected_heading = 'Loading tests from: %s\n\n' % self.base_path expected_tests = [] expected_total = 0 for test in tests: if type(test) == tuple: expected_tests.append(' *%d %s' % test) expected_total += test[0] else: expected_tests.append(' %s' % test) expected_total += 1 expected_footer = ' %d total %s.' % (expected_total, total_title) parts = output.split('\n') observed_heading = '\n'.join(parts[:2]) + '\n' observed_test_list = parts[2:-4] observed_footer = parts[-2] self.assertThat(expected_heading, Equals(observed_heading)) self.assertThat( sorted(expected_tests), Equals(sorted(observed_test_list)) ) self.assertThat(expected_footer, Equals(observed_footer)) class FunctionalTestMain(AutopilotFunctionalTestsBase): def test_config_available_in_decorator(self): """Any commandline config values must be available for decorators.""" unique_config_value = self.getUniqueString() self.create_test_file( 'test_config_skip.py', dedent("""\ from testtools import skipIf import autopilot from autopilot.testcase import AutopilotTestCase class ConfigTest(AutopilotTestCase): @skipIf( autopilot.get_test_configuration().get('skipme', None) == '{unique_config_value}', 'Skipping Test') def test_config(self): self.fail('Should not run.') """.format(unique_config_value=unique_config_value)) ) config_string = 'skipme={}'.format(unique_config_value) code, output, error = self.run_autopilot( ['run', 'tests', '--config', config_string]) self.assertThat(code, Equals(0)) def test_can_list_empty_test_dir(self): """Autopilot list must report 0 tests found with an empty test module.""" code, output, error = self.run_autopilot_list() self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertTestsInOutput([], output) def test_can_list_tests(self): """Autopilot must find tests in a file.""" self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) # ideally these would be different tests, but I'm lazy: valid_test_specs = [ 'tests', 'tests.test_simple', 'tests.test_simple.SimpleTest', 'tests.test_simple.SimpleTest.test_simple', ] for test_spec in valid_test_specs: code, output, error = self.run_autopilot_list(test_spec) self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertTestsInOutput( ['tests.test_simple.SimpleTest.test_simple'], output) def test_list_tests_with_import_error(self): self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase # create an import error: import asdjkhdfjgsdhfjhsd class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot_list() self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertThat( output, MatchesRegex( ".*ImportError: No module named [']?asdjkhdfjgsdhfjhsd[']?.*", re.DOTALL ) ) def test_list_tests_with_syntax_error(self): self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase # create a syntax error: .. class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot_list() expected_error = 'SyntaxError: invalid syntax' self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertThat(output, Contains(expected_error)) def test_list_nonexistent_test_returns_nonzero(self): code, output, error = self.run_autopilot_list(list_spec='1234') expected_msg = "could not import package 1234: No module" expected_result = "0 total tests" self.assertThat(code, Equals(1)) self.assertThat(output, Contains(expected_msg)) self.assertThat(output, Contains(expected_result)) def test_can_list_scenariod_tests(self): """Autopilot must show scenario counts next to tests that have scenarios.""" self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): scenarios = [ ('scenario one', {'key': 'value'}), ] def test_simple(self): pass """) ) expected_output = '''\ Loading tests from: %s *1 tests.test_simple.SimpleTest.test_simple 1 total tests. ''' % self.base_path code, output, error = self.run_autopilot_list() self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertThat(output, Equals(expected_output)) def test_can_list_scenariod_tests_with_multiple_scenarios(self): """Autopilot must show scenario counts next to tests that have scenarios. Tests multiple scenarios on a single test suite with multiple test cases. """ self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): scenarios = [ ('scenario one', {'key': 'value'}), ('scenario two', {'key': 'value2'}), ] def test_simple(self): pass def test_simple_two(self): pass """) ) code, output, error = self.run_autopilot_list() self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertTestsInOutput( [ (2, 'tests.test_simple.SimpleTest.test_simple'), (2, 'tests.test_simple.SimpleTest.test_simple_two'), ], output ) def test_can_list_invalid_scenarios(self): """Autopilot must ignore scenarios that are not lists.""" self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): scenarios = None def test_simple(self): pass """) ) code, output, error = self.run_autopilot_list() self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertTestsInOutput( ['tests.test_simple.SimpleTest.test_simple'], output) def test_local_module_loaded_and_not_system_module(self): module_path1 = self.create_empty_test_module() module_path2 = self.create_empty_test_module() self.base_path = module_path2 retcode, stdout, stderr = self.run_autopilot( ["run", "tests"], pythonpath=module_path1, use_script=True ) self.assertThat(stdout, Contains(module_path2)) def test_can_list_just_suites(self): """Must only list available suites, not the contained tests.""" self.create_test_file( 'test_simple_suites.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass class AnotherSimpleTest(AutopilotTestCase): def test_another_simple(self): pass def test_yet_another_simple(self): pass """) ) code, output, error = self.run_autopilot_list(extra_args=['--suites']) self.assertThat(code, Equals(0)) self.assertThat(error, Equals('')) self.assertTestsInOutput( ['tests.test_simple_suites.SimpleTest', 'tests.test_simple_suites.AnotherSimpleTest'], output, total_title='suites') @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_record_flag_works(self): """Must be able to record videos when the -r flag is present.""" video_dir = mktemp() ap_dir = '/tmp/autopilot' video_session_pattern = '/tmp/rMD-session*' self.addCleanup(remove_if_exists, video_dir) self.addCleanup( remove_if_exists, '%s/Dummy_Description.ogv' % (ap_dir) ) self.addCleanup(remove_if_exists, ap_dir) mock_test_case = Mock() mock_test_case.shortDescription.return_value = 'Dummy_Description' orig_sessions = glob.glob(video_session_pattern) video_logger = RMDVideoLogFixture(video_dir, mock_test_case) video_logger.setUp() video_logger._test_passed = False # We use Eventually() to avoid the case where recordmydesktop does not # create a file because it gets stopped before it's even started # capturing anything. self.assertThat( lambda: glob.glob(video_session_pattern), Eventually(NotEquals(orig_sessions)) ) video_logger._stop_video_capture(mock_test_case) self.assertTrue(os.path.exists(video_dir)) self.assertTrue(os.path.exists( '%s/Dummy_Description.ogv' % (video_dir))) self.assertFalse(os.path.exists( '%s/Dummy_Description.ogv' % (ap_dir))) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_record_dir_option_and_record_works(self): """Must be able to specify record directory flag and record.""" # The sleep is to avoid the case where recordmydesktop does not create # a file because it gets stopped before it's even started capturing # anything. self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple(self): sleep(1) self.fail() """) ) video_dir = mktemp() ap_dir = '/tmp/autopilot' self.addCleanup(remove_if_exists, video_dir) should_delete = not os.path.exists(ap_dir) if should_delete: self.addCleanup(remove_if_exists, ap_dir) else: self.addCleanup( remove_if_exists, '%s/tests.test_simple.SimpleTest.test_simple.ogv' % (ap_dir)) code, output, error = self.run_autopilot( ["run", "-r", "-rd", video_dir, "tests"]) self.assertThat(code, Equals(1)) self.assertTrue(os.path.exists(video_dir)) self.assertTrue(os.path.exists( '%s/tests.test_simple.SimpleTest.test_simple.ogv' % (video_dir))) self.assertFalse( os.path.exists( '%s/tests.test_simple.SimpleTest.test_simple.ogv' % (ap_dir))) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_record_dir_option_works(self): """Must be able to specify record directory flag.""" # The sleep is to avoid the case where recordmydesktop does not create # a file because it gets stopped before it's even started capturing # anything. self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple(self): sleep(1) self.fail() """) ) video_dir = mktemp() self.addCleanup(remove_if_exists, video_dir) code, output, error = self.run_autopilot( ["run", "-rd", video_dir, "tests"]) self.assertThat(code, Equals(1)) self.assertTrue(os.path.exists(video_dir)) self.assertTrue( os.path.exists( '%s/tests.test_simple.SimpleTest.test_simple.ogv' % (video_dir))) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_no_videos_saved_when_record_option_is_not_present(self): """Videos must not be saved if the '-r' option is not specified.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple(self): sleep(1) self.fail() """) ) self.addCleanup( remove_if_exists, '/tmp/autopilot/tests.test_simple.SimpleTest.test_simple.ogv') code, output, error = self.run_autopilot(["run", "tests"]) self.assertThat(code, Equals(1)) self.assertFalse(os.path.exists( '/tmp/autopilot/tests.test_simple.SimpleTest.test_simple.ogv')) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_no_videos_saved_for_skipped_test(self): """Videos must not be saved if the test has been skipped (not failed). """ self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple(self): sleep(1) self.skip("Skipping Test") """) ) video_file_path = ( '/tmp/autopilot/tests.test_simple.SimpleTest.test_simple.ogv') self.addCleanup(remove_if_exists, video_file_path) code, output, error = self.run_autopilot(["run", "-r", "tests"]) self.assertThat(code, Equals(0)) self.assertThat(os.path.exists(video_file_path), Equals(False)) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_no_video_session_dir_saved_for_passed_test(self): """RecordMyDesktop should clean up its session files in tmp dir.""" with TempDir() as tmp_dir_fixture: dir_pattern = os.path.join(tmp_dir_fixture.path, 'rMD-session*') original_session_dirs = set(glob.glob(dir_pattern)) get_new_sessions = lambda: \ set(glob.glob(dir_pattern)) - original_session_dirs mock_test_case = Mock() mock_test_case.shortDescription.return_value = "Dummy_Description" logger = RMDVideoLogFixture(tmp_dir_fixture.path, mock_test_case) logger.set_recording_dir(tmp_dir_fixture.path) logger._recording_opts = ['--workdir', tmp_dir_fixture.path] \ + logger._recording_opts logger.setUp() self.assertThat(get_new_sessions, Eventually(NotEquals(set()))) logger._stop_video_capture(mock_test_case) self.assertThat(get_new_sessions, Eventually(Equals(set()))) @skipIf(platform.model() != "Desktop", "Only suitable on Desktop (VidRec)") def test_no_video_for_nested_testcase_when_parent_and_child_fail(self): """Test recording must not create a new recording for nested testcases where both the parent and the child testcase fail. """ self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase import os class OuterTestCase(AutopilotTestCase): def test_nested_classes(self): class InnerTestCase(AutopilotTestCase): def test_will_fail(self): self.assertTrue(False) InnerTestCase("test_will_fail").run() self.assertTrue(False) """) ) expected_video_file = ( '/tmp/autopilot/tests.test_simple.OuterTestCase.' 'test_nested_classes.ogv') erroneous_video_file = ( '/tmp/autopilot/tests.test_simple.OuterTestCase.' 'test_nested_classes.InnerTestCase.test_will_fail.ogv') self.addCleanup(remove_if_exists, expected_video_file) self.addCleanup(remove_if_exists, erroneous_video_file) code, output, error = self.run_autopilot(["run", "-v", "-r", "tests"]) self.assertThat(code, Equals(1)) self.assertThat(os.path.exists(expected_video_file), Equals(True)) self.assertThat(os.path.exists(erroneous_video_file), Equals(False)) def test_runs_with_import_errors_fail(self): """Import errors inside a test must be considered a test failure.""" self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase # create an import error: import asdjkhdfjgsdhfjhsd class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "tests"]) self.assertThat(code, Equals(1)) self.assertThat(error, Equals('')) self.assertThat( output, MatchesRegex( ".*ImportError: No module named [']?asdjkhdfjgsdhfjhsd[']?.*", re.DOTALL ) ) self.assertThat(output, Contains("FAILED (failures=1)")) def test_runs_with_syntax_errors_fail(self): """Import errors inside a test must be considered a test failure.""" self.create_test_file( 'test_simple.py', dedent("""\ from autopilot.testcase import AutopilotTestCase # create a syntax error: .. class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "tests"]) expected_error = '''\ tests/test_simple.py", line 4 .. ^ SyntaxError: invalid syntax ''' self.assertThat(code, Equals(1)) self.assertThat(error, Equals('')) self.assertThat(output, Contains(expected_error)) self.assertThat(output, Contains("FAILED (failures=1)")) def test_can_create_subunit_result_file(self): self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) output_file_path = mktemp() self.addCleanup(remove_if_exists, output_file_path) code, output, error = self.run_autopilot([ "run", "-o", output_file_path, "-f", "subunit", "tests"]) self.assertThat(code, Equals(0)) self.assertTrue(os.path.exists(output_file_path)) def test_launch_needs_arguments(self): """Autopilot launch must complain if not given an application to launch.""" rc, _, _ = self.run_autopilot(["launch"]) self.assertThat(rc, Equals(2)) def test_complains_on_unknown_introspection_type(self): """Launching a binary that does not support an introspection type we are familiar with must result in a nice error message. """ rc, stdout, _ = self.run_autopilot(["launch", "yes"]) self.assertThat(rc, Equals(1)) self.assertThat( stdout, Contains( "Error: Could not determine introspection type to use for " "application '/usr/bin/yes'")) def test_complains_on_missing_file(self): """Must give a nice error message if we try and launch a binary that's missing.""" rc, stdout, _ = self.run_autopilot(["launch", "DoEsNotExist"]) self.assertThat(rc, Equals(1)) self.assertThat( stdout, Contains("Error: Cannot find application 'DoEsNotExist'")) def test_complains_on_non_dynamic_binary(self): """Must give a nice error message when passing in a non-dynamic binary.""" # tzselect is a bash script, and is in the base system, so should # always exist. rc, stdout, _ = self.run_autopilot(["launch", "tzselect"]) self.assertThat(rc, Equals(1)) self.assertThat( stdout, Contains( "Error detecting launcher: Command '['ldd', " "'/usr/bin/tzselect']' returned non-zero exit status 1\n" "(Perhaps use the '-i' argument to specify an interface.)\n") ) def test_run_random_order_flag_works(self): """Must run tests in random order when -ro is used""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple_one(self): pass def test_simple_two(self): pass """) ) code, output, error = self.run_autopilot(["run", "-ro", "tests"]) self.assertThat(code, Equals(0)) self.assertThat(output, Contains('Running tests in random order')) def test_run_random_flag_not_used(self): """Must not run tests in random order when -ro is not used""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from time import sleep class SimpleTest(AutopilotTestCase): def test_simple_one(self): pass def test_simple_two(self): pass """) ) code, output, error = self.run_autopilot(["run", "tests"]) self.assertThat(code, Equals(0)) self.assertThat(output, Not(Contains('Running tests in random order'))) def test_get_test_configuration_from_command_line(self): self.create_test_file( 'test_config.py', dedent("""\ from autopilot import get_test_configuration from autopilot.testcase import AutopilotTestCase class Tests(AutopilotTestCase): def test_foo(self): c = get_test_configuration() print(c['foo']) """) ) code, output, error = self.run_autopilot( ["run", "--config", "foo=This is a test", "tests"] ) self.assertThat(code, Equals(0)) self.assertIn("This is a test", output) class AutopilotVerboseFunctionalTests(AutopilotFunctionalTestsBase): """Scenarioed functional tests for autopilot's verbose logging.""" scenarios = [ ('text_format', dict(output_format='text')), ('xml_format', dict(output_format='xml')) ] def test_verbose_flag_works(self): """Verbose flag must log to stderr.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat(code, Equals(0)) self.assertThat( error, Contains( "Starting test tests.test_simple.SimpleTest.test_simple")) def test_verbose_flag_shows_timestamps(self): """Verbose log must include timestamps.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat(error, MatchesRegex("^\d\d:\d\d:\d\d\.\d\d\d")) def test_verbose_flag_shows_success(self): """Verbose log must indicate successful tests (text format).""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat( error, Contains("OK: tests.test_simple.SimpleTest.test_simple")) def test_verbose_flag_shows_error(self): """Verbose log must indicate test error with a traceback.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): raise RuntimeError("Intentionally fail test.") """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat( error, Contains("ERROR: tests.test_simple.SimpleTest.test_simple")) self.assertThat(error, Contains("traceback:")) self.assertThat( error, Contains("RuntimeError: Intentionally fail test.") ) def test_verbose_flag_shows_failure(self): """Verbose log must indicate a test failure with a traceback (xml format).""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): self.assertTrue(False) """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertIn("FAIL: tests.test_simple.SimpleTest.test_simple", error) self.assertIn("traceback:", error) self.assertIn("AssertionError: False is not true", error) def test_verbose_flag_captures_nested_autopilottestcase_classes(self): """Verbose log must contain the log details of both the nested and parent testcase.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase import os class OuterTestCase(AutopilotTestCase): def test_nested_classes(self): class InnerTestCase(AutopilotTestCase): def test_produce_log_output(self): self.assertTrue(True) InnerTestCase("test_produce_log_output").run() self.assertTrue(True) """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat(code, Equals(0)) self.assertThat( error, Contains( "Starting test tests.test_simple.OuterTestCase." "test_nested_classes" ) ) self.assertThat( error, Contains( "Starting test tests.test_simple.InnerTestCase." "test_produce_log_output" ) ) def test_can_enable_debug_output(self): """Verbose log must show debug messages if we specify '-vv'.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from autopilot.utilities import get_debug_logger class SimpleTest(AutopilotTestCase): def test_simple(self): get_debug_logger().debug("Hello World") """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-vv", "tests"]) self.assertThat(error, Contains("Hello World")) def test_debug_output_not_shown_by_default(self): """Verbose log must not show debug messages unless we specify '-vv'.""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase from autopilot.utilities import get_debug_logger class SimpleTest(AutopilotTestCase): def test_simple(self): get_debug_logger().debug("Hello World") """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat(error, Not(Contains("Hello World"))) def test_debug_output_not_shown_by_default_normal_logger(self): """Verbose log must not show logger.debug level details with -v.""" debug_string = self.getUniqueString() self.create_test_file( "test_simple.py", dedent("""\ import logging from autopilot.testcase import AutopilotTestCase logger = logging.getLogger(__name__) class SimpleTest(AutopilotTestCase): def test_simple(self): logger.debug('{debug_string}') """.format(debug_string=debug_string)) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat(error, Not(Contains(debug_string))) def test_verbose_flag_shows_autopilot_version(self): from autopilot import get_version_string """Verbose log must indicate successful tests (text format).""" self.create_test_file( "test_simple.py", dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_simple(self): pass """) ) code, output, error = self.run_autopilot(["run", "-f", self.output_format, "-v", "tests"]) self.assertThat( error, Contains(get_version_string())) def test_failfast(self): """Run stops after first error encountered.""" self.create_test_file( 'test_failfast.py', dedent("""\ from autopilot.testcase import AutopilotTestCase class SimpleTest(AutopilotTestCase): def test_one(self): raise Exception def test_two(self): raise Exception """) ) code, output, error = self.run_autopilot(["run", "--failfast", "tests"]) self.assertThat(code, Equals(1)) self.assertIn("Ran 1 test", output) self.assertIn("FAILED (failures=1)", output) ./autopilot/tests/functional/test_ap_apps.py0000644000004100000410000003471314002063567021636 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import datetime import os import subprocess import logging import sys from testtools import skipIf from testtools.matchers import ( Equals, LessThan, Not, Raises, raises, ) from textwrap import dedent from fixtures import EnvironmentVariable from autopilot.application import ( NormalApplicationLauncher, ) from autopilot.exceptions import ProcessSearchError from autopilot.process import ProcessManager from autopilot.platform import model from autopilot.testcase import AutopilotTestCase from autopilot.tests.functional.fixtures import ( ExecutableScript, TempDesktopFile, ) from autopilot.introspection import get_proxy_object_for_existing_process from autopilot.introspection.utilities import _pid_is_running from autopilot.utilities import sleep logger = logging.getLogger(__name__) def locale_is_supported(): """Check if our currently set locale supports writing unicode to stdout.""" try: encoding = sys.stdout.encoding or sys.getfilesystemencoding() '\u2026'.encode(encoding) return True except UnicodeEncodeError: return False def _get_unused_pid(): """Returns a Process ID number that isn't currently running. :raises: **RuntimeError** if unable to produce a number that doesn't correspond to a currently running process. """ for i in range(10000, 20000): if not _pid_is_running(i): return i raise RuntimeError("Unable to find test PID.") class ApplicationTests(AutopilotTestCase): """A base class for application mixin tests.""" def write_script(self, content, extension=".py"): """Write a script to a temporary file, make it executable, and return the path to the script file. """ return self.useFixture(ExecutableScript(content, extension)).path class ApplicationLaunchTests(ApplicationTests): def test_unknown_app_exception(self): """launch_test_app must raise a RuntimeError when asked to launch an application that has an unknown introspection type. """ path = self.write_script("") expected_error_message = ( "Autopilot could not determine the correct " "introspection type to use. You can specify this by providing " "app_type." ) self.assertThat( lambda: self.launch_test_application(path), raises(RuntimeError(expected_error_message))) def test_creating_app_proxy_for_running_app_not_on_dbus_fails(self): """Creating app proxy object for an application that isn't connected to the dbus session must raise a ProcessSearchError exception. """ path = self.write_script(dedent("""\ #!%s from time import sleep while True: print("Still running") sleep(1) """ % sys.executable)) self.assertThat( lambda: self.launch_test_application(path, app_type='qt'), raises(ProcessSearchError) ) def test_creating_app_for_non_running_app_fails(self): """Attempting to create an application proxy object for a process (using a PID) that isn't running must raise an exception. """ pid = _get_unused_pid() self.assertThat( lambda: get_proxy_object_for_existing_process(pid=pid), raises(ProcessSearchError("PID %d could not be found" % pid)) ) def test_creating_proxy_for_segfaulted_app_failed(self): """Creating a proxy object for an application that has died since launching must throw ProcessSearchError exception. """ path = self.write_script(dedent("""\ #!%s from time import sleep import sys sleep(5) sys.exit(1) """ % sys.executable)) expected_error = "Process exited with exit code: 1" self.assertThat( lambda: self.launch_test_application(path, app_type='qt'), raises(ProcessSearchError(expected_error)) ) def test_creating_proxy_for_segfaulted_app_fails_quicker(self): """Searching for a process that has died since launching, the search must fail before the 10 second timeout. """ path = self.write_script(dedent("""\ #!%s from time import sleep import sys sleep(1) sys.exit(1) """ % sys.executable)) start = datetime.datetime.now() try: self.launch_test_application(path, app_type='qt') except ProcessSearchError: end = datetime.datetime.now() else: self.fail( "launch_test_application didn't raise expected exception" ) difference = end - start self.assertThat(difference.total_seconds(), LessThan(5)) @skipIf(model() != "Desktop", "Not suitable for device (Qt4)") def test_closing_app_produces_good_error(self): """Testing an application that closes before the test ends must produce a good error message when calling refresh_state() on the application proxy object. """ path = self.write_script(dedent("""\ #!%s from PyQt4.QtGui import QMainWindow, QApplication from PyQt4.QtCore import QTimer from sys import argv app = QApplication(argv) win = QMainWindow() win.show() QTimer.singleShot(8000, app.exit) app.exec_() """ % sys.executable)) app_proxy = self.launch_test_application(path, app_type='qt') self.assertTrue(app_proxy is not None) def crashing_fn(): for i in range(10): logger.debug("%d %r", i, app_proxy.refresh_state()) sleep(1) self.assertThat( crashing_fn, raises( RuntimeError( "Application under test exited before the test finished!" ) ) ) class QmlTestMixin(object): def get_qml_viewer_app_path(self): try: qtversions = subprocess.check_output( ['qtchooser', '-list-versions'], universal_newlines=True ).split('\n') check_func = self._find_qt_binary_chooser except OSError: # This means no qtchooser is installed, so let's check for # qmlviewer and qmlscene manually, the old way qtversions = ['qt4', 'qt5'] check_func = self._find_qt_binary_old not_found = True if 'qt4' in qtversions: path = check_func('qt4', 'qmlviewer') if path: not_found = False self.qml_viewer_app_path = path self.useFixture(EnvironmentVariable("QT_SELECT", "qt4")) if 'qt5' in qtversions: path = check_func('qt5', 'qmlscene') if path: not_found = False self.qml_viewer_app_path = path self.useFixture(EnvironmentVariable("QT_SELECT", "qt5")) if not_found: self.skip("Neither qmlviewer nor qmlscene is installed") return self.qml_viewer_app_path def _find_qt_binary_chooser(self, version, name): # Check for existence of the binary when qtchooser is installed # We cannot use 'which', as qtchooser installs wrappers - we need to # check in the actual library paths env = subprocess.check_output( ['qtchooser', '-qt=' + version, '-print-env'], universal_newlines=True).split('\n') for i in env: if i.find('QTTOOLDIR') >= 0: path = i.lstrip("QTTOOLDIR=").strip('"') + "/" + name if os.path.exists(path): return path return None return None def _find_qt_binary_old(self, version, name): # Check for the existence of the binary the old way try: path = subprocess.check_output(['which', 'qmlviewer'], universal_newlines=True).strip() except subprocess.CalledProcessError: path = None return path class QtTests(ApplicationTests, QmlTestMixin): def test_can_launch_normal_app(self): path = self.get_qml_viewer_app_path() fixture = self.useFixture(TempDesktopFile(exec_=path,)) launcher = self.useFixture(NormalApplicationLauncher()) app_proxy = launcher.launch( path, ['--desktop_file_hint=%s' % fixture.get_desktop_file_path()], app_type='qt' ) self.assertTrue(app_proxy is not None) @skipIf(model() != "Desktop", "Only suitable on Desktop (Qt4)") def test_can_launch_normal_qt_script(self): path = self.write_script(dedent("""\ #!%s from PyQt4.QtGui import QMainWindow, QApplication from sys import argv app = QApplication(argv) win = QMainWindow() win.show() app.exec_() """ % sys.executable)) app_proxy = self.launch_test_application(path, app_type='qt') self.assertTrue(app_proxy is not None) # TODO: move this into a test module that tests bamf. @skipIf(model() != 'Desktop', "Bamf only available on desktop (Qt4)") def test_bamf_geometry_gives_reliable_results(self): path = self.write_script(dedent("""\ #!%s from PyQt4.QtGui import QMainWindow, QApplication from sys import argv app = QApplication(argv) win = QMainWindow() win.show() app.exec_() """ % sys.executable)) app_proxy = self.launch_test_application(path, app_type='qt') proxy_window = app_proxy.select_single('QMainWindow') pm = ProcessManager.create() window = [ w for w in pm.get_open_windows() if w.name == os.path.basename(path) ][0] self.assertThat(list(window.geometry), Equals(proxy_window.geometry)) def test_can_launch_qt_script_that_aborts(self): path = self.write_script(dedent("""\ #!/usr/bin/python import os import time time.sleep(1) os.abort() """)) launch_fn = lambda: self.launch_test_application(path, app_type='qt') self.assertThat(launch_fn, raises(ProcessSearchError)) @skipIf(model() != "Desktop", "Only suitable on Desktop (Qt4)") def test_can_launch_wrapper_script(self): path = self.write_script(dedent("""\ #!%s from PyQt4.QtGui import QMainWindow, QApplication from sys import argv app = QApplication(argv) win = QMainWindow() win.show() app.exec_() """ % sys.executable)) wrapper_path = self.write_script(dedent("""\ #!/bin/sh echo "Launching %s" %s $* """ % (path, path)), extension=".sh") app_proxy = self.launch_test_application(wrapper_path, app_type='qt') self.assertTrue(app_proxy is not None) @skipIf( model() != "Desktop" or not locale_is_supported(), "Current locale is not supported or not on desktop (Qt4)" ) def test_can_handle_non_unicode_stdout_and_stderr(self): path = self.write_script(dedent("""\ #!%s # -*- coding: utf-8 -*- from PyQt4.QtGui import QMainWindow, QApplication from sys import argv, stdout, stderr app = QApplication(argv) win = QMainWindow() win.show() stdout.write('Hello\x88stdout') stdout.flush() stderr.write('Hello\x88stderr') stderr.flush() app.exec_() """ % sys.executable)) self.launch_test_application(path, app_type='qt') details_dict = self.getDetails() for name, content_obj in details_dict.items(): self.assertThat( lambda: content_obj.as_text(), Not(Raises()) ) @skipIf(model() != "Desktop", "Only suitable on Desktop (Gtk)") class GtkTests(ApplicationTests): def _get_mahjongg_path(self): try: return subprocess.check_output( ['which', 'gnome-mahjongg'], universal_newlines=True).strip() except: return def test_can_launch_gtk_app(self): mahjongg_path = self._get_mahjongg_path() if not mahjongg_path: self.skip("gnome-mahjongg not found.") app_proxy = self.launch_test_application(mahjongg_path) self.assertTrue(app_proxy is not None) def test_can_launch_gtk_script(self): path = self.write_script(dedent("""\ #!%s from gi.repository import Gtk win = Gtk.Window() win.connect("delete-event", Gtk.main_quit) win.show_all() Gtk.main() """ % sys.executable)) app_proxy = self.launch_test_application(path, app_type='gtk') self.assertTrue(app_proxy is not None) def test_can_launch_wrapper_script(self): path = self.write_script(dedent("""\ #!%s from gi.repository import Gtk win = Gtk.Window() win.connect("delete-event", Gtk.main_quit) win.show_all() Gtk.main() """ % sys.executable)) wrapper_path = self.write_script(dedent("""\ #!/bin/sh echo "Launching %s" %s """ % (path, path)), extension=".sh") app_proxy = self.launch_test_application(wrapper_path, app_type='gtk') self.assertTrue(app_proxy is not None) ./autopilot/tests/functional/fixtures.py0000644000004100000410000001353314002063564021017 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Fixtures for the autopilot functional test suite.""" import logging import os import stat from shutil import rmtree import tempfile from textwrap import dedent import time from fixtures import EnvironmentVariable, Fixture logger = logging.getLogger(__name__) class ExecutableScript(Fixture): """Write some text to a file on disk and make it executable.""" def __init__(self, script, extension=".py"): """Initialise the fixture. :param script: The contents of the script file. :param extension: The desired extension on the script file. """ super(ExecutableScript, self).__init__() self._script = script self._extension = extension def setUp(self): super(ExecutableScript, self).setUp() with tempfile.NamedTemporaryFile( suffix=self._extension, mode='w', delete=False ) as f: f.write(self._script) self.path = f.name self.addCleanup(os.unlink, self.path) os.chmod(self.path, os.stat(self.path).st_mode | stat.S_IXUSR) class TempDesktopFile(Fixture): def __init__(self, type=None, exec_=None, name=None, icon=None): """Create a TempDesktopFile instance. Parameters control the contents of the created desktop file. Default values will create a desktop file with bogus contents. :param type: The type field in the created file. Defaults to 'Application'. :param exec_: The path to the file to execute. :param name: The name of the application being launched. Defaults to "Test App". """ super(TempDesktopFile, self).__init__() type_line = type if type is not None else "Application" exec_line = exec_ if exec_ is not None else "Not Important" name_line = name if name is not None else "Test App" icon_line = icon if icon is not None else "Not Important" self._file_contents = dedent( """\ [Desktop Entry] Type={} Exec={} Name={} Icon={} """.format(type_line, exec_line, name_line, icon_line) ) def setUp(self): super(TempDesktopFile, self).setUp() path_created = TempDesktopFile._ensure_desktop_dir_exists() self._desktop_file_path = self._create_desktop_file( self._file_contents, ) self.addCleanup( TempDesktopFile._remove_desktop_file_components, path_created, self._desktop_file_path, ) def get_desktop_file_path(self): return self._desktop_file_path def get_desktop_file_id(self): return os.path.splitext(os.path.basename(self._desktop_file_path))[0] @staticmethod def _ensure_desktop_dir_exists(): desktop_file_dir = TempDesktopFile._desktop_file_dir() if not os.path.exists(desktop_file_dir): return TempDesktopFile._create_desktop_file_dir(desktop_file_dir) return '' @staticmethod def _desktop_file_dir(): return os.path.join( os.getenv('HOME'), '.local', 'share', 'applications' ) @staticmethod def _create_desktop_file_dir(desktop_file_dir): """Create the directory specified. Returns the component of the path that did not exist, or the empty string if the entire path already existed. """ # We might be creating more than just the leaf directory, so we need to # keep track of what doesn't already exist and remove it when we're # done. Defaults to removing the full path path_to_delete = "" if not os.path.exists(desktop_file_dir): path_to_delete = desktop_file_dir full_path, leaf = os.path.split(desktop_file_dir) while leaf != "": if not os.path.exists(full_path): path_to_delete = full_path full_path, leaf = os.path.split(full_path) try: os.makedirs(desktop_file_dir) except OSError: logger.warning("Directory already exists: %s" % desktop_file_dir) return path_to_delete @staticmethod def _remove_desktop_file_components(created_path, created_file): if created_path != "": rmtree(created_path) else: os.remove(created_file) @staticmethod def _create_desktop_file(file_contents): _, tmp_file_path = tempfile.mkstemp( suffix='.desktop', dir=TempDesktopFile._desktop_file_dir() ) with open(tmp_file_path, 'w') as desktop_file: desktop_file.write(file_contents) return tmp_file_path class Timezone(Fixture): def __init__(self, timezone): self._timezone = timezone def setUp(self): super().setUp() # These steps need to happen in the right order otherwise they won't # get cleaned up properly and we'll be left in an incorrect timezone. self.addCleanup(time.tzset) self.useFixture(EnvironmentVariable('TZ', self._timezone)) time.tzset() ./autopilot/tests/functional/test_platform.py0000644000004100000410000000236314002063564022030 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase, skipIf import autopilot.platform as platform class PublicAPITests(TestCase): @skipIf(platform.model() != "Desktop", "Only available on desktop.") def test_get_display_server_returns_x11(self): self.assertEqual(platform.get_display_server(), "X11") @skipIf(platform.model() == "Desktop", "Only available on device.") def test_get_display_server_returns_mir(self): self.assertEqual(platform.get_display_server(), "MIR") ./autopilot/tests/functional/test_types.py0000644000004100000410000000723114002063564021347 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from datetime import datetime from autopilot.testcase import AutopilotTestCase from autopilot.tests.functional import QmlScriptRunnerMixin from autopilot.tests.functional.fixtures import Timezone from textwrap import dedent class DateTimeTests(AutopilotTestCase, QmlScriptRunnerMixin): scenarios = [ ('UTC', dict( TZ='UTC', )), ('NZ', dict( TZ='Pacific/Auckland', )), ('US Central', dict( TZ='US/Central', )), ('US Eastern', dict( TZ='US/Eastern', )), ('Hongkong', dict( TZ='Hongkong' )), ('CET', dict( TZ='Europe/Copenhagen', )), # QML timezone database is incorrect/out-of-date for # Europe/Moscow. Given the timestamp of 1411992000 (UTC: 2014:9:29 # 12:00) for this date offset should be +0400 # (http://en.wikipedia.org/wiki/Time_in_Russia#Daylight_saving_time) # QML app gives: 2014:9:29 14:00 where it should be 2014:9:29 16:00 # ('MSK', dict( # TZ='Europe/Moscow', # )), ] def get_test_qml_string(self, date_string): return dedent(""" import QtQuick 2.0 import QtQml 2.2 Rectangle { property date testingTime: new Date(%s); Text { text: testingTime; } }""" % date_string) def test_qml_applies_timezone_to_timestamp(self): """Test that when given a timestamp the datetime displayed has the timezone applied to it. QML will apply a timezone calculation to a timestamp (but not a timestring). """ self.useFixture(Timezone(self.TZ)) timestamp = 1411992000 timestamp_ms = 1411992000 * 1000 qml_script = self.get_test_qml_string(timestamp_ms) expected_string = datetime.fromtimestamp(timestamp).strftime('%FT%T') proxy = self.start_qml_script(qml_script) self.assertEqual( proxy.select_single('QQuickText').text, expected_string ) def test_timezone_not_applied_to_timestring(self): """Test that, in all timezones, the literal representation we get in the proxy object matches the one in the Qml script. """ self.useFixture(Timezone(self.TZ)) qml_script = self.get_test_qml_string("'2014-01-15 12:34:52'") proxy = self.start_qml_script(qml_script) date_object = proxy.select_single("QQuickRectangle").testingTime self.assertEqual(date_object.year, 2014) self.assertEqual(date_object.month, 1) self.assertEqual(date_object.day, 15) self.assertEqual(date_object.hour, 12) self.assertEqual(date_object.minute, 34) self.assertEqual(date_object.second, 52) self.assertEqual(datetime(2014, 1, 15, 12, 34, 52), date_object) ./autopilot/tests/functional/test_custom_assertions.py0000644000004100000410000001001014002063564023754 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot.testcase import AutopilotTestCase from testtools.matchers import Equals, raises, Not import logging logger = logging.getLogger(__name__) class TestObject(object): test_property = 123 another_property = "foobar" none_prop = None def test_method(self): return 456 class AssertionTests(AutopilotTestCase): test_object = TestObject() def test_assertProperty_raises_valueerror_on_empty_test(self): """assertProperty must raise ValueError if called without any kwargs.""" self.assertThat( lambda: self.assertProperty(self.test_object), raises(ValueError)) def test_assertProperty_raises_valueerror_on_callable(self): """assertProperty must raise ValueError when called with a callable property name. """ self.assertThat( lambda: self.assertProperty(self.test_object, test_method=456), raises(ValueError)) def test_assertProperty_raises_assert_with_single_property(self): """assertProperty must raise an AssertionError when called with a single property. """ self.assertThat( lambda: self.assertProperty(self.test_object, test_property=234), raises(AssertionError)) def test_assertProperty_doesnt_raise(self): """assertProperty must not raise an exception if called with correct parameters. """ self.assertThat( lambda: self.assertProperty(self.test_object, test_property=123), Not(raises(AssertionError))) def test_assertProperty_doesnt_raise_multiples(self): """assertProperty must not raise an exception if called with correct parameters. """ self.assertThat( lambda: self.assertProperty( self.test_object, test_property=123, another_property="foobar"), Not(raises(AssertionError))) def test_assertProperty_raises_assert_with_double_properties(self): """assertProperty must raise an AssertionError when called with multiple properties. """ self.assertThat( lambda: self.assertProperty( self.test_object, test_property=234, another_property=123), raises(AssertionError)) def test_assertProperties_works(self): """Asserts that the assertProperties method is a synonym for assertProperty.""" self.assertThat(callable(self.assertProperties), Equals(True)) self.assertThat( lambda: self.assertProperties( self.test_object, test_property=123, another_property="foobar"), Not(raises(AssertionError))) def test_assertProperty_raises_assertionerror_on_no_such_property(self): """AssertProperty must rise an AssertionError if the property is not found.""" self.assertThat( lambda: self.assertProperty(self.test_object, foo="bar"), raises(AssertionError)) def test_assertProperty_works_for_None_properties(self): """Must be able to match properties whose values are None.""" self.assertThat( lambda: self.assertProperties(self.test_object, none_prop=None), Not(raises(AssertionError))) ./autopilot/tests/functional/test_application_launcher.py0000644000004100000410000000321714002063567024372 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from unittest.mock import patch from autopilot.testcase import AutopilotTestCase from autopilot.application import _launcher class AutopilotTestCaseClassTests(TestCase): """Test functions of the AutopilotTestCase class.""" @patch('autopilot.testcase.NormalApplicationLauncher') def test_launch_test_application(self, nal): class LauncherTest(AutopilotTestCase): """Test launchers.""" def test_anything(self): pass test_case = LauncherTest('test_anything') with patch.object(test_case, 'useFixture') as uf: result = test_case.launch_test_application('a', 'b', 'c') uf.assert_called_once_with(nal.return_value) uf.return_value.launch.assert_called_once_with('a', ('b', 'c')) self.assertEqual(result, uf.return_value.launch.return_value) ./autopilot/tests/__init__.py0000644000004100000410000000541414002063564016542 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging import testtools class LogHandlerTestCase(testtools.TestCase): """A mixin that adds a memento loghandler for testing logging.""" class MementoHandler(logging.Handler): """A handler class which stores logging records in a list.""" def __init__(self, *args, **kwargs): """Create the instance, and add a records attribute.""" super().__init__(*args, **kwargs) self.records = [] def emit(self, record): """Just add the record to self.records.""" self.records.append(record) def check(self, level, msg, check_traceback=False): """Check that something is logged.""" result = False for rec in self.records: if rec.levelname == level: result = str(msg) in rec.getMessage() if not result and check_traceback: result = str(msg) in rec.exc_text if result: break return result def setUp(self): """Add the memento handler to the root logger.""" super().setUp() self.memento_handler = self.MementoHandler() self.root_logger = logging.getLogger() self.root_logger.addHandler(self.memento_handler) def tearDown(self): """Remove the memento handler from the root logger.""" self.root_logger.removeHandler(self.memento_handler) super().tearDown() def assertLogLevelContains(self, level, message, check_traceback=False): check = self.memento_handler.check( level, message, check_traceback=check_traceback) msg = ('Expected logging message/s could not be found:\n%s\n' 'Current logging records are:\n%s') expected = '\t%s: %s' % (level, message) records = ['\t%s: %s' % (r.levelname, r.getMessage()) for r in self.memento_handler.records] self.assertTrue(check, msg % (expected, '\n'.join(records))) ./autopilot/tests/acceptance/0000755000004100000410000000000014002063564016513 5ustar www-datawww-data./autopilot/tests/acceptance/__init__.py0000644000004100000410000000147714002063564020635 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Acceptance tests for the autopilot vis tool.""" ./autopilot/tests/unit/0000755000004100000410000000000014002063567015407 5ustar www-datawww-data./autopilot/tests/unit/test_introspection_search.py0000644000004100000410000006603614002063564023255 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from dbus import DBusException import os from unittest.mock import call, patch, Mock from testtools import TestCase from testtools.matchers import ( Contains, Equals, MatchesAll, MatchesListwise, MatchesSetwise, Not, raises, ) from autopilot.exceptions import ProcessSearchError from autopilot.utilities import sleep from autopilot.introspection import _search as _s from autopilot.introspection import CustomEmulatorBase from autopilot.introspection.constants import AUTOPILOT_PATH def ListContainsOnly(value_list): """Returns a MatchesAll matcher for comparing a list.""" return MatchesSetwise(*map(Equals, value_list)) class PassingFilter(object): @classmethod def matches(cls, dbus_tuple, params): return True class FailingFilter(object): @classmethod def matches(cls, dbus_tuple, params): return False class LowPriorityFilter(object): @classmethod def priority(cls): return 0 class HighPriorityFilter(object): @classmethod def priority(cls): return 10 class MatcherCallableTests(TestCase): def test_can_provide_list_of_filters(self): _s._filter_runner([PassingFilter], None, None) def test_passing_empty_filter_list_raises(self): self.assertThat( lambda: _s._filter_runner([], None, None), raises(ValueError("Filter list must not be empty")) ) def test_matches_returns_True_with_PassingFilter(self): self.assertTrue(_s._filter_runner([PassingFilter], None, None)) def test_matches_returns_False_with_FailingFilter(self): self.assertFalse(_s._filter_runner([FailingFilter], None, None)) def test_fails_when_first_filter_fails(self): self.assertFalse( _s._filter_runner([FailingFilter, PassingFilter], None, None) ) def test_fails_when_second_filter_fails(self): self.assertFalse( _s._filter_runner([PassingFilter, FailingFilter], None, None) ) def test_passes_when_two_filters_pass(self): self.assertTrue( _s._filter_runner([PassingFilter, PassingFilter], None, None) ) def test_fails_when_two_filters_fail(self): self.assertFalse( _s._filter_runner([FailingFilter, FailingFilter], None, None) ) def test_filter_returning_False_results_in_failure(self): class FalseFilter(object): @classmethod def matches(cls, dbus_tuple, params): return False _s._filter_runner([FalseFilter], None, None) self.assertFalse( _s._filter_runner([FalseFilter], None, None) ) def test_runner_matches_passes_dbus_tuple_to_filter(self): DBusConnectionFilter = Mock() dbus_tuple = ("bus", "connection_name") _s._filter_runner([DBusConnectionFilter], {}, dbus_tuple) DBusConnectionFilter.matches.assert_called_once_with( dbus_tuple, {} ) class FilterFunctionGeneratorTests(TestCase): """Tests to ensure the correctness of the _filter_function_from_search_params function. """ def test_uses_priority_sorted_filter_list(self): unsorted_filters = [LowPriorityFilter, HighPriorityFilter] matcher = _s._filter_function_with_sorted_filters(unsorted_filters, {}) self.assertThat( matcher.args[0], Equals([HighPriorityFilter, LowPriorityFilter]) ) class FiltersFromSearchParametersTests(TestCase): def test_raises_with_unknown_search_parameter(self): search_parameters = dict(unexpected_key=True) placeholder_lookup = dict(noop_lookup=True) self.assertThat( lambda: _s._filters_from_search_parameters( search_parameters, placeholder_lookup ), raises( KeyError( "Search parameter 'unexpected_key' doesn't have a " "corresponding filter in %r" % placeholder_lookup ) ) ) def test_returns_only_required_filters(self): search_parameters = dict(high=True, low=True) filter_lookup = dict( high=HighPriorityFilter, low=LowPriorityFilter, passing=PassingFilter, ) self.assertThat( _s._filters_from_search_parameters( search_parameters, filter_lookup ), ListContainsOnly([HighPriorityFilter, LowPriorityFilter]) ) def test_creates_unique_list_of_filters(self): search_parameters = dict(pid=True, process=True) filter_lookup = dict( pid=HighPriorityFilter, process=HighPriorityFilter ) self.assertThat( _s._filters_from_search_parameters( search_parameters, filter_lookup ), ListContainsOnly([HighPriorityFilter]) ) def test_doesnt_modify_search_parameters(self): search_parameters = dict(high=True) filter_lookup = dict(high=HighPriorityFilter) _s._filters_from_search_parameters( search_parameters, filter_lookup ) self.assertThat(search_parameters.get('high', None), Not(Equals(None))) class MandatoryFiltersTests(TestCase): def test_returns_list_containing_mandatory_filters(self): self.assertThat( _s._mandatory_filters(), ListContainsOnly([ _s.ConnectionIsNotOurConnection, _s.ConnectionIsNotOrgFreedesktopDBus ]) ) class PrioritySortFiltersTests(TestCase): def test_sorts_filters_based_on_priority(self): self.assertThat( _s._priority_sort_filters( [LowPriorityFilter, HighPriorityFilter] ), Equals([HighPriorityFilter, LowPriorityFilter]) ) def test_sorts_single_filter_based_on_priority(self): self.assertThat( _s._priority_sort_filters( [LowPriorityFilter] ), Equals([LowPriorityFilter]) ) class FilterFunctionFromFiltersTests(TestCase): def test_returns_a_callable(self): self.assertTrue( callable(_s._filter_function_with_sorted_filters([], {})) ) def test_uses_sorted_filter_list(self): matcher = _s._filter_function_with_sorted_filters( [HighPriorityFilter, LowPriorityFilter], {} ) self.assertThat( matcher.args[0], Equals([HighPriorityFilter, LowPriorityFilter]) ) class ConnectionHasNameTests(TestCase): """Tests specific to the ConnectionHasName filter.""" def test_raises_KeyError_when_missing_connection_name_param(self): dbus_tuple = ("bus", "name") self.assertThat( lambda: _s.ConnectionHasName.matches(dbus_tuple, {}), raises(KeyError('connection_name')) ) def test_returns_True_when_connection_name_matches(self): dbus_tuple = ("bus", "connection_name") search_params = dict(connection_name="connection_name") self.assertTrue( _s.ConnectionHasName.matches(dbus_tuple, search_params) ) def test_returns_False_when_connection_name_matches(self): dbus_tuple = ("bus", "connection_name") search_params = dict(connection_name="not_connection_name") self.assertFalse( _s.ConnectionHasName.matches(dbus_tuple, search_params) ) class ConnectionIsNotOurConnectionTests(TestCase): @patch.object(_s, '_get_bus_connections_pid') def test_doesnt_raise_exception_with_no_parameters(self, get_bus_pid): dbus_tuple = ("bus", "name") _s.ConnectionIsNotOurConnection.matches(dbus_tuple, {}) @patch.object(_s, '_get_bus_connections_pid', return_value=0) def test_returns_True_when_pid_isnt_our_connection(self, get_bus_pid): dbus_tuple = ("bus", "name") self.assertTrue( _s.ConnectionIsNotOurConnection.matches( dbus_tuple, {} ) ) @patch.object(_s, '_get_bus_connections_pid', return_value=os.getpid()) def test_returns_False_when_pid_is_our_connection(self, get_bus_pid): dbus_tuple = ("bus", "name") self.assertFalse( _s.ConnectionIsNotOurConnection.matches( dbus_tuple, {} ) ) @patch.object(_s, '_get_bus_connections_pid', side_effect=DBusException()) def test_returns_False_exception_raised(self, get_bus_pid): dbus_tuple = ("bus", "name") self.assertFalse( _s.ConnectionIsNotOurConnection.matches( dbus_tuple, {} ) ) class ConnectionHasPathWithAPInterfaceTests(TestCase): """Tests specific to the ConnectionHasPathWithAPInterface filter.""" def test_raises_KeyError_when_missing_object_path_param(self): dbus_tuple = ("bus", "name") self.assertThat( lambda: _s.ConnectionHasPathWithAPInterface.matches( dbus_tuple, {} ), raises(KeyError('object_path')) ) @patch.object(_s.dbus, "Interface") def test_returns_True_on_success(self, Interface): bus_obj = Mock() connection_name = "name" path = "path" dbus_tuple = (bus_obj, connection_name) self.assertTrue( _s.ConnectionHasPathWithAPInterface.matches( dbus_tuple, dict(object_path=path) ) ) bus_obj.get_object.assert_called_once_with("name", path) @patch.object(_s.dbus, "Interface") def test_returns_False_on_dbus_exception(self, Interface): bus_obj = Mock() connection_name = "name" path = "path" dbus_tuple = (bus_obj, connection_name) Interface.side_effect = DBusException() self.assertFalse( _s.ConnectionHasPathWithAPInterface.matches( dbus_tuple, dict(object_path=path) ) ) bus_obj.get_object.assert_called_once_with("name", path) class ConnectionHasPidTests(TestCase): """Tests specific to the ConnectionHasPid filter.""" def test_raises_when_missing_param(self): self.assertThat( lambda: _s.ConnectionHasPid.matches(None, {}), raises(KeyError('pid')) ) def test_returns_True_when_bus_pid_matches(self): connection_pid = self.getUniqueInteger() dbus_tuple = ("bus", "org.freedesktop.DBus") params = dict(pid=connection_pid) with patch.object( _s, '_get_bus_connections_pid', return_value=connection_pid ): self.assertTrue( _s.ConnectionHasPid.matches(dbus_tuple, params) ) def test_returns_False_with_DBusException(self): connection_pid = self.getUniqueInteger() dbus_tuple = ("bus", "org.freedesktop.DBus") params = dict(pid=connection_pid) with patch.object( _s, '_get_bus_connections_pid', side_effect=DBusException() ): self.assertFalse( _s.ConnectionHasPid.matches(dbus_tuple, params) ) class ConnectionIsNotOrgFreedesktopDBusTests(TestCase): """Tests specific to the ConnectionIsNotOrgFreedesktopDBus filter.""" def test_returns_True_when_connection_name_isnt_DBus(self): dbus_tuple = ("bus", "connection.name") self.assertTrue( _s.ConnectionIsNotOrgFreedesktopDBus.matches(dbus_tuple, {}) ) def test_returns_False_when_connection_name_is_DBus(self): dbus_tuple = ("bus", "org.freedesktop.DBus") self.assertFalse( _s.ConnectionIsNotOrgFreedesktopDBus.matches(dbus_tuple, {}) ) class ConnectionHasAppNameTests(TestCase): """Tests specific to the ConnectionHasAppName filter.""" def test_raises_when_missing_app_name_param(self): self.assertThat( lambda: _s.ConnectionHasAppName.matches(None, {}), raises(KeyError('application_name')) ) @patch.object(_s.ConnectionHasAppName, '_get_application_name') def test_uses_default_object_name_when_not_provided(self, app_name): dbus_tuple = ("bus", "connection_name") search_params = dict(application_name="application_name") _s.ConnectionHasAppName.matches(dbus_tuple, search_params) app_name.assert_called_once_with( "bus", "connection_name", AUTOPILOT_PATH ) @patch.object(_s.ConnectionHasAppName, '_get_application_name') def test_uses_provided_object_name(self, app_name): object_name = self.getUniqueString() dbus_tuple = ("bus", "connection_name") search_params = dict( application_name="application_name", object_path=object_name ) _s.ConnectionHasAppName.matches(dbus_tuple, search_params) app_name.assert_called_once_with( "bus", "connection_name", object_name ) def get_mock_dbus_address_with_app_name(slf, app_name): mock_dbus_address = Mock() mock_dbus_address.introspection_iface.GetState.return_value = ( ('/' + app_name, {}), ) return mock_dbus_address def test_get_application_name_returns_application_name(self): with patch.object(_s, '_get_dbus_address_object') as gdbao: gdbao.return_value = self.get_mock_dbus_address_with_app_name( "SomeAppName" ) self.assertEqual( _s.ConnectionHasAppName._get_application_name("", "", ""), "SomeAppName" ) class FilterHelpersTests(TestCase): """Tests for helpers around the Filters themselves.""" def test_param_to_filter_includes_all(self): """Ensure all filters are used in the matcher when requested.""" search_parameters = { f: True for f in _s._filter_lookup_map().keys() } self.assertThat( _s._filters_from_search_parameters(search_parameters), ListContainsOnly(_s._filter_lookup_map().values()) ) def test_filter_priority_order_is_correct(self): """Ensure all filters are used in the matcher when requested.""" search_parameters = { f: True for f in _s._filter_lookup_map().keys() } search_filters = _s._filters_from_search_parameters(search_parameters) mandatory_filters = _s._mandatory_filters() sorted_filters = _s._priority_sort_filters( search_filters + mandatory_filters ) expected_filter_order = [ _s.ConnectionIsNotOrgFreedesktopDBus, _s.ConnectionIsNotOurConnection, _s.ConnectionHasName, _s.ConnectionHasPid, _s.ConnectionHasPathWithAPInterface, _s.ConnectionHasAppName, ] self.assertThat( sorted_filters, MatchesListwise(list(map(Equals, expected_filter_order))) ) class ProcessAndPidErrorCheckingTests(TestCase): def test_raises_ProcessSearchError_when_process_is_not_running(self): with patch.object(_s, '_pid_is_running') as pir: pir.return_value = False self.assertThat( lambda: _s._check_process_and_pid_details(pid=123), raises(ProcessSearchError("PID 123 could not be found")) ) def test_raises_RuntimeError_when_pid_and_process_disagree(self): mock_process = Mock() mock_process.pid = 1 self.assertThat( lambda: _s._check_process_and_pid_details(mock_process, 2), raises(RuntimeError("Supplied PID and process.pid do not match.")) ) def test_returns_pid_when_specified(self): expected = self.getUniqueInteger() with patch.object(_s, '_pid_is_running') as pir: pir.return_value = True observed = _s._check_process_and_pid_details(pid=expected) self.assertEqual(expected, observed) def test_returns_process_pid_attr_when_specified(self): fake_process = Mock() fake_process.pid = self.getUniqueInteger() with patch.object(_s, '_pid_is_running') as pir: pir.return_value = True observed = _s._check_process_and_pid_details(fake_process) self.assertEqual(fake_process.pid, observed) def test_returns_None_when_neither_parameters_present(self): self.assertEqual( None, _s._check_process_and_pid_details() ) def test_returns_pid_when_both_specified(self): fake_process = Mock() fake_process.pid = self.getUniqueInteger() with patch.object(_s, '_pid_is_running') as pir: pir.return_value = True observed = _s._check_process_and_pid_details( fake_process, fake_process.pid ) self.assertEqual(fake_process.pid, observed) class FilterParentPidsFromChildrenTests(TestCase): def test_returns_all_connections_with_no_parent_match(self): search_pid = 123 connections = ['1:0', '1:3'] dbus_bus = Mock() pid_mapping = Mock(side_effect=[111, 222]) self.assertThat( _s._filter_parent_pids_from_children( search_pid, connections, dbus_bus, _connection_pid_fn=pid_mapping ), Equals(connections) ) def test_calls_connection_pid_fn_in_order(self): search_pid = 123 connections = ['1:3', '1:0'] dbus_bus = Mock() pid_mapping = Mock(side_effect=[222, 123]) _s._filter_parent_pids_from_children( search_pid, connections, dbus_bus, _connection_pid_fn=pid_mapping ) self.assertTrue( pid_mapping.call_args_list == [ call('1:3', dbus_bus), call('1:0', dbus_bus) ] ) def test_returns_just_parent_connection_with_pid_match(self): search_pid = 123 # connection '1.0' has pid 123. connections = ['1:3', '1:0'] dbus_bus = Mock() # Mapping returns parent pid on second call. pid_mapping = Mock(side_effect=[222, 123]) self.assertThat( _s._filter_parent_pids_from_children( search_pid, connections, dbus_bus, _connection_pid_fn=pid_mapping ), Equals(['1:0']) ) self.assertTrue( pid_mapping.call_args_list == [ call('1:3', dbus_bus), call('1:0', dbus_bus) ] ) def test_returns_all_connections_with_no_pids_returned_in_search(self): search_pid = 123 connections = ['1:3', '1:0'] dbus_bus = Mock() pid_mapping = Mock(side_effect=[None, None]) self.assertThat( _s._filter_parent_pids_from_children( search_pid, connections, dbus_bus, _connection_pid_fn=pid_mapping ), Equals(connections) ) class ProcessSearchErrorStringRepTests(TestCase): """Various tests for the _get_search_criteria_string_representation function. """ def test_get_string_rep_defaults_to_empty_string(self): observed = _s._get_search_criteria_string_representation() self.assertEqual("", observed) def test_pid(self): self.assertEqual( 'pid = 123', _s._get_search_criteria_string_representation(pid=123) ) def test_dbus_bus(self): self.assertEqual( "dbus bus = 'foo'", _s._get_search_criteria_string_representation(dbus_bus='foo') ) def test_connection_name(self): self.assertEqual( "connection name = 'foo'", _s._get_search_criteria_string_representation( connection_name='foo' ) ) def test_object_path(self): self.assertEqual( "object path = 'foo'", _s._get_search_criteria_string_representation(object_path='foo') ) def test_application_name(self): self.assertEqual( "application name = 'foo'", _s._get_search_criteria_string_representation( application_name='foo' ) ) def test_process_object(self): class FakeProcess(object): def __repr__(self): return 'foo' process = FakeProcess() self.assertEqual( "process object = 'foo'", _s._get_search_criteria_string_representation(process=process) ) def test_all_parameters_combined(self): class FakeProcess(object): def __repr__(self): return 'foo' process = FakeProcess() observed = _s._get_search_criteria_string_representation( pid=123, dbus_bus='session_bus', connection_name='com.Canonical.Unity', object_path='/com/Canonical/Autopilot', application_name='MyApp', process=process ) expected_strings = [ "pid = 123", "dbus bus = 'session_bus'", "connection name = 'com.Canonical.Unity'", "object path = '/com/Canonical/Autopilot'", "application name = 'MyApp'", "process object = 'foo'", ] self.assertThat( observed, MatchesAll(*map(Contains, expected_strings)) ) class ProxyObjectTests(TestCase): def test_raise_if_not_single_result_raises_on_0_connections(self): criteria_string = self.getUniqueString() self.assertThat( lambda: _s._raise_if_not_single_result( [], criteria_string ), raises( ProcessSearchError( "Search criteria (%s) returned no results" % criteria_string ) ) ) def test_raise_if_not_single_result_raises_on_many_connections(self): criteria_string = self.getUniqueString() self.assertThat( lambda: _s._raise_if_not_single_result( [1, 2, 3, 4, 5], criteria_string ), raises( RuntimeError( "Search criteria (%s) returned multiple results" % criteria_string ) ) ) def test_raise_if_not_single_result_doesnt_raise_with_single_result(self): _s._raise_if_not_single_result([1], "") class FMCTest: def list_names(self): return ["conn1"] def test_find_matching_connections_calls_connection_matcher(self): bus = ProxyObjectTests.FMCTest() connection_matcher = Mock(return_value=False) with sleep.mocked(): _s._find_matching_connections(bus, connection_matcher) connection_matcher.assert_called_with((bus, "conn1")) def test_find_matching_connections_attempts_multiple_times(self): bus = ProxyObjectTests.FMCTest() connection_matcher = Mock(return_value=False) with sleep.mocked(): _s._find_matching_connections(bus, connection_matcher) connection_matcher.assert_called_with((bus, "conn1")) self.assertEqual(connection_matcher.call_count, 11) def test_find_matching_connections_dedupes_results_on_pid(self): bus = ProxyObjectTests.FMCTest() with patch.object(_s, '_dedupe_connections_on_pid') as dedupe: with sleep.mocked(): _s._find_matching_connections(bus, lambda *args: True) dedupe.assert_called_once_with(["conn1"], bus) class ActualBaseClassTests(TestCase): def test_dont_raise_passed_base_when_is_only_base(self): class ActualBase(CustomEmulatorBase): pass try: _s._raise_if_base_class_not_actually_base(ActualBase) except ValueError: self.fail('Unexpected ValueError exception') def test_raises_if_passed_incorrect_base_class(self): class ActualBase(CustomEmulatorBase): pass class InheritedCPO(ActualBase): pass self.assertRaises( ValueError, _s._raise_if_base_class_not_actually_base, InheritedCPO ) def test_raises_parent_with_simple_non_ap_multi_inheritance(self): """When mixing in non-customproxy classes must return the base.""" class ActualBase(CustomEmulatorBase): pass class InheritedCPO(ActualBase): pass class TrickyOne(object): pass class FinalForm(InheritedCPO, TrickyOne): pass self.assertRaises( ValueError, _s._raise_if_base_class_not_actually_base, FinalForm ) def test_raises_parent_with_non_ap_multi_inheritance(self): class ActualBase(CustomEmulatorBase): pass class InheritedCPO(ActualBase): pass class TrickyOne(object): pass class FinalForm(TrickyOne, InheritedCPO): pass self.assertRaises( ValueError, _s._raise_if_base_class_not_actually_base, FinalForm ) def test_dont_raise_when_using_default_emulator_base(self): # _make_proxy_object potentially creates a default base. DefaultBase = _s._make_default_emulator_base() try: _s._raise_if_base_class_not_actually_base(DefaultBase) except ValueError: self.fail('Unexpected ValueError exception') def test_exception_message_contains_useful_information(self): class ActualBase(CustomEmulatorBase): pass class InheritedCPO(ActualBase): pass try: _s._raise_if_base_class_not_actually_base(InheritedCPO) except ValueError as err: self.assertEqual( str(err), _s.WRONG_CPO_CLASS_MSG.format( passed=InheritedCPO, actual=ActualBase ) ) ./autopilot/tests/unit/test_pick_backend.py0000644000004100000410000001164614002063564021422 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from collections import OrderedDict from testtools import TestCase from testtools.matchers import raises, Equals, IsInstance from textwrap import dedent from autopilot.exceptions import BackendException from autopilot.utilities import _pick_backend class PickBackendTests(TestCase): def test_raises_runtime_error_on_empty_backends(self): """Must raise a RuntimeError when we pass no backends.""" fn = lambda: _pick_backend({}, '') self.assertThat( fn, raises(RuntimeError("Unable to instantiate any backends\n"))) def test_single_backend(self): """Must return a backend when called with a single backend.""" class Backend(object): pass _create_backend = lambda: Backend() backend = _pick_backend(dict(foo=_create_backend), '') self.assertThat(backend, IsInstance(Backend)) def test_first_backend(self): """Must return the first backend when called with a two backends.""" class Backend1(object): pass class Backend2(object): pass backend_dict = OrderedDict() backend_dict['be1'] = lambda: Backend1() backend_dict['be2'] = lambda: Backend2() backend = _pick_backend(backend_dict, '') self.assertThat(backend, IsInstance(Backend1)) def test_preferred_backend(self): """Must return the preferred backend when called with a two backends.""" class Backend1(object): pass class Backend2(object): pass backend_dict = OrderedDict() backend_dict['be1'] = lambda: Backend1() backend_dict['be2'] = lambda: Backend2() backend = _pick_backend(backend_dict, 'be2') self.assertThat(backend, IsInstance(Backend2)) def test_raises_backend_exception_on_preferred_backend(self): """Must raise a BackendException when the preferred backendcannot be created.""" class Backend1(object): pass class Backend2(object): def __init__(self): raise ValueError("Foo") backend_dict = OrderedDict() backend_dict['be1'] = lambda: Backend1() backend_dict['be2'] = lambda: Backend2() fn = lambda: _pick_backend(backend_dict, 'be2') self.assertThat(fn, raises(BackendException)) def test_raises_RuntimeError_on_invalid_preferred_backend(self): """Must raise RuntimeError when we pass a backend that's not there""" class Backend(object): pass _create_backend = lambda: Backend() fn = lambda: _pick_backend(dict(foo=_create_backend), 'bar') self.assertThat( fn, raises(RuntimeError("Unknown backend 'bar'")) ) def test_backend_exception_wraps_original_exception(self): """Raised backend Exception must wrap exception from backend.""" class Backend1(object): pass class Backend2(object): def __init__(self): raise ValueError("Foo") backend_dict = OrderedDict() backend_dict['be1'] = lambda: Backend1() backend_dict['be2'] = lambda: Backend2() raised = False try: _pick_backend(backend_dict, 'be2') except BackendException as e: raised = True self.assertTrue(hasattr(e, 'original_exception')) self.assertThat(e.original_exception, IsInstance(ValueError)) self.assertThat(str(e.original_exception), Equals("Foo")) self.assertTrue(raised) def test_failure_of_all_backends(self): """When we cannot create any backends, must raise RuntimeError.""" class BadBackend(object): def __init__(self): raise ValueError("Foo") backend_dict = OrderedDict() backend_dict['be1'] = lambda: BadBackend() backend_dict['be2'] = lambda: BadBackend() fn = lambda: _pick_backend(backend_dict, '') expected_exception = RuntimeError(dedent("""\ Unable to instantiate any backends be1: ValueError('Foo') be2: ValueError('Foo')""")) self.assertThat(fn, raises(expected_exception)) ./autopilot/tests/unit/test_introspection_backends.py0000644000004100000410000002777314002063564023567 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from dbus import String, DBusException from unittest.mock import patch, MagicMock, Mock from testtools import TestCase from testtools.matchers import Equals, Not, NotEquals, IsInstance from autopilot.introspection import ( _xpathselect as xpathselect, backends, dbus, ) class DBusAddressTests(TestCase): def test_can_construct(self): fake_bus = object() backends.DBusAddress(fake_bus, "conn", "path") def test_can_store_address_in_dictionary(self): fake_bus = object() backends.DBusAddress(fake_bus, "conn", "path") dict(addr=object()) def test_equality_operator(self): fake_bus = object() addr1 = backends.DBusAddress(fake_bus, "conn", "path") self.assertThat( addr1, Equals(backends.DBusAddress(fake_bus, "conn", "path")) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(fake_bus, "conn", "new_path")) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(fake_bus, "conn2", "path")) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(object(), "conn", "path")) ) def test_inequality_operator(self): fake_bus = object() addr1 = backends.DBusAddress(fake_bus, "conn", "path") self.assertThat( addr1, Not(NotEquals(backends.DBusAddress(fake_bus, "conn", "path"))) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(fake_bus, "conn", "new_path")) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(fake_bus, "conn2", "path")) ) self.assertThat( addr1, NotEquals(backends.DBusAddress(object(), "conn", "path")) ) def test_session_bus_construction(self): connection = self.getUniqueString() object_path = self.getUniqueString() with patch.object(backends, 'get_session_bus') as patch_sb: addr = backends.DBusAddress.SessionBus(connection, object_path) self.assertThat( addr._addr_tuple, Equals( backends.DBusAddress.AddrTuple( patch_sb.return_value, connection, object_path ) ) ) def test_system_bus_construction(self): connection = self.getUniqueString() object_path = self.getUniqueString() with patch.object(backends, 'get_system_bus') as patch_sb: addr = backends.DBusAddress.SystemBus(connection, object_path) self.assertThat( addr._addr_tuple, Equals( backends.DBusAddress.AddrTuple( patch_sb.return_value, connection, object_path ) ) ) def test_custom_bus_construction(self): connection = self.getUniqueString() object_path = self.getUniqueString() bus_path = self.getUniqueString() with patch.object(backends, 'get_custom_bus') as patch_cb: addr = backends.DBusAddress.CustomBus( bus_path, connection, object_path ) self.assertThat( addr._addr_tuple, Equals( backends.DBusAddress.AddrTuple( patch_cb.return_value, connection, object_path ) ) ) patch_cb.assert_called_once_with(bus_path) class ClientSideFilteringTests(TestCase): def get_empty_fake_object(self): return type( 'EmptyObject', (object,), {'no_automatic_refreshing': MagicMock()} ) def test_object_passes_filters_disables_refreshing(self): obj = self.get_empty_fake_object() backends._object_passes_filters(obj) obj.no_automatic_refreshing.assert_called_once_with() self.assertTrue( obj.no_automatic_refreshing.return_value.__enter__.called ) def test_object_passes_filters_works_with_no_filters(self): obj = self.get_empty_fake_object() self.assertTrue(backends._object_passes_filters(obj)) def test_object_passes_filters_fails_when_attr_missing(self): obj = self.get_empty_fake_object() self.assertFalse(backends._object_passes_filters(obj, foo=123)) def test_object_passes_filters_fails_when_attr_has_wrong_value(self): obj = self.get_empty_fake_object() obj.foo = 456 self.assertFalse(backends._object_passes_filters(obj, foo=123)) def test_object_passes_filters_succeeds_with_one_correct_parameter(self): obj = self.get_empty_fake_object() obj.foo = 123 self.assertTrue(backends._object_passes_filters(obj, foo=123)) class BackendTests(TestCase): @patch('autopilot.introspection.backends._logger') def test_large_query_returns_log_warnings(self, mock_logger): """Queries that return large numbers of items must cause a log warning. 'large' is defined as more than 15. """ query = xpathselect.Query.root('foo') fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.return_value = \ [(b'/root/path', {}) for i in range(16)] backend = backends.Backend(fake_dbus_address) backend.execute_query_get_data( query, ) mock_logger.warning.assert_called_once_with( "Your query '%r' returned a lot of data (%d items). This " "is likely to be slow. You may want to consider optimising" " your query to return fewer items.", query, 16) @patch('autopilot.introspection.backends._logger') def test_small_query_returns_dont_log_warnings(self, mock_logger): """Queries that return small numbers of items must not log a warning. 'small' is defined as 15 or fewer. """ query = xpathselect.Query.root('foo') fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.return_value = \ [(b'/root/path', {}) for i in range(15)] backend = backends.Backend(fake_dbus_address) backend.execute_query_get_data( query, ) self.assertThat(mock_logger.warning.called, Equals(False)) @patch.object(backends, 'make_introspection_object', return_value=None) def test_proxy_instances_returns_list(self, mio): query = xpathselect.Query.root('foo') fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.return_value = [ (b'/root/path', {}) for i in range(1) ] backend = backends.Backend(fake_dbus_address) self.assertThat( backend.execute_query_get_proxy_instances(query, 0), Equals([None]) ) @patch.object(backends, 'make_introspection_object', return_value=None) def test_proxy_instances_with_clientside_filtering_returns_list(self, mio): query = xpathselect.Query.root('foo') query.needs_client_side_filtering = Mock(return_value=True) fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.return_value = [ (b'/root/path', {}) for i in range(1) ] backend = backends.Backend(fake_dbus_address) with patch.object( backends, '_object_passes_filters', return_value=True): self.assertThat( backend.execute_query_get_proxy_instances(query, 0), Equals([None]) ) def test_proxy_instance_catches_unknown_service_exception(self): query = xpathselect.Query.root('foo') e = DBusException( name='org.freedesktop.DBus.Error.ServiceUnknown' ) fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.side_effect = e backend = backends.Backend(fake_dbus_address) self.assertRaises(RuntimeError, backend.execute_query_get_data, query) def test_unknown_service_exception_gives_correct_msg(self): query = xpathselect.Query.root('foo') e = DBusException( name='org.freedesktop.DBus.Error.ServiceUnknown' ) fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.side_effect = e backend = backends.Backend(fake_dbus_address) try: backend.execute_query_get_data(query) except RuntimeError as e: msg = ("Lost dbus backend communication. It appears the " "application under test exited before the test " "finished!") self.assertEqual(str(e), msg) def test_proxy_instance_raises_uncaught_dbus_exceptions(self): query = xpathselect.Query.root('foo') e = DBusException() fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.side_effect = e backend = backends.Backend(fake_dbus_address) self.assertRaises(DBusException, backend.execute_query_get_data, query) def test_proxy_instance_raises_uncaught_exceptions(self): query = xpathselect.Query.root('foo') e = Exception() fake_dbus_address = Mock() fake_dbus_address.introspection_iface.GetState.side_effect = e backend = backends.Backend(fake_dbus_address) self.assertRaises(Exception, backend.execute_query_get_data, query) class MakeIntrospectionObjectTests(TestCase): """Test selection of custom proxy object class.""" class DefaultSelector(dbus.CustomEmulatorBase): pass class AlwaysSelected(dbus.CustomEmulatorBase): @classmethod def validate_dbus_object(cls, path, state): """Validate always. :returns: True """ return True class NeverSelected(dbus.CustomEmulatorBase): @classmethod def validate_dbus_object(cls, path, state): """Validate never. :returns: False """ return False def test_class_has_validation_method(self): """Verify that a class has a validation method by default.""" self.assertTrue(callable(self.DefaultSelector.validate_dbus_object)) @patch.object(backends, '_get_proxy_object_class') def test_make_introspection_object(self, gpoc): """Verify that make_introspection_object makes the right call.""" gpoc.return_value = self.DefaultSelector fake_id = Mock() new_fake = backends.make_introspection_object( (String('/Object'), {'id': [0, 42]}), None, fake_id, ) self.assertThat(new_fake, IsInstance(self.DefaultSelector)) gpoc.assert_called_once_with( fake_id, b'/Object', {'id': [0, 42]} ) def test_validate_dbus_object_matches_on_class_name(self): """Validate_dbus_object must match class name.""" selected = self.DefaultSelector.validate_dbus_object( '/DefaultSelector', {}) self.assertTrue(selected) ./autopilot/tests/unit/test_exceptions.py0000644000004100000410000000614214002063567021204 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import raises, Equals, EndsWith from autopilot.exceptions import StateNotFoundError class StateNotFoundTests(TestCase): def test_requires_class_name_to_construct(self): """You must pass a class name in to the StateNotFoundError exception class initialiser in order to construct it. """ self.assertThat( StateNotFoundError, raises(ValueError("Must specify either class name or filters.")) ) def test_can_be_constructed_with_class_name_only(self): """Must be able to construct error class with a class name only.""" err = StateNotFoundError("MyClass") self.assertThat( str(err), Equals("Object not found with name 'MyClass'.\n\n{}".format( StateNotFoundError._troubleshoot_url_message )) ) def test_can_be_constructed_with_filters_only(self): """Must be able to construct exception with filters only.""" err = StateNotFoundError(foo="bar") self.assertThat( str(err), Equals( "Object not found with properties {}." "\n\n{}".format( "{'foo': 'bar'}", StateNotFoundError._troubleshoot_url_message )) ) def test_can_be_constructed_with_class_name_and_filters(self): """Must be able to construct with both class name and filters.""" err = StateNotFoundError('MyClass', foo="bar") self.assertThat( str(err), Equals("Object not found with name 'MyClass'" " and properties {}.\n\n{}".format( "{'foo': 'bar'}", StateNotFoundError._troubleshoot_url_message )) ) def test_StateNotFoundError_endswith_troubleshoot_url_message_text(self): """The assertion raised must end with a link to troubleshooting url.""" err = StateNotFoundError('MyClass', foo="bar") self.assertThat( str(err), EndsWith( 'Tips on minimizing the occurrence of this failure ' 'are available here: ' 'https://developer.ubuntu.com/api/autopilot/python/1.6.1/' 'faq-troubleshooting/' ) ) ./autopilot/tests/unit/test_debug.py0000644000004100000410000001541514002063564020111 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot import _debug as d from unittest.mock import Mock, patch from tempfile import NamedTemporaryFile from testtools import TestCase from testtools.matchers import ( Equals, Not, Raises, ) class CaseAddDetailToNormalAddDetailDecoratorTests(TestCase): def test_sets_decorated_ivar(self): fake_detailed = Mock() decorated = d.CaseAddDetailToNormalAddDetailDecorator(fake_detailed) self.assertThat(decorated.decorated, Equals(fake_detailed)) def test_addDetail_calls_caseAddDetail(self): fake_detailed = Mock() decorated = d.CaseAddDetailToNormalAddDetailDecorator(fake_detailed) content_name = self.getUniqueString() content_object = object() decorated.addDetail(content_name, content_object) fake_detailed.caseAddDetail.assert_called_once_with( content_name, content_object ) def test_all_other_attrs_are_passed_through(self): fake_detailed = Mock() decorated = d.CaseAddDetailToNormalAddDetailDecorator(fake_detailed) decorated.some_method() fake_detailed.some_method.assert_called_once_with() def test_repr(self): fake_detailed = Mock() decorated = d.CaseAddDetailToNormalAddDetailDecorator(fake_detailed) self.assertThat( repr(decorated), Equals( ''.format( repr(fake_detailed) ) ) ) class DebugProfileTests(TestCase): def setUp(self): super(DebugProfileTests, self).setUp() self.fake_caseAddDetail = Mock() def test_can_construct_debug_profile(self): d.DebugProfile(self.fake_caseAddDetail) def test_debug_profile_sets_caseAddDetail(self): profile = d.DebugProfile(self.fake_caseAddDetail) self.assertThat( profile.caseAddDetail, Equals(self.fake_caseAddDetail) ) def test_default_debug_profile_is_normal(self): self.assertThat( d.get_default_debug_profile().name, Equals("normal") ) def test_normal_profile_name(self): self.assertThat( d.NormalDebugProfile(self.fake_caseAddDetail).name, Equals("normal") ) def test_verbose_profile_name(self): self.assertThat( d.VerboseDebugProfile(self.fake_caseAddDetail).name, Equals("verbose") ) def test_all_profiles(self): self.assertThat( d.get_all_debug_profiles(), Equals({d.VerboseDebugProfile, d.NormalDebugProfile}) ) def test_debug_profile_uses_fixtures_in_setup(self): class DebugObjectDouble(d.DebugObject): init_called = False setup_called = False def __init__(self, *args, **kwargs): super(DebugObjectDouble, self).__init__(*args, **kwargs) DebugObjectDouble.init_called = True def setUp(self, *args, **kwargs): super(DebugObjectDouble, self).setUp(*args, **kwargs) DebugObjectDouble.setup_called = True class TestDebugProfile(d.DebugProfile): name = "test" def __init__(self, caseAddDetail): super(TestDebugProfile, self).__init__( caseAddDetail, [DebugObjectDouble] ) profile = TestDebugProfile(Mock()) profile.setUp() self.assertTrue(DebugObjectDouble.init_called) self.assertTrue(DebugObjectDouble.setup_called) class LogFollowerTests(TestCase): def setUp(self): super(LogFollowerTests, self).setUp() self.fake_caseAddDetail = Mock() def test_can_construct_log_debug_object(self): path = self.getUniqueString() log_debug_object = d.LogFileDebugObject( self.fake_caseAddDetail, path ) self.assertThat(log_debug_object.log_path, Equals(path)) def test_calls_follow_file_with_correct_parameters(self): path = self.getUniqueString() with patch.object(d, 'follow_file') as patched_follow_file: log_debug_object = d.LogFileDebugObject( self.fake_caseAddDetail, path ) log_debug_object.setUp() self.assertThat(patched_follow_file.call_count, Equals(1)) args, _ = patched_follow_file.call_args self.assertThat(args[0], Equals(path)) self.assertTrue(callable(getattr(args[1], 'addDetail', None))) self.assertTrue(callable(getattr(args[1], 'addCleanup', None))) def test_reads_new_file_lines(self): open_args = dict(buffering=0) with NamedTemporaryFile(**open_args) as temp_file: temp_file.write("Hello\n".encode()) log_debug_object = d.LogFileDebugObject( self.fake_caseAddDetail, temp_file.name ) log_debug_object.setUp() temp_file.write("World\n".encode()) log_debug_object.cleanUp() self.assertThat(self.fake_caseAddDetail.call_count, Equals(1)) args, _ = self.fake_caseAddDetail.call_args self.assertThat(args[0], Equals(temp_file.name)) self.assertThat(args[1].as_text(), Equals("World\n")) def test_can_follow_file_with_binary_content(self): open_args = dict(buffering=0) with NamedTemporaryFile(**open_args) as temp_file: log_debug_object = d.LogFileDebugObject( self.fake_caseAddDetail, temp_file.name ) log_debug_object.setUp() temp_file.write("Hello\x88World".encode()) log_debug_object.cleanUp() args, _ = self.fake_caseAddDetail.call_args self.assertThat(args[1].as_text, Not(Raises())) def test_can_create_syslog_follower(self): debug_obj = d.SyslogDebugObject(Mock()) self.assertThat(debug_obj.log_path, Equals("/var/log/syslog")) ./autopilot/tests/unit/test_query_resolution.py0000644000004100000410000000141314002063564022444 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # ./autopilot/tests/unit/test_utilities.py0000644000004100000410000003031314002063564021030 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import Mock, patch import re from testtools import TestCase from testtools.content import Content from testtools.matchers import ( Equals, IsInstance, LessThan, MatchesRegex, Not, raises, Raises, ) import timeit from autopilot.utilities import ( _raise_if_time_delta_not_sane, _raise_on_unknown_kwargs, _sleep_for_calculated_delta, cached_result, compatible_repr, deprecated, EventDelay, safe_text_content, sleep, ) class ElapsedTimeCounter(object): """A simple utility to count the amount of real time that passes.""" def __enter__(self): self._start_time = timeit.default_timer() return self def __exit__(self, *args): pass @property def elapsed_time(self): return timeit.default_timer() - self._start_time class MockableSleepTests(TestCase): def test_mocked_sleep_contextmanager(self): with ElapsedTimeCounter() as time_counter: with sleep.mocked(): sleep(10) self.assertThat(time_counter.elapsed_time, LessThan(2)) def test_mocked_sleep_methods(self): with ElapsedTimeCounter() as time_counter: sleep.enable_mock() self.addCleanup(sleep.disable_mock) sleep(10) self.assertThat(time_counter.elapsed_time, LessThan(2)) def test_total_time_slept_starts_at_zero(self): with sleep.mocked() as sleep_counter: self.assertThat(sleep_counter.total_time_slept(), Equals(0.0)) def test_total_time_slept_accumulates(self): with sleep.mocked() as sleep_counter: sleep(1) self.assertThat(sleep_counter.total_time_slept(), Equals(1.0)) sleep(0.5) self.assertThat(sleep_counter.total_time_slept(), Equals(1.5)) sleep(0.5) self.assertThat(sleep_counter.total_time_slept(), Equals(2.0)) def test_unmocked_sleep_calls_real_time_sleep_function(self): with patch('autopilot.utilities.time') as patched_time: sleep(1.0) patched_time.sleep.assert_called_once_with(1.0) class EventDelayTests(TestCase): def test_mocked_event_delayer_contextmanager(self): event_delayer = EventDelay() with event_delayer.mocked(): # The first call of delay() only stores the last time # stamp, it is only the second call where the delay # actually happens. So we call delay() twice here to # ensure mocking is working as expected. event_delayer.delay(duration=0) event_delayer.delay(duration=3) self.assertAlmostEqual(sleep.total_time_slept(), 3, places=1) def test_last_event_start_at_zero(self): event_delayer = EventDelay() self.assertThat(event_delayer.last_event_time(), Equals(0.0)) def test_last_event_delay_counter_updates_on_first_call(self): event_delayer = EventDelay() event_delayer.delay(duration=1.0, current_time=lambda: 10) self.assertThat(event_delayer._last_event, Equals(10.0)) def test_first_call_to_delay_causes_no_sleep(self): event_delayer = EventDelay() with sleep.mocked() as mocked_sleep: event_delayer.delay(duration=0.0) self.assertThat(mocked_sleep.total_time_slept(), Equals(0.0)) def test_second_call_to_delay_causes_sleep(self): event_delayer = EventDelay() with sleep.mocked() as mocked_sleep: event_delayer.delay(duration=0, current_time=lambda: 100) event_delayer.delay(duration=10, current_time=lambda: 105) self.assertThat(mocked_sleep.total_time_slept(), Equals(5.0)) def test_no_delay_if_time_jumps_since_last_event(self): event_delayer = EventDelay() with sleep.mocked() as mocked_sleep: event_delayer.delay(duration=2, current_time=lambda: 100) event_delayer.delay(duration=2, current_time=lambda: 110) self.assertThat(mocked_sleep.total_time_slept(), Equals(0.0)) def test_no_delay_if_given_delay_time_negative(self): event_delayer = EventDelay() with sleep.mocked() as mocked_sleep: event_delayer.delay(duration=-2, current_time=lambda: 100) event_delayer.delay(duration=-2, current_time=lambda: 101) self.assertThat(mocked_sleep.total_time_slept(), Equals(0.0)) def test_sleep_delta_calculator_returns_zero_if_time_delta_negative(self): result = _sleep_for_calculated_delta(100, 97, 2) self.assertThat(result, Equals(0.0)) def test_sleep_delta_calculator_doesnt_sleep_if_time_delta_negative(self): with sleep.mocked() as mocked_sleep: _sleep_for_calculated_delta(100, 97, 2) self.assertThat(mocked_sleep.total_time_slept(), Equals(0.0)) def test_sleep_delta_calculator_returns_zero_if_time_delta_zero(self): result = _sleep_for_calculated_delta(100, 98, 2) self.assertThat(result, Equals(0.0)) def test_sleep_delta_calculator_doesnt_sleep_if_time_delta_zero(self): with sleep.mocked() as mocked_sleep: _sleep_for_calculated_delta(100, 98, 2) self.assertThat(mocked_sleep.total_time_slept(), Equals(0.0)) def test_sleep_delta_calculator_returns_non_zero_if_delta_not_zero(self): with sleep.mocked(): result = _sleep_for_calculated_delta(101, 100, 2) self.assertThat(result, Equals(1.0)) def test_sleep_delta_calc_returns_zero_if_gap_duration_negative(self): result = _sleep_for_calculated_delta(100, 99, -2) self.assertEquals(result, 0.0) def test_sleep_delta_calc_raises_if_last_event_ahead_current_time(self): self.assertRaises( ValueError, _sleep_for_calculated_delta, current_time=100, last_event_time=110, gap_duration=2 ) def test_sleep_delta_calc_raises_if_last_event_equals_current_time(self): self.assertRaises( ValueError, _sleep_for_calculated_delta, current_time=100, last_event_time=100, gap_duration=2 ) def test_sleep_delta_calc_raises_if_current_time_negative(self): self.assertRaises( ValueError, _sleep_for_calculated_delta, current_time=-100, last_event_time=10, gap_duration=10 ) def test_time_sanity_checker_raises_if_time_smaller_than_last_event(self): self.assertRaises( ValueError, _raise_if_time_delta_not_sane, current_time=90, last_event_time=100 ) def test_time_sanity_checker_raises_if_time_equal_last_event_time(self): self.assertRaises( ValueError, _raise_if_time_delta_not_sane, current_time=100, last_event_time=100 ) def test_time_sanity_checker_raises_if_time_negative_last_event_not(self): self.assertRaises( ValueError, _raise_if_time_delta_not_sane, current_time=-100, last_event_time=100 ) class CompatibleReprTests(TestCase): def test_py3_unicode_is_untouched(self): repr_fn = compatible_repr(lambda: "unicode") result = repr_fn() self.assertThat(result, IsInstance(str)) self.assertThat(result, Equals('unicode')) def test_py3_bytes_are_returned_as_unicode(self): repr_fn = compatible_repr(lambda: b"bytes") result = repr_fn() self.assertThat(result, IsInstance(str)) self.assertThat(result, Equals('bytes')) class UnknownKWArgsTests(TestCase): def test_raise_if_not_empty_raises_on_nonempty_dict(self): populated_dict = dict(testing=True) self.assertThat( lambda: _raise_on_unknown_kwargs(populated_dict), raises(ValueError("Unknown keyword arguments: 'testing'.")) ) def test_raise_if_not_empty_does_not_raise_on_empty(self): empty_dict = dict() self.assertThat( lambda: _raise_on_unknown_kwargs(empty_dict), Not(Raises()) ) class DeprecatedDecoratorTests(TestCase): def test_deprecated_logs_warning(self): @deprecated('Testing') def not_testing(): pass with patch('autopilot.utilities.logger') as patched_log: not_testing() self.assertThat( patched_log.warning.call_args[0][0], MatchesRegex( "WARNING: in file \".*.py\", line \d+ in " "test_deprecated_logs_warning\nThis " "function is deprecated. Please use 'Testing' instead.\n", re.DOTALL ) ) class CachedResultTests(TestCase): def get_wrapped_mock_pair(self): inner = Mock() # Mock() under python 2 does not support __name__. When we drop py2 # support we can obviously delete this hack: return inner, cached_result(inner) def test_can_be_used_as_decorator(self): @cached_result def foo(): pass def test_adds_reset_cache_callable_to_function(self): @cached_result def foo(): pass self.assertTrue(hasattr(foo, 'reset_cache')) def test_retains_docstring(self): @cached_result def foo(): """xxXX super docstring XXxx""" pass self.assertThat(foo.__doc__, Equals("xxXX super docstring XXxx")) def test_call_passes_through_once(self): inner, wrapped = self.get_wrapped_mock_pair() wrapped() inner.assert_called_once_with() def test_call_passes_through_only_once(self): inner, wrapped = self.get_wrapped_mock_pair() wrapped() wrapped() inner.assert_called_once_with() def test_first_call_returns_actual_result(self): inner, wrapped = self.get_wrapped_mock_pair() self.assertThat( wrapped(), Equals(inner.return_value) ) def test_subsequent_calls_return_actual_results(self): inner, wrapped = self.get_wrapped_mock_pair() wrapped() self.assertThat( wrapped(), Equals(inner.return_value) ) def test_can_pass_hashable_arguments(self): inner, wrapped = self.get_wrapped_mock_pair() wrapped(1, True, 2.0, "Hello", tuple(), ) inner.assert_called_once_with(1, True, 2.0, "Hello", tuple()) def test_passing_kwargs_raises_TypeError(self): inner, wrapped = self.get_wrapped_mock_pair() self.assertThat( lambda: wrapped(foo='bar'), raises(TypeError) ) def test_passing_unhashable_args_raises_TypeError(self): inner, wrapped = self.get_wrapped_mock_pair() self.assertThat( lambda: wrapped([]), raises(TypeError) ) def test_resetting_cache_works(self): inner, wrapped = self.get_wrapped_mock_pair() wrapped() wrapped.reset_cache() wrapped() self.assertThat(inner.call_count, Equals(2)) class SafeTextContentTests(TestCase): def test_raises_TypeError_on_non_texttype(self): self.assertThat( lambda: safe_text_content(None), raises(TypeError) ) def test_returns_text_content_object(self): example_string = self.getUniqueString() content_obj = safe_text_content(example_string) self.assertTrue(isinstance(content_obj, Content)) ./autopilot/tests/unit/test_out_of_test_addcleanup.py0000644000004100000410000000302714002063564023531 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals from autopilot.utilities import addCleanup, on_test_started log = '' class AddCleanupTests(TestCase): def test_addCleanup_called_with_args_and_kwargs(self): """Test that out-of-test addClenaup works as expected, and is passed both args and kwargs. """ class InnerTest(TestCase): def write_to_log(self, *args, **kwargs): global log log = "Hello %r %r" % (args, kwargs) def test_foo(self): on_test_started(self) addCleanup(self.write_to_log, "arg1", 2, foo='bar') InnerTest('test_foo').run() self.assertThat(log, Equals("Hello ('arg1', 2) {'foo': 'bar'}")) ./autopilot/tests/unit/test_timeout.py0000644000004100000410000000613714002063564020512 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals, GreaterThan from autopilot.globals import ( get_default_timeout_period, get_long_timeout_period, set_default_timeout_period, set_long_timeout_period, ) from autopilot._timeout import Timeout from autopilot.utilities import sleep class TimeoutClassTests(TestCase): def setUp(self): super(TimeoutClassTests, self).setUp() # We need to ignore the settings the autopilot runner may set, # otherwise we cannot make any meaningful assertions in these tests. self.addCleanup( set_default_timeout_period, get_default_timeout_period() ) set_default_timeout_period(10.0) self.addCleanup( set_long_timeout_period, get_long_timeout_period() ) set_long_timeout_period(30.0) def test_medium_sleeps_for_correct_time(self): with sleep.mocked() as mocked_sleep: for _ in Timeout.default(): pass self.assertEqual(10.0, mocked_sleep.total_time_slept()) def test_long_sleeps_for_correct_time(self): with sleep.mocked() as mocked_sleep: for _ in Timeout.long(): pass self.assertEqual(30.0, mocked_sleep.total_time_slept()) def test_medium_elapsed_time_increases(self): with sleep.mocked(): last_elapsed = None for elapsed in Timeout.default(): if last_elapsed is not None: self.assertThat(elapsed, GreaterThan(last_elapsed)) else: self.assertEqual(elapsed, 0.0) last_elapsed = elapsed def test_long_elapsed_time_increases(self): with sleep.mocked(): last_elapsed = None for elapsed in Timeout.long(): if last_elapsed is not None: self.assertThat(elapsed, GreaterThan(last_elapsed)) else: self.assertEqual(elapsed, 0.0) last_elapsed = elapsed def test_medium_timeout_final_call(self): set_default_timeout_period(0.0) self.assertThat(len(list(Timeout.default())), Equals(1)) def test_long_timeout_final_call(self): set_long_timeout_period(0.0) self.assertThat(len(list(Timeout.long())), Equals(1)) ./autopilot/tests/unit/test_testcase.py0000644000004100000410000001162514002063564020635 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import Mock from testtools import TestCase from testtools.matchers import raises from autopilot.testcase import ( _compare_system_with_process_snapshot, _considered_failing_test, _get_application_launch_args, ) from autopilot.utilities import sleep class ProcessSnapshotTests(TestCase): def test_snapshot_returns_when_no_apps_running(self): with sleep.mocked() as mock_sleep: _compare_system_with_process_snapshot(lambda: [], []) self.assertEqual(0.0, mock_sleep.total_time_slept()) def test_snapshot_raises_AssertionError_with_new_apps_opened(self): with sleep.mocked(): fn = lambda: _compare_system_with_process_snapshot( lambda: ['foo'], [] ) self.assertThat(fn, raises(AssertionError( "The following apps were started during the test and " "not closed: ['foo']" ))) def test_bad_snapshot_waits_10_seconds(self): with sleep.mocked() as mock_sleep: try: _compare_system_with_process_snapshot( lambda: ['foo'], [] ) except: pass finally: self.assertEqual(10.0, mock_sleep.total_time_slept()) def test_snapshot_does_not_raise_on_closed_old_app(self): _compare_system_with_process_snapshot(lambda: [], ['foo']) def test_snapshot_exits_after_first_success(self): get_snapshot = Mock() get_snapshot.side_effect = [['foo'], []] with sleep.mocked() as mock_sleep: _compare_system_with_process_snapshot( get_snapshot, [] ) self.assertEqual(1.0, mock_sleep.total_time_slept()) class AutopilotTestCaseSupportFunctionTests(TestCase): def test_considered_failing_test_returns_true_for_failing(self): self.assertTrue(_considered_failing_test(AssertionError)) def test_considered_failing_test_returns_true_for_unexpected_success(self): from unittest.case import _UnexpectedSuccess self.assertTrue(_considered_failing_test(_UnexpectedSuccess)) def test_considered_failing_test_returns_false_for_skip(self): from unittest.case import SkipTest self.assertFalse(_considered_failing_test(SkipTest)) def test_considered_failing_test_returns_false_for_inherited_skip(self): from unittest.case import SkipTest class CustomSkip(SkipTest): pass self.assertFalse(_considered_failing_test(CustomSkip)) def test_considered_failing_test_returns_false_for_expected_fail(self): from testtools.testcase import _ExpectedFailure self.assertFalse(_considered_failing_test(_ExpectedFailure)) def test_considered_failing_test_returns_false_for_inherited_expected_fail(self): # NOQA from testtools.testcase import _ExpectedFailure class CustomExpected(_ExpectedFailure): pass self.assertFalse(_considered_failing_test(CustomExpected)) class AutopilotGetApplicationLaunchArgsTests(TestCase): def test_when_no_args_returns_empty_dict(self): self.assertEqual(_get_application_launch_args(dict()), dict()) def test_ignores_unknown_args(self): self.assertEqual(_get_application_launch_args(dict(unknown="")), {}) def test_gets_argument_values(self): app_type_value = self.getUniqueString() self.assertEqual( _get_application_launch_args(dict(app_type=app_type_value)), dict(app_type=app_type_value) ) def test_gets_argument_values_ignores_unknown_values(self): app_type_value = self.getUniqueString() kwargs = dict(app_type=app_type_value, unknown=self.getUniqueString()) self.assertEqual( _get_application_launch_args(kwargs), dict(app_type=app_type_value) ) def test_removes_used_arguments_from_parameter(self): app_type_value = self.getUniqueString() kwargs = dict(app_type=app_type_value) _get_application_launch_args(kwargs) self.assertEqual(kwargs, dict()) ./autopilot/tests/unit/__init__.py0000644000004100000410000000142014002063564017512 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # ./autopilot/tests/unit/test_introspection_utilities.py0000644000004100000410000001403714002063564024015 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2016 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from autopilot.introspection.dbus import raises from autopilot.introspection.utilities import process_util, sort_by_keys from autopilot.tests.unit.introspection_base import ( get_mock_object, get_global_rect, ) PROCESS_NAME = 'dummy_process' PROCESS_WITH_SINGLE_INSTANCE = [{'name': PROCESS_NAME, 'pid': -80}] PROCESS_WITH_MULTIPLE_INSTANCES = [ PROCESS_WITH_SINGLE_INSTANCE[0], {'name': PROCESS_NAME, 'pid': -81} ] # a list of dummy co-ordinates X_COORDS = [0, 0, 0, 0] Y_COORDS = [7, 9, 18, 14] class ProcessUtilitiesTestCase(TestCase): def test_passing_non_running_process_raises(self): self.assertRaises( ValueError, process_util._query_pids_for_process, PROCESS_NAME ) def test_passing_running_process_not_raises(self): with process_util.mocked(PROCESS_WITH_SINGLE_INSTANCE): self.assertFalse( raises( ValueError, process_util._query_pids_for_process, PROCESS_NAME ) ) def test_passing_integer_raises(self): self.assertRaises( ValueError, process_util._query_pids_for_process, 911 ) def test_pid_for_process_is_int(self): with process_util.mocked(PROCESS_WITH_SINGLE_INSTANCE): self.assertIsInstance( process_util.get_pid_for_process(PROCESS_NAME), int ) def test_pids_for_process_is_list(self): with process_util.mocked(PROCESS_WITH_MULTIPLE_INSTANCES): self.assertIsInstance( process_util.get_pids_for_process(PROCESS_NAME), list ) def test_passing_process_with_multiple_pids_raises(self): with process_util.mocked(PROCESS_WITH_MULTIPLE_INSTANCES): self.assertRaises( ValueError, process_util.get_pid_for_process, PROCESS_NAME ) class SortByKeysTests(TestCase): def _get_root_property_from_object_list(self, objects, prop): return [getattr(obj, prop) for obj in objects] def _get_child_property_from_object_list(self, objects, child, prop): return [getattr(getattr(obj, child), prop) for obj in objects] def test_sort_by_single_property(self): objects = [get_mock_object(y=y) for y in Y_COORDS] sorted_objects = sort_by_keys(objects, ['y']) self.assertEqual(len(sorted_objects), len(objects)) self.assertEqual( self._get_root_property_from_object_list(sorted_objects, 'y'), sorted(Y_COORDS) ) def test_sort_by_multiple_properties(self): objects = [ get_mock_object(x=x, y=y) for x, y in zip(X_COORDS, Y_COORDS) ] sorted_objects = sort_by_keys(objects, ['x', 'y']) self.assertEqual(len(sorted_objects), len(objects)) self.assertEqual( self._get_root_property_from_object_list(sorted_objects, 'x'), sorted(X_COORDS) ) self.assertEqual( self._get_root_property_from_object_list(sorted_objects, 'y'), sorted(Y_COORDS) ) def test_sort_by_single_nested_property(self): objects = [ get_mock_object(globalRect=get_global_rect(y=y)) for y in Y_COORDS ] sorted_objects = sort_by_keys(objects, ['globalRect.y']) self.assertEqual(len(sorted_objects), len(objects)) self.assertEqual( self._get_child_property_from_object_list( sorted_objects, child='globalRect', prop='y' ), sorted(Y_COORDS) ) def test_sort_by_multiple_nested_properties(self): objects = [ get_mock_object(globalRect=get_global_rect(x=x, y=y)) for x, y in zip(X_COORDS, Y_COORDS) ] sorted_objects = sort_by_keys( objects, ['globalRect.x', 'globalRect.y'] ) self.assertEqual(len(sorted_objects), len(objects)) self.assertEqual( self._get_child_property_from_object_list( sorted_objects, child='globalRect', prop='x' ), sorted(X_COORDS) ) self.assertEqual( self._get_child_property_from_object_list( sorted_objects, child='globalRect', prop='y' ), sorted(Y_COORDS) ) def test_sort_three_levels_nested_property(self): objects = [ get_mock_object( fake_property=get_global_rect( y=get_global_rect(y=y) ) ) for y in Y_COORDS ] sorted_objects = sort_by_keys(objects, ['fake_property.y.y']) self.assertEqual(len(sorted_objects), len(objects)) sorted_ys = [i.fake_property.y.y for i in sorted_objects] self.assertEqual(sorted_ys, sorted(Y_COORDS)) def test_raises_if_sort_keys_not_list(self): self.assertRaises(ValueError, sort_by_keys, None, 'y') def test_returns_unchanged_if_one_object(self): obj = [get_mock_object()] output = sort_by_keys(obj, ['x']) self.assertEqual(output, obj) ./autopilot/tests/unit/test_globals.py0000644000004100000410000000551214002063564020443 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals import autopilot.globals as _g def restore_value(cleanup_enabled, object, attr_name): """Ensure that, at the end of the current test, object.attr_name is restored to it's current state. """ original_value = getattr(object, attr_name) cleanup_enabled.addCleanup( lambda: setattr(object, attr_name, original_value) ) class DebugProfileFunctionTests(TestCase): def setUp(self): super(DebugProfileFunctionTests, self).setUp() # since we're modifying a global in our tests, make sure we restore # the original value after each test has run: restore_value(self, _g, '_debug_profile_fixture') def test_can_set_and_get_fixture(self): fake_fixture = object() _g.set_debug_profile_fixture(fake_fixture) self.assertThat(_g.get_debug_profile_fixture(), Equals(fake_fixture)) class TimeoutFunctionTests(TestCase): def setUp(self): super(TimeoutFunctionTests, self).setUp() # since we're modifying a global in our tests, make sure we restore # the original value after each test has run: restore_value(self, _g, '_default_timeout_value') restore_value(self, _g, '_long_timeout_value') restore_value(self, _g, '_test_timeout') def test_default_timeout_values(self): self.assertEqual(10.0, _g.get_default_timeout_period()) self.assertEqual(30.0, _g.get_long_timeout_period()) def test_can_set_default_timeout_value(self): new_value = self.getUniqueInteger() _g.set_default_timeout_period(new_value) self.assertEqual(new_value, _g.get_default_timeout_period()) def test_can_set_long_timeout_value(self): new_value = self.getUniqueInteger() _g.set_long_timeout_period(new_value) self.assertEqual(new_value, _g.get_long_timeout_period()) def test_can_set_test_timeout(self): new_value = self.getUniqueInteger() _g.set_test_timeout(new_value) self.assertEqual(new_value, _g.get_test_timeout()) ./autopilot/tests/unit/test_stagnate_state.py0000644000004100000410000000445514002063564022033 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals, raises from autopilot.utilities import StagnantStateDetector class StagnantCheckTests(TestCase): def test_state_change_resets_counter(self): state_check = StagnantStateDetector(threshold=5) x, y = (1, 1) for i in range(4): state_check.check_state(x, y) self.assertThat(state_check._stagnant_count, Equals(3)) state_check.check_state(10, 10) self.assertThat(state_check._stagnant_count, Equals(0)) def test_raises_exception_when_threshold_hit(self): state_check = StagnantStateDetector(threshold=1) x, y = (1, 1) state_check.check_state(x, y) fn = lambda: state_check.check_state(x, y) self.assertThat( fn, raises( StagnantStateDetector.StagnantState( "State has been the same for 1 iterations" ) ) ) def test_raises_exception_when_thresold_is_zero(self): fn = lambda: StagnantStateDetector(threshold=0) self.assertThat( fn, raises(ValueError("Threshold must be a positive integer.")) ) def test_passing_nonhashable_data_raises_exception(self): class UnHashable(object): __hash__ = None no_hash = UnHashable() state_check = StagnantStateDetector(threshold=5) fn = lambda: state_check.check_state(no_hash) self.assertThat(fn, raises(TypeError("unhashable type: 'UnHashable'"))) ./autopilot/tests/unit/test_test_loader.py0000644000004100000410000001756014002063564021333 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os import os.path import random import string from testtools import TestCase from testtools.matchers import Not, Raises from contextlib import contextmanager from unittest.mock import patch import shutil import tempfile from autopilot.run import ( _discover_test, get_package_location, load_test_suite_from_name ) @contextmanager def working_dir(directory): original_directory = os.getcwd() os.chdir(directory) try: yield finally: os.chdir(original_directory) class TestLoaderTests(TestCase): _previous_module_names = [] def setUp(self): super(TestLoaderTests, self).setUp() self.sandbox_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.sandbox_dir) self.test_module_name = self._unique_module_name() def _unique_module_name(self): generator = lambda: ''.join( random.choice(string.ascii_letters) for letter in range(8) ) name = generator() while name in self._previous_module_names: name = generator() self._previous_module_names.append(name) return name def create_empty_package_file(self, filename): full_filename = os.path.join(self.test_module_name, filename) with self.open_sandbox_file(full_filename) as f: f.write('') def create_package_file_with_contents(self, filename, contents): full_filename = os.path.join(self.test_module_name, filename) with self.open_sandbox_file(full_filename) as f: f.write(contents) @contextmanager def open_sandbox_file(self, relative_path): full_path = os.path.join(self.sandbox_dir, relative_path) dirname = os.path.dirname(full_path) if not os.path.exists(dirname): os.makedirs(dirname) with open(full_path, 'w') as f: yield f @contextmanager def simple_file_setup(self): with self.open_sandbox_file('test_foo.py') as f: f.write('') with working_dir(self.sandbox_dir): yield def test_get_package_location_can_import_file(self): with self.simple_file_setup(): self.assertThat( lambda: get_package_location('test_foo'), Not(Raises()) ) def test_get_package_location_returns_correct_directory(self): with self.simple_file_setup(): actual = get_package_location('test_foo') self.assertEqual(self.sandbox_dir, actual) def test_get_package_location_can_import_package(self): self.create_empty_package_file('__init__.py') with working_dir(self.sandbox_dir): self.assertThat( lambda: get_package_location(self.test_module_name), Not(Raises()), verbose=True ) def test_get_package_location_returns_correct_directory_for_package(self): self.create_empty_package_file('__init__.py') with working_dir(self.sandbox_dir): actual = get_package_location(self.test_module_name) self.assertEqual(self.sandbox_dir, actual) def test_get_package_location_can_import_nested_module(self): self.create_empty_package_file('__init__.py') self.create_empty_package_file('foo.py') with working_dir(self.sandbox_dir): self.assertThat( lambda: get_package_location('%s.foo' % self.test_module_name), Not(Raises()), verbose=True ) def test_get_package_location_returns_correct_directory_for_nested_module(self): # noqa self.create_empty_package_file('__init__.py') self.create_empty_package_file('foo.py') with working_dir(self.sandbox_dir): actual = get_package_location('%s.foo' % self.test_module_name) self.assertEqual(self.sandbox_dir, actual) @patch('autopilot.run._show_test_locations', new=lambda a: True) def test_load_test_suite_from_name_can_load_file(self): with self.open_sandbox_file('test_foo.py') as f: f.write(SIMPLE_TESTCASE) with working_dir(self.sandbox_dir): suite, _ = load_test_suite_from_name('test_foo') self.assertEqual(1, len(suite._tests)) @patch('autopilot.run._show_test_locations', new=lambda a: True) def test_load_test_suite_from_name_can_load_nested_module(self): self.create_empty_package_file('__init__.py') self.create_package_file_with_contents('test_foo.py', SIMPLE_TESTCASE) with working_dir(self.sandbox_dir): suite, _ = load_test_suite_from_name( '%s.test_foo' % self.test_module_name ) self.assertEqual(1, suite.countTestCases()) @patch('autopilot.run._show_test_locations', new=lambda a: True) def test_load_test_suite_from_name_only_loads_requested_suite(self): self.create_empty_package_file('__init__.py') self.create_package_file_with_contents('test_foo.py', SIMPLE_TESTCASE) self.create_package_file_with_contents('test_bar.py', SIMPLE_TESTCASE) with working_dir(self.sandbox_dir): suite, _ = load_test_suite_from_name( '%s.test_bar' % self.test_module_name ) self.assertEqual(1, suite.countTestCases()) @patch('autopilot.run._show_test_locations', new=lambda a: True) def test_load_test_suite_from_name_loads_requested_test_from_suite(self): self.create_empty_package_file('__init__.py') self.create_package_file_with_contents('test_foo.py', SAMPLE_TESTCASES) self.create_package_file_with_contents('test_bar.py', SAMPLE_TESTCASES) with working_dir(self.sandbox_dir): suite, _ = load_test_suite_from_name( '%s.test_bar.SampleTests.test_passes_again' % self.test_module_name ) self.assertEqual(1, suite.countTestCases()) @patch('autopilot.run._handle_discovery_error') @patch('autopilot.run._show_test_locations', new=lambda a: True) def test_loading_nonexistent_test_suite_doesnt_error(self, err_handler): self.assertThat( lambda: load_test_suite_from_name('nonexistent'), Not(Raises()) ) def test_loading_nonexistent_test_suite_indicates_error(self): self.assertRaises( ImportError, lambda: _discover_test('nonexistent') ) @patch('autopilot.run._reexecute_autopilot_using_module') @patch('autopilot.run._is_testing_autopilot_module', new=lambda *a: True) def test_testing_autopilot_is_redirected(self, patched_executor): patched_executor.return_value = 0 self.assertRaises( SystemExit, lambda: load_test_suite_from_name('autopilot') ) self.assertTrue(patched_executor.called) SIMPLE_TESTCASE = """\ from unittest import TestCase class SimpleTests(TestCase): def test_passes(self): self.assertEqual(1, 1) """ SAMPLE_TESTCASES = """\ from unittest import TestCase class SampleTests(TestCase): def test_passes(self): self.assertEqual(1, 1) def test_passes_again(self): self.assertEqual(1, 1) """ ./autopilot/tests/unit/test_command_line_args.py0000644000004100000410000003511014002063564022456 0ustar www-datawww-data#!/usr/bin/env python # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # "Unit tests for the command line parser in autopilot." from unittest.mock import patch from io import StringIO from testscenarios import WithScenarios from testtools import TestCase from testtools.matchers import Equals from autopilot.run import _parse_arguments class InvalidArguments(Exception): pass def parse_args(args): if isinstance(args, str): args = args.split() try: return _parse_arguments(args) except SystemExit as e: raise InvalidArguments("%s" % e) class CommandLineArgsTests(TestCase): def test_launch_command_accepts_application(self): args = parse_args("launch app") self.assertThat(args.mode, Equals("launch")) def test_launch_command_has_correct_default_interface(self): args = parse_args("launch app") self.assertThat(args.interface, Equals("Auto")) def test_launch_command_can_specify_Qt_interface(self): args = parse_args("launch -i Qt app") self.assertThat(args.interface, Equals("Qt")) def test_launch_command_can_specify_Gtk_interface(self): args = parse_args("launch -i Gtk app") self.assertThat(args.interface, Equals("Gtk")) @patch('sys.stderr', new=StringIO()) def test_launch_command_fails_on_unknown_interface(self): self.assertRaises( InvalidArguments, parse_args, "launch -i unknown app") def test_launch_command_has_correct_default_verbosity(self): args = parse_args("launch app") self.assertThat(args.verbose, Equals(False)) def test_launch_command_can_specify_verbosity(self): args = parse_args("launch -v app") self.assertThat(args.verbose, Equals(1)) def test_launch_command_can_specify_extra_verbosity(self): args = parse_args("launch -vv app") self.assertThat(args.verbose, Equals(2)) args = parse_args("launch -v -v app") self.assertThat(args.verbose, Equals(2)) def test_launch_command_stores_application(self): args = parse_args("launch app") self.assertThat(args.application, Equals(["app"])) def test_launch_command_stores_application_with_args(self): args = parse_args("launch app arg1 arg2") self.assertThat(args.application, Equals(["app", "arg1", "arg2"])) def test_launch_command_accepts_different_app_arg_formats(self): args = parse_args("launch app -s --long --key=val arg1 arg2") self.assertThat( args.application, Equals(["app", "-s", "--long", "--key=val", "arg1", "arg2"])) @patch('sys.stderr', new=StringIO()) def test_launch_command_must_specify_app(self): self.assertRaises(InvalidArguments, parse_args, "launch") @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_present_when_vis_module_installed(self): args = parse_args('vis') self.assertThat(args.mode, Equals("vis")) @patch('autopilot.run.have_vis', new=lambda: False) @patch('sys.stderr', new=StringIO()) def test_vis_not_present_when_vis_module_not_installed(self): self.assertRaises(InvalidArguments, parse_args, 'vis') @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_default_verbosity(self): args = parse_args('vis') self.assertThat(args.verbose, Equals(False)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_single_verbosity(self): args = parse_args('vis -v') self.assertThat(args.verbose, Equals(1)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_double_verbosity(self): args = parse_args('vis -vv') self.assertThat(args.verbose, Equals(2)) args = parse_args('vis -v -v') self.assertThat(args.verbose, Equals(2)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_default_testability_flag(self): args = parse_args('vis') self.assertThat(args.testability, Equals(False)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_can_set_testability_flag(self): args = parse_args('vis -testability') self.assertThat(args.testability, Equals(True)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_default_profile_flag(self): args = parse_args('vis') self.assertThat(args.enable_profile, Equals(False)) @patch('autopilot.run.have_vis', new=lambda: True) def test_vis_can_enable_profiling(self): args = parse_args('vis --enable-profile') self.assertThat(args.enable_profile, Equals(True)) def test_list_mode(self): args = parse_args('list foo') self.assertThat(args.mode, Equals("list")) def test_list_mode_accepts_suite_name(self): args = parse_args('list foo') self.assertThat(args.suite, Equals(["foo"])) def test_list_mode_accepts_many_suite_names(self): args = parse_args('list foo bar baz') self.assertThat(args.suite, Equals(["foo", "bar", "baz"])) def test_list_run_order_long_option(self): args = parse_args('list --run-order foo') self.assertThat(args.run_order, Equals(True)) def test_list_run_order_short_option(self): args = parse_args('list -ro foo') self.assertThat(args.run_order, Equals(True)) def test_list_no_run_order(self): args = parse_args('list foo') self.assertThat(args.run_order, Equals(False)) def test_list_suites_option(self): args = parse_args('list --suites foo') self.assertThat(args.suites, Equals(True)) def test_list_not_suites_option(self): args = parse_args('list foo') self.assertThat(args.suites, Equals(False)) def test_run_mode(self): args = parse_args('run foo') self.assertThat(args.mode, Equals("run")) def test_run_mode_accepts_suite_name(self): args = parse_args('run foo') self.assertThat(args.suite, Equals(["foo"])) def test_run_mode_accepts_many_suite_names(self): args = parse_args('run foo bar baz') self.assertThat(args.suite, Equals(["foo", "bar", "baz"])) def test_run_command_default_output(self): args = parse_args('run foo') self.assertThat(args.output, Equals(None)) def test_run_command_path_output_short(self): args = parse_args('run -o /path/to/file foo') self.assertThat(args.output, Equals("/path/to/file")) def test_run_command_path_output_long(self): args = parse_args('run --output ../file foo') self.assertThat(args.output, Equals("../file")) def test_run_command_default_format(self): args = parse_args('run foo') self.assertThat(args.format, Equals("text")) def test_run_command_text_format_short_version(self): args = parse_args('run -f text foo') self.assertThat(args.format, Equals("text")) def test_run_command_text_format_long_version(self): args = parse_args('run --format text foo') self.assertThat(args.format, Equals("text")) def test_run_command_xml_format_short_version(self): args = parse_args('run -f xml foo') self.assertThat(args.format, Equals("xml")) def test_run_command_xml_format_long_version(self): args = parse_args('run --format xml foo') self.assertThat(args.format, Equals("xml")) def test_run_command_default_failfast_off(self): args = parse_args('run foo') self.assertThat(args.failfast, Equals(False)) def test_run_command_accepts_failfast_short(self): args = parse_args('run -ff foo') self.assertThat(args.failfast, Equals(True)) def test_run_command_accepts_failfast_long(self): args = parse_args('run --failfast foo') self.assertThat(args.failfast, Equals(True)) @patch('sys.stderr', new=StringIO()) def test_run_command_unknown_format_short_version(self): self.assertRaises( InvalidArguments, parse_args, 'run -f unknown foo') @patch('sys.stderr', new=StringIO()) def test_run_command_unknown_format_long_version(self): self.assertRaises( InvalidArguments, parse_args, 'run --format unknown foo') def test_run_command_record_flag_default(self): args = parse_args("run foo") self.assertThat(args.record, Equals(False)) def test_run_command_record_flag_short(self): args = parse_args("run -r foo") self.assertThat(args.record, Equals(True)) def test_run_command_record_flag_long(self): args = parse_args("run --record foo") self.assertThat(args.record, Equals(True)) def test_run_command_record_dir_flag_short(self): args = parse_args("run -rd /path/to/dir foo") self.assertThat(args.record_directory, Equals("/path/to/dir")) def test_run_command_record_dir_flag_long(self): args = parse_args("run --record-directory /path/to/dir foo") self.assertThat(args.record_directory, Equals("/path/to/dir")) def test_run_command_record_options_flag_long(self): args = parse_args( "run --record-options=--fps=6,--no-wm-check foo") self.assertThat(args.record_options, Equals("--fps=6,--no-wm-check")) def test_run_command_random_order_flag_short(self): args = parse_args("run -ro foo") self.assertThat(args.random_order, Equals(True)) def test_run_command_random_order_flag_long(self): args = parse_args("run --random-order foo") self.assertThat(args.random_order, Equals(True)) def test_run_command_random_order_flag_default(self): args = parse_args("run foo") self.assertThat(args.random_order, Equals(False)) def test_run_default_verbosity(self): args = parse_args('run foo') self.assertThat(args.verbose, Equals(False)) def test_run_single_verbosity(self): args = parse_args('run -v foo') self.assertThat(args.verbose, Equals(1)) def test_run_double_verbosity(self): args = parse_args('run -vv foo') self.assertThat(args.verbose, Equals(2)) args = parse_args('run -v -v foo') self.assertThat(args.verbose, Equals(2)) def test_fails_with_no_arguments_supplied(self): with patch('sys.stderr', new=StringIO()) as patched_err: try: _parse_arguments([]) except SystemExit as e: self.assertThat(e.code, Equals(2)) stderr_lines = patched_err.getvalue().split('\n') self.assertTrue( stderr_lines[-2].endswith("error: too few arguments") ) self.assertThat(stderr_lines[-1], Equals("")) else: self.fail("Argument parser unexpectedly passed") def test_default_debug_profile_is_normal(self): args = parse_args('run foo') self.assertThat(args.debug_profile, Equals('normal')) def test_can_select_normal_profile(self): args = parse_args('run --debug-profile normal foo') self.assertThat(args.debug_profile, Equals('normal')) def test_can_select_verbose_profile(self): args = parse_args('run --debug-profile verbose foo') self.assertThat(args.debug_profile, Equals('verbose')) @patch('sys.stderr', new=StringIO()) def test_cannot_select_other_debug_profile(self): self.assertRaises( InvalidArguments, parse_args, 'run --debug-profile nonexistant foo' ) def test_default_timeout_profile_is_normal(self): args = parse_args('run foo') self.assertThat(args.timeout_profile, Equals('normal')) def test_can_select_long_timeout_profile(self): args = parse_args('run --timeout-profile long foo') self.assertThat(args.timeout_profile, Equals('long')) @patch('sys.stderr', new=StringIO()) def test_cannot_select_other_timeout_profile(self): self.assertRaises( InvalidArguments, parse_args, 'run --timeout-profile nonexistant foo' ) def test_list_mode_strips_single_suite_slash(self): args = parse_args('list foo/') self.assertThat(args.suite, Equals(["foo"])) def test_list_mode_strips_multiple_suite_slash(self): args = parse_args('list foo/ bar/') self.assertThat(args.suite, Equals(["foo", "bar"])) def test_run_mode_strips_single_suite_slash(self): args = parse_args('run foo/') self.assertThat(args.suite, Equals(["foo"])) def test_run_mode_strips_multiple_suite_slash(self): args = parse_args('run foo/ bar/') self.assertThat(args.suite, Equals(["foo", "bar"])) def test_accepts_config_string(self): args = parse_args('run --config foo test_id') self.assertThat(args.test_config, Equals('foo')) def test_accepts_long_config_string(self): args = parse_args('run --config bar=foo,baz test_id') self.assertThat(args.test_config, Equals('bar=foo,baz')) def test_default_config_string(self): args = parse_args('run foo') self.assertThat(args.test_config, Equals("")) def test_default_test_timeout(self): args = parse_args('run foo') self.assertThat(args.test_timeout, Equals(0)) def test_can_set_test_timeout(self): args = parse_args('run --test-timeout 42 foo') self.assertThat(args.test_timeout, Equals(42)) class GlobalProfileOptionTests(WithScenarios, TestCase): scenarios = [ ('run', dict(command='run', args='foo')), ('list', dict(command='list', args='foo')), ('launch', dict(command='launch', args='foo')), ('vis', dict(command='vis', args='')), ] @patch('autopilot.run.have_vis', new=lambda: True) def test_all_commands_support_profile_option(self): command_parts = [self.command, '--enable-profile'] if self.args: command_parts.append(self.args) args = parse_args(' '.join(command_parts)) self.assertThat(args.enable_profile, Equals(True)) ./autopilot/tests/unit/test_fixtures.py0000644000004100000410000000617014002063564020672 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import raises from unittest.mock import patch, Mock import autopilot._fixtures as ap_fixtures class FixtureWithDirectAddDetailTests(TestCase): def test_sets_caseAddDetail_method(self): fixture = ap_fixtures.FixtureWithDirectAddDetail(self.addDetail) self.assertEqual(fixture.caseAddDetail, self.addDetail) def test_can_construct_without_arguments(self): fixture = ap_fixtures.FixtureWithDirectAddDetail() self.assertEqual(fixture.caseAddDetail, fixture.addDetail) class GSettingsAccessTests(TestCase): def test_incorrect_schema_raises_exception(self): self.assertThat( lambda: ap_fixtures._gsetting_get_setting('com.foo.', 'baz'), raises(ValueError) ) def test_incorrect_key_raises_exception(self): self.assertThat( lambda: ap_fixtures._gsetting_get_setting( 'org.gnome.system.locale', 'baz' ), raises(ValueError) ) def test_get_value_returns_expected_value(self): with patch.object(ap_fixtures, '_gsetting_get_setting') as get_setting: setting = Mock() setting.get_boolean.return_value = True get_setting.return_value = setting self.assertEqual( ap_fixtures.get_bool_gsettings_value('foo', 'bar'), True ) class OSKAlwaysEnabledTests(TestCase): @patch.object(ap_fixtures, '_gsetting_get_setting') def test_sets_stayhidden_to_False(self, gs): with patch.object(ap_fixtures, 'set_bool_gsettings_value') as set_gs: with ap_fixtures.OSKAlwaysEnabled(): set_gs.assert_called_once_with( 'com.canonical.keyboard.maliit', 'stay-hidden', False ) def test_resets_value_to_original(self): with patch.object(ap_fixtures, 'set_bool_gsettings_value') as set_gs: with patch.object(ap_fixtures, 'get_bool_gsettings_value') as get_gs: # NOQA get_gs.return_value = 'foo' with ap_fixtures.OSKAlwaysEnabled(): pass set_gs.assert_called_with( 'com.canonical.keyboard.maliit', 'stay-hidden', 'foo' ) ./autopilot/tests/unit/test_display_screenshot.py0000644000004100000410000001276714002063564022734 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Unit tests for the display screenshot functionality.""" import subprocess import tempfile import os from contextlib import contextmanager from tempfile import NamedTemporaryFile from testtools import TestCase, skipIf from testtools.matchers import ( Equals, FileExists, MatchesRegex, Not, StartsWith, raises, ) from unittest.mock import Mock, patch import autopilot.display._screenshot as _ss from autopilot import platform class ScreenShotTests(TestCase): def test_get_screenshot_data_raises_RuntimeError_on_unknown_display(self): self.assertRaises(RuntimeError, lambda: _ss.get_screenshot_data("")) class X11ScreenShotTests(TestCase): def get_pixbuf_that_mocks_saving(self, success, data): pixbuf_obj = Mock() pixbuf_obj.save_to_bufferv.return_value = (success, data) return pixbuf_obj @skipIf(platform.model() != "Desktop", "Only available on desktop.") def test_save_gdk_pixbuf_to_fileobject_raises_error_if_save_failed(self): pixbuf_obj = self.get_pixbuf_that_mocks_saving(False, None) with patch.object(_ss, 'logger') as p_log: self.assertRaises( RuntimeError, lambda: _ss._save_gdk_pixbuf_to_fileobject(pixbuf_obj) ) p_log.error.assert_called_once_with("Unable to write image data.") def test_save_gdk_pixbuf_to_fileobject_returns_data_object(self): expected_data = b"Tests Rock" pixbuf_obj = self.get_pixbuf_that_mocks_saving(True, expected_data) data_object = _ss._save_gdk_pixbuf_to_fileobject(pixbuf_obj) self.assertThat(data_object, Not(Equals(None))) self.assertEqual(data_object.tell(), 0) self.assertEqual(data_object.getvalue(), expected_data) class MirScreenShotTests(TestCase): def test_take_screenshot_raises_when_binary_not_available(self): with patch.object(_ss.subprocess, 'check_call') as check_call: check_call.side_effect = FileNotFoundError() self.assertThat( _ss._take_mirscreencast_screenshot, raises( FileNotFoundError( "The utility 'mirscreencast' is not available." ) ) ) def test_take_screenshot_raises_when_screenshot_fails(self): with patch.object(_ss.subprocess, 'check_call') as check_call: check_call.side_effect = subprocess.CalledProcessError(None, None) self.assertThat( _ss._take_mirscreencast_screenshot, raises( subprocess.CalledProcessError( None, None, "Failed to take screenshot." ) ) ) def test_take_screenshot_returns_resulting_filename(self): with patch.object(_ss.subprocess, 'check_call'): self.assertThat( _ss._take_mirscreencast_screenshot(), MatchesRegex(".*ap-screenshot-data-\d+.rgba") ) def test_take_screenshot_filepath_is_in_tmp_dir(self): with patch.object(_ss.subprocess, 'check_call'): self.assertThat( _ss._take_mirscreencast_screenshot(), StartsWith(tempfile.gettempdir()) ) def test_image_data_from_file_returns_PIL_Image(self): with _single_pixel_rgba_data_file() as filepath: image_data = _ss._image_data_from_file(filepath, (1, 1)) self.assertEqual(image_data.mode, "RGBA") self.assertEqual(image_data.size, (1, 1)) def test_get_png_from_rgba_file_returns_png_file(self): with _single_pixel_rgba_data_file() as filepath: png_image_data = _ss._get_png_from_rgba_file(filepath, (1, 1)) self.assertEqual(0, png_image_data.tell()) self.assertThat(png_image_data.read(), StartsWith(b'\x89PNG\r\n')) @skipIf(platform.model() == "Desktop", "Only available on device.") def test_raw_data_file_cleaned_up_on_failure(self): """Creation of image will fail with a nonsense filepath.""" with _simulate_bad_rgba_image_file() as image_file_path: self.assertRaises(ValueError, _ss._get_screenshot_mir) self.assertThat(image_file_path, Not(FileExists())) @contextmanager def _simulate_bad_rgba_image_file(): try: with NamedTemporaryFile(delete=False) as f: with patch.object(_ss, "_take_mirscreencast_screenshot") as mir_ss: mir_ss.return_value = f.name yield f.name finally: if os.path.exists(f.name): os.remove(f.name) @contextmanager def _single_pixel_rgba_data_file(): with NamedTemporaryFile() as f: f.write(b'<\x1e#\xff') f.seek(0) yield f.name ./autopilot/tests/unit/test_introspection_xpathselect.py0000644000004100000410000003413214002063564024324 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testscenarios import TestWithScenarios from testtools import TestCase from testtools.matchers import raises from autopilot.introspection import _xpathselect as xpathselect from autopilot.exceptions import InvalidXPathQuery class XPathSelectQueryTests(TestCase): def test_query_raises_TypeError_on_non_bytes_query(self): fn = lambda: xpathselect.Query( None, xpathselect.Query.Operation.CHILD, 'asd' ) self.assertThat( fn, raises( TypeError( "'query' parameter must be bytes, not %s" % type('').__name__ ) ) ) def test_can_create_root_query(self): q = xpathselect.Query.root(b'Foo') self.assertEqual(b"/Foo", q.server_query_bytes()) def test_can_create_app_name_from_ascii_string(self): q = xpathselect.Query.root('Foo') self.assertEqual(b"/Foo", q.server_query_bytes()) def test_creating_root_query_with_unicode_app_name_raises(self): self.assertThat( lambda: xpathselect.Query.root("\u2026"), raises( InvalidXPathQuery( "Type name '%s', must be ASCII encodable" % ('\u2026') ) ) ) def test_repr_with_path(self): path = b"/some/path" q = xpathselect.Query.root('some').select_child('path') self.assertEqual("Query(%r)" % path, repr(q)) def test_repr_with_path_and_filters(self): expected = b"/some/path[bar=456,foo=123]" filters = dict(foo=123, bar=456) q = xpathselect.Query.root('some').select_child('path', filters) self.assertEqual("Query(%r)" % expected, repr(q)) def test_select_child(self): q = xpathselect.Query.root("Foo").select_child("Bar") self.assertEqual(q.server_query_bytes(), b"/Foo/Bar") def test_select_child_with_filters(self): q = xpathselect.Query.root("Foo")\ .select_child("Bar", dict(visible=True)) self.assertEqual(q.server_query_bytes(), b"/Foo/Bar[visible=True]") def test_many_select_children(self): q = xpathselect.Query.root("Foo") \ .select_child("Bar") \ .select_child("Baz") self.assertEqual(b"/Foo/Bar/Baz", q.server_query_bytes()) def test_many_select_children_with_filters(self): q = xpathselect.Query.root("Foo") \ .select_child("Bar", dict(visible=True)) \ .select_child("Baz", dict(id=123)) self.assertEqual( b"/Foo/Bar[visible=True]/Baz[id=123]", q.server_query_bytes() ) def test_select_descendant(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar") self.assertEqual(b"/Foo//Bar", q.server_query_bytes()) def test_select_descendant_with_filters(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar", dict(name="Hello")) self.assertEqual(b'/Foo//Bar[name="Hello"]', q.server_query_bytes()) def test_many_select_descendants(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar") \ .select_descendant("Baz") self.assertEqual(b"/Foo//Bar//Baz", q.server_query_bytes()) def test_many_select_descendants_with_filters(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar", dict(visible=True)) \ .select_descendant("Baz", dict(id=123)) self.assertEqual( b"/Foo//Bar[visible=True]//Baz[id=123]", q.server_query_bytes() ) def test_full_server_side_filter(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar", dict(visible=True)) \ .select_descendant("Baz", dict(id=123)) self.assertFalse(q.needs_client_side_filtering()) def test_client_side_filter(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar", dict(visible=True)) \ .select_descendant("Baz", dict(name="\u2026")) self.assertTrue(q.needs_client_side_filtering()) def test_client_side_filter_all_query_bytes(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Bar", dict(visible=True)) \ .select_descendant("Baz", dict(name="\u2026")) self.assertEqual( b'/Foo//Bar[visible=True]//Baz', q.server_query_bytes() ) def test_deriving_from_client_side_filtered_query_raises_ValueError(self): q = xpathselect.Query.root("Foo") \ .select_descendant("Baz", dict(name="\u2026")) fn = lambda: q.select_child("Foo") self.assertThat( fn, raises(InvalidXPathQuery( "Cannot create a new query from a parent that requires " "client-side filter processing." )) ) def test_init_raises_TypeError_on_invalid_operation_type(self): fn = lambda: xpathselect.Query(None, '/', b'sdf') self.assertThat( fn, raises(TypeError( "'operation' parameter must be bytes, not '%s'" % type('').__name__ )) ) def test_init_raises_ValueError_on_invalid_operation(self): fn = lambda: xpathselect.Query(None, b'foo', b'sdf') self.assertThat( fn, raises(InvalidXPathQuery("Invalid operation 'foo'.")) ) def test_init_raises_ValueError_on_invalid_descendant_search(self): fn = lambda: xpathselect.Query(None, b'//', b'*') self.assertThat( fn, raises(InvalidXPathQuery( "Must provide at least one server-side filter when searching " "for descendants and using a wildcard node." )) ) def test_new_from_path_and_id_raises_TypeError_on_unicode_path(self): fn = lambda: xpathselect.Query.new_from_path_and_id('bad_path', 42) self.assertThat( fn, raises(TypeError( "'path' attribute must be bytes, not '%s'" % type('').__name__ )) ) def test_new_from_path_and_id_raises_ValueError_on_invalid_path(self): fn = lambda: xpathselect.Query.new_from_path_and_id(b'bad_path', 42) self.assertThat( fn, raises(InvalidXPathQuery("Invalid path 'bad_path'.")) ) def test_new_from_path_and_id_raises_ValueError_on_invalid_path2(self): fn = lambda: xpathselect.Query.new_from_path_and_id(b'/', 42) self.assertThat( fn, raises(InvalidXPathQuery("Invalid path '/'.")) ) def test_new_from_path_and_id_works_for_root_node(self): q = xpathselect.Query.new_from_path_and_id(b'/root', 42) self.assertEqual(b'/root', q.server_query_bytes()) def test_new_from_path_and_id_works_for_small_tree(self): q = xpathselect.Query.new_from_path_and_id(b'/root/child', 42) self.assertEqual(b'/root/child[id=42]', q.server_query_bytes()) def test_new_from_path_and_id_works_for_larger_tree(self): q = xpathselect.Query.new_from_path_and_id(b'/root/child/leaf', 42) self.assertEqual(b'/root/child/leaf[id=42]', q.server_query_bytes()) def test_get_client_side_filters_returns_client_side_filters(self): q = xpathselect.Query.root('app') \ .select_child('leaf', dict(name='\u2026')) self.assertEqual(dict(name='\u2026'), q.get_client_side_filters()) def test_get_parent_on_root_node_returns_the_same_query(self): q = xpathselect.Query.root('app') q2 = q.select_parent() self.assertEqual(b'/app/..', q2.server_query_bytes()) def test_get_parent_on_node_returns_parent_query(self): q = xpathselect.Query.new_from_path_and_id(b'/root/child', 42) q2 = q.select_parent() self.assertEqual(b'/root/child[id=42]/..', q2.server_query_bytes()) def test_init_raises_ValueError_when_passing_filters_and_parent(self): fn = lambda: xpathselect.Query(None, b'/', b'..', dict(foo=123)) self.assertThat( fn, raises(InvalidXPathQuery( "Cannot specify filters while selecting a parent" )) ) def test_init_raises_ValueError_when_passing_bad_op_and_parent(self): fn = lambda: xpathselect.Query(None, b'//', b'..') self.assertThat( fn, raises(InvalidXPathQuery( "Operation must be CHILD while selecting a parent" )) ) def test_select_tree_root_returns_correct_query(self): q = xpathselect.Query.pseudo_tree_root() self.assertEqual(b'/', q.server_query_bytes()) def test_cannot_select_child_on_pseudo_tree_root(self): fn = lambda: xpathselect.Query.pseudo_tree_root().select_child('foo') self.assertThat( fn, raises(InvalidXPathQuery( "Cannot select children from a pseudo-tree-root query." )) ) def test_whole_tree_search_returns_correct_query(self): q = xpathselect.Query.whole_tree_search('Foo') self.assertEqual(b'//Foo', q.server_query_bytes()) def test_whole_tree_search_with_filters_returns_correct_query(self): q = xpathselect.Query.whole_tree_search('Foo', dict(foo='bar')) self.assertEqual(b'//Foo[foo="bar"]', q.server_query_bytes()) class ParameterFilterStringScenariodTests(TestWithScenarios, TestCase): scenarios = [ ('bool true', dict(k='visible', v=True, r=b"visible=True")), ('bool false', dict(k='visible', v=False, r=b"visible=False")), ('int +ve', dict(k='size', v=123, r=b"size=123")), ('int -ve', dict(k='prio', v=-12, r=b"prio=-12")), ('simple string', dict(k='Name', v="btn1", r=b"Name=\"btn1\"")), ('simple bytes', dict(k='Name', v=b"btn1", r=b"Name=\"btn1\"")), ('string space', dict(k='Name', v="a b c ", r=b"Name=\"a b c \"")), ('bytes space', dict(k='Name', v=b"a b c ", r=b"Name=\"a b c \"")), ('string escapes', dict( k='a', v="\a\b\f\n\r\t\v\\", r=br'a="\x07\x08\x0c\n\r\t\x0b\\"')), ('byte escapes', dict( k='a', v=b"\a\b\f\n\r\t\v\\", r=br'a="\x07\x08\x0c\n\r\t\x0b\\"')), ('escape quotes (str)', dict(k='b', v="'", r=b'b="\\' + b"'" + b'"')), ( 'escape quotes (bytes)', dict(k='b', v=b"'", r=b'b="\\' + b"'" + b'"') ), ] def test_query_string(self): s = xpathselect._get_filter_string_for_key_value_pair(self.k, self.v) self.assertEqual(s, self.r) class ParameterFilterStringTests(TestWithScenarios, TestCase): def test_raises_ValueError_on_unknown_type(self): fn = lambda: xpathselect._get_filter_string_for_key_value_pair( 'k', object() ) self.assertThat( fn, raises( ValueError("Unsupported value type: object") ) ) class ServerSideParamMatchingTests(TestWithScenarios, TestCase): """Tests for the server side matching decision function.""" scenarios = [ ('should work', dict(key='keyname', value='value', result=True)), ('invalid key', dict(key='k e', value='value', result=False)), ('string value', dict(key='key', value='v e', result=True)), ('string value2', dict(key='key', value='v?e', result=True)), ('string value3', dict(key='key', value='1/2."!@#*&^%', result=True)), ('bool value', dict(key='key', value=False, result=True)), ('int value', dict(key='key', value=123, result=True)), ('int value2', dict(key='key', value=-123, result=True)), ('float value', dict(key='key', value=1.0, result=False)), ('dict value', dict(key='key', value={}, result=False)), ('obj value', dict(key='key', value=TestCase, result=False)), ('int overflow 1', dict(key='key', value=-2147483648, result=True)), ('int overflow 2', dict(key='key', value=-2147483649, result=False)), ('int overflow 3', dict(key='key', value=2147483647, result=True)), ('int overflow 4', dict(key='key', value=2147483648, result=False)), ('unicode string', dict(key='key', value='H\u2026i', result=False)), ] def test_valid_server_side_param(self): self.assertEqual( xpathselect._is_valid_server_side_filter_param( self.key, self.value ), self.result ) class GetClassnameFromPathTests(TestCase): def test_single_element(self): self.assertEqual("Foo", xpathselect.get_classname_from_path("Foo")) def test_single_element_with_path(self): self.assertEqual("Foo", xpathselect.get_classname_from_path("/Foo")) def test_multiple_elements(self): self.assertEqual( "Baz", xpathselect.get_classname_from_path("/Foo/Bar/Baz") ) class GetPathRootTests(TestCase): def test_get_root_path_on_string_path(self): self.assertEqual("Foo", xpathselect.get_path_root("/Foo/Bar/Baz")) def test_get_root_path_on_bytes_literal_path(self): self.assertEqual(b"Foo", xpathselect.get_path_root(b"/Foo/Bar/Baz")) def test_get_root_path_on_garbage_path_raises(self): self.assertRaises(IndexError, xpathselect.get_path_root, "asdfgh") ./autopilot/tests/unit/test_application_environment.py0000644000004100000410000000705414002063564023752 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import patch from testtools import TestCase from testtools.matchers import raises from autopilot.application._environment import ( ApplicationEnvironment, GtkApplicationEnvironment, QtApplicationEnvironment, ) class ApplicationEnvironmentTests(TestCase): def test_raises_notimplementederror(self): self.assertThat( lambda: ApplicationEnvironment().prepare_environment(None, None), raises( NotImplementedError("Sub-classes must implement this method.") ) ) class GtkApplicationEnvironmentTests(TestCase): def setUp(self): super(GtkApplicationEnvironmentTests, self).setUp() self.app_environment = GtkApplicationEnvironment() def test_does_not_alter_app(self): fake_app = self.getUniqueString() app, args = self.app_environment.prepare_environment(fake_app, []) self.assertEqual(fake_app, app) @patch("autopilot.application._environment.os") def test_modules_patched(self, patched_os): patched_os.getenv.return_value = "" fake_app = self.getUniqueString() app, args = self.app_environment.prepare_environment(fake_app, []) patched_os.putenv.assert_called_once_with('GTK_MODULES', ':autopilot') @patch("autopilot.application._environment.os") def test_modules_not_patched_twice(self, patched_os): patched_os.getenv.return_value = "autopilot" fake_app = self.getUniqueString() app, args = self.app_environment.prepare_environment(fake_app, []) self.assertFalse(patched_os.putenv.called) class QtApplicationEnvironmentTests(TestCase): def setUp(self): super(QtApplicationEnvironmentTests, self).setUp() self.app_environment = QtApplicationEnvironment() def test_does_not_alter_app(self): fake_app = self.getUniqueString() app, args = self.app_environment.prepare_environment(fake_app, []) self.assertEqual(fake_app, app) def test_inserts_testability_with_no_args(self): app, args = self.app_environment.prepare_environment('some_app', []) self.assertEqual(['-testability'], args) def test_inserts_testability_before_normal_argument(self): app, args = self.app_environment.prepare_environment('app', ['-l']) self.assertEqual(['-testability', '-l'], args) def test_inserts_testability_after_qt_version_arg(self): app, args = self.app_environment.prepare_environment( 'app', ['-qt=qt5'] ) self.assertEqual(['-qt=qt5', '-testability'], args) def test_does_not_insert_testability_if_already_present(self): app, args = self.app_environment.prepare_environment( 'app', ['-testability'] ) self.assertEqual(['-testability'], args) ./autopilot/tests/unit/test_config.py0000644000004100000410000000733714002063564020274 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import patch from testtools import TestCase from autopilot import _config as config class TestConfigurationTests(TestCase): def test_can_set_test_config_string(self): token = self.getUniqueString() config.set_configuration_string(token) self.assertEqual(config._test_config_string, token) def test_can_create_config_dictionary_from_empty_string(self): d = config.ConfigDict('') self.assertEqual(0, len(d)) def test_cannot_write_to_config_dict(self): def set_item(): d['sdf'] = 123 d = config.ConfigDict('') self.assertRaises( TypeError, set_item, ) def test_simple_key_present(self): d = config.ConfigDict('foo') self.assertTrue('foo' in d) def test_simple_key_value(self): d = config.ConfigDict('foo') self.assertEqual(d['foo'], '1') def test_single_value_containing_equals_symbol(self): d = config.ConfigDict('foo=b=a') self.assertEqual(d['foo'], 'b=a') def test_multiple_simple_keys(self): d = config.ConfigDict('foo,bar') self.assertTrue('foo' in d) self.assertTrue('bar' in d) self.assertEqual(2, len(d)) def test_ignores_empty_simple_keys_at_end(self): d = config.ConfigDict('foo,,') self.assertEqual(1, len(d)) def test_ignores_empty_simple_keys_at_start(self): d = config.ConfigDict(',,foo') self.assertEqual(1, len(d)) def test_ignores_empty_simple_keys_in_middle(self): d = config.ConfigDict('foo,,bar') self.assertEqual(2, len(d)) def test_strips_leading_whitespace_for_simple_keys(self): d = config.ConfigDict(' foo, bar') self.assertEqual(set(d.keys()), {'foo', 'bar'}) def test_complex_key_single(self): d = config.ConfigDict('foo=bar') self.assertEqual(1, len(d)) self.assertEqual(d['foo'], 'bar') def test_complex_key_multiple(self): d = config.ConfigDict('foo=bar,baz=foo') self.assertEqual(d['foo'], 'bar') self.assertEqual(d['baz'], 'foo') def test_complex_keys_strip_leading_whitespace(self): d = config.ConfigDict(' foo=bar, bar=baz') self.assertEqual(set(d.keys()), {'foo', 'bar'}) def test_raises_ValueError_on_blank_key(self): self.assertRaises(ValueError, lambda: config.ConfigDict('=,')) def test_raises_ValueError_on_space_key(self): self.assertRaises(ValueError, lambda: config.ConfigDict(' =,')) def test_raises_ValueError_on_invalid_string(self): self.assertRaises(ValueError, lambda: config.ConfigDict('f,=')) def test_iter(self): k = config.ConfigDict('foo').keys() self.assertEqual({'foo'}, k) def test_get_test_configuration_uses_global_test_config_string(self): with patch.object(config, '_test_config_string', new='foo'): d = config.get_test_configuration() self.assertTrue('foo' in d) ./autopilot/tests/unit/test_input.py0000644000004100000410000011702514002063564020162 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013, 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging import unittest import testscenarios from evdev import ecodes, uinput from unittest.mock import ANY, call, Mock, patch from testtools import TestCase from testtools.matchers import Contains, raises import autopilot.input from autopilot import ( tests, utilities ) from autopilot.input import _uinput, get_center_point, Keyboard class Empty(object): def __repr__(self): return "" def make_fake_object(globalRect=False, center=False, xywh=False): obj = Empty() if globalRect: obj.globalRect = (0, 0, 100, 100) if center: obj.center_x = 123 obj.center_y = 345 if xywh: obj.x, obj.y, obj.w, obj.h = (100, 100, 20, 40) return obj class InputCenterPointTests(TestCase): """Tests for the input get_center_point utility.""" def test_get_center_point_raises_ValueError_on_empty_object(self): obj = make_fake_object() fn = lambda: get_center_point(obj) expected_exception = ValueError( "Object '%r' does not have any recognised position attributes" % obj) self.assertThat(fn, raises(expected_exception)) def test_get_center_point_works_with_globalRect(self): obj = make_fake_object(globalRect=True) x, y = get_center_point(obj) self.assertEqual(50, x) self.assertEqual(50, y) def test_raises_ValueError_on_uniterable_globalRect(self): obj = Empty() obj.globalRect = 123 expected_exception = ValueError( "Object '' has globalRect attribute, but it is not of the " "correct type" ) self.assertThat( lambda: get_center_point(obj), raises(expected_exception) ) def test_raises_ValueError_on_too_small_globalRect(self): obj = Empty() obj.globalRect = (1, 2, 3) expected_exception = ValueError( "Object '' has globalRect attribute, but it is not of the " "correct type" ) self.assertThat( lambda: get_center_point(obj), raises(expected_exception) ) @patch('autopilot.input._common._logger') def test_get_center_point_logs_with_globalRect(self, mock_logger): obj = make_fake_object(globalRect=True) x, y = get_center_point(obj) mock_logger.debug.assert_called_once_with( "Moving to object's globalRect coordinates." ) def test_get_center_point_works_with_center_points(self): obj = make_fake_object(center=True) x, y = get_center_point(obj) self.assertEqual(123, x) self.assertEqual(345, y) @patch('autopilot.input._common._logger') def test_get_center_point_logs_with_center_points(self, mock_logger): obj = make_fake_object(center=True) x, y = get_center_point(obj) mock_logger.debug.assert_called_once_with( "Moving to object's center_x, center_y coordinates." ) def test_get_center_point_works_with_xywh(self): obj = make_fake_object(xywh=True) x, y = get_center_point(obj) self.assertEqual(110, x) self.assertEqual(120, y) @patch('autopilot.input._common._logger') def test_get_center_point_logs_with_xywh(self, mock_logger): obj = make_fake_object(xywh=True) x, y = get_center_point(obj) mock_logger.debug.assert_called_once_with( "Moving to object's center point calculated from x,y,w,h " "attributes." ) def test_get_center_point_raises_valueError_on_non_numerics(self): obj = Empty() obj.x, obj.y, obj.w, obj.h = 1, None, True, "oof" expected_exception = ValueError( "Object '' has x,y attribute, but they are not of the " "correct type" ) self.assertThat( lambda: get_center_point(obj), raises(expected_exception) ) def test_get_center_point_prefers_globalRect(self): obj = make_fake_object(globalRect=True, center=True, xywh=True) x, y = get_center_point(obj) self.assertEqual(50, x) self.assertEqual(50, y) def test_get_center_point_prefers_center_points(self): obj = make_fake_object(globalRect=False, center=True, xywh=True) x, y = get_center_point(obj) self.assertEqual(123, x) self.assertEqual(345, y) class UInputTestCase(TestCase): """Tests for the global methods of the uinput module.""" def test_create_touch_device_must_print_deprecation_message(self): with patch('autopilot.utilities.logger') as patched_log: with patch('autopilot.input._uinput.UInput'): _uinput.create_touch_device('dummy', 'dummy') self.assertThat( patched_log.warning.call_args[0][0], Contains( "This function is deprecated. Please use 'the Touch class to " "instantiate a device object' instead." ) ) class UInputKeyboardDeviceTestCase(TestCase): """Test the integration with evdev.UInput for the keyboard.""" _PRESS_VALUE = 1 _RELEASE_VALUE = 0 def get_keyboard_with_mocked_backend(self): keyboard = _uinput._UInputKeyboardDevice(device_class=Mock) keyboard._device.mock_add_spec(uinput.UInput, spec_set=True) return keyboard def assert_key_press_emitted_write_and_syn(self, keyboard, key): self.assert_emitted_write_and_syn(keyboard, key, self._PRESS_VALUE) def assert_key_release_emitted_write_and_syn(self, keyboard, key): self.assert_emitted_write_and_syn(keyboard, key, self._RELEASE_VALUE) def assert_emitted_write_and_syn(self, keyboard, key, value): key_ecode = ecodes.ecodes.get(key) expected_calls = [ call.write(ecodes.EV_KEY, key_ecode, value), call.syn() ] self.assertEqual(expected_calls, keyboard._device.mock_calls) def press_key_and_reset_mock(self, keyboard, key): keyboard.press(key) keyboard._device.reset_mock() def test_press_key_must_emit_write_and_syn(self): keyboard = self.get_keyboard_with_mocked_backend() keyboard.press('KEY_A') self.assert_key_press_emitted_write_and_syn(keyboard, 'KEY_A') def test_press_key_must_append_leading_string(self): keyboard = self.get_keyboard_with_mocked_backend() keyboard.press('A') self.assert_key_press_emitted_write_and_syn(keyboard, 'KEY_A') def test_press_key_must_ignore_case(self): keyboard = self.get_keyboard_with_mocked_backend() keyboard.press('a') self.assert_key_press_emitted_write_and_syn(keyboard, 'KEY_A') def test_press_unexisting_key_must_raise_error(self): keyboard = self.get_keyboard_with_mocked_backend() error = self.assertRaises( ValueError, keyboard.press, 'unexisting') self.assertEqual('Unknown key name: unexisting.', str(error)) def test_release_not_pressed_key_must_raise_error(self): keyboard = self.get_keyboard_with_mocked_backend() error = self.assertRaises( ValueError, keyboard.release, 'A') self.assertEqual("Key 'A' not pressed.", str(error)) def test_release_key_must_emit_write_and_syn(self): keyboard = self.get_keyboard_with_mocked_backend() self.press_key_and_reset_mock(keyboard, 'KEY_A') keyboard.release('KEY_A') self.assert_key_release_emitted_write_and_syn(keyboard, 'KEY_A') def test_release_key_must_append_leading_string(self): keyboard = self.get_keyboard_with_mocked_backend() self.press_key_and_reset_mock(keyboard, 'KEY_A') keyboard.release('A') self.assert_key_release_emitted_write_and_syn(keyboard, 'KEY_A') def test_release_key_must_ignore_case(self): keyboard = self.get_keyboard_with_mocked_backend() self.press_key_and_reset_mock(keyboard, 'KEY_A') keyboard.release('a') self.assert_key_release_emitted_write_and_syn(keyboard, 'KEY_A') def test_release_unexisting_key_must_raise_error(self): keyboard = self.get_keyboard_with_mocked_backend() error = self.assertRaises( ValueError, keyboard.release, 'unexisting') self.assertEqual('Unknown key name: unexisting.', str(error)) def test_release_pressed_keys_without_pressed_keys_must_do_nothing(self): keyboard = self.get_keyboard_with_mocked_backend() keyboard.release_pressed_keys() self.assertEqual([], keyboard._device.mock_calls) def test_release_pressed_keys_with_pressed_keys(self): expected_calls = [ call.write( ecodes.EV_KEY, ecodes.ecodes.get('KEY_A'), self._RELEASE_VALUE), call.syn(), call.write( ecodes.EV_KEY, ecodes.ecodes.get('KEY_B'), self._RELEASE_VALUE), call.syn() ] keyboard = self.get_keyboard_with_mocked_backend() self.press_key_and_reset_mock(keyboard, 'KEY_A') self.press_key_and_reset_mock(keyboard, 'KEY_B') keyboard.release_pressed_keys() self.assertEqual(expected_calls, keyboard._device.mock_calls) def test_release_pressed_keys_already_released(self): expected_calls = [] keyboard = self.get_keyboard_with_mocked_backend() keyboard.press('KEY_A') keyboard.release_pressed_keys() keyboard._device.reset_mock() keyboard.release_pressed_keys() self.assertEqual(expected_calls, keyboard._device.mock_calls) class UInputKeyboardTestCase(testscenarios.TestWithScenarios, TestCase): """Test UInput Keyboard helper for autopilot tests.""" scenarios = [ ('single key', dict(keys='a', expected_calls_args=['a'])), ('upper-case letter', dict( keys='A', expected_calls_args=['KEY_LEFTSHIFT', 'A'])), ('key combination', dict( keys='a+b', expected_calls_args=['a', 'b'])) ] def setUp(self): super(UInputKeyboardTestCase, self).setUp() # Return to the original device after the test. self.addCleanup(self.set_keyboard_device, _uinput.Keyboard._device) # Mock the sleeps so we don't have to spend time actually sleeping. self.addCleanup(utilities.sleep.disable_mock) utilities.sleep.enable_mock() def set_keyboard_device(self, device): _uinput.Keyboard._device = device def get_keyboard_with_mocked_backend(self): _uinput.Keyboard._device = None keyboard = _uinput.Keyboard(device_class=Mock) keyboard._device.mock_add_spec( _uinput._UInputKeyboardDevice, spec_set=True) return keyboard def test_press_must_put_press_device_keys(self): expected_calls = [ call.press(arg) for arg in self.expected_calls_args] keyboard = self.get_keyboard_with_mocked_backend() keyboard.press(self.keys) self.assertEqual(expected_calls, keyboard._device.mock_calls) def test_release_must_release_device_keys(self): keyboard = self.get_keyboard_with_mocked_backend() keyboard.press(self.keys) keyboard._device.reset_mock() expected_calls = [ call.release(arg) for arg in reversed(self.expected_calls_args)] keyboard.release(self.keys) self.assertEqual( expected_calls, keyboard._device.mock_calls) def test_press_and_release_must_press_device_keys(self): expected_press_calls = [ call.press(arg) for arg in self.expected_calls_args] ignored_calls = [ ANY for arg in self.expected_calls_args] keyboard = self.get_keyboard_with_mocked_backend() keyboard.press_and_release(self.keys) self.assertEqual( expected_press_calls + ignored_calls, keyboard._device.mock_calls) def test_press_and_release_must_release_device_keys_in_reverse_order( self): ignored_calls = [ ANY for arg in self.expected_calls_args] expected_release_calls = [ call.release(arg) for arg in reversed(self.expected_calls_args)] keyboard = self.get_keyboard_with_mocked_backend() keyboard.press_and_release(self.keys) self.assertEqual( ignored_calls + expected_release_calls, keyboard._device.mock_calls) def test_on_test_end_without_device_must_do_nothing(self): _uinput.Keyboard._device = None # This will fail if it calls anything from the device, as it's None. _uinput.Keyboard.on_test_end(self) def test_on_test_end_with_device_must_release_pressed_keys(self): keyboard = self.get_keyboard_with_mocked_backend() _uinput.Keyboard.on_test_end(self) self.assertEqual( [call.release_pressed_keys()], keyboard._device.mock_calls) class TouchEventsTestCase(TestCase): def assert_expected_ev_abs(self, res_x, res_y, actual_ev_abs): expected_ev_abs = [ (ecodes.ABS_X, (0, res_x, 0, 0)), (ecodes.ABS_Y, (0, res_y, 0, 0)), (ecodes.ABS_PRESSURE, (0, 65535, 0, 0)), (ecodes.ABS_MT_POSITION_X, (0, res_x, 0, 0)), (ecodes.ABS_MT_POSITION_Y, (0, res_y, 0, 0)), (ecodes.ABS_MT_TOUCH_MAJOR, (0, 30, 0, 0)), (ecodes.ABS_MT_TRACKING_ID, (0, 65535, 0, 0)), (ecodes.ABS_MT_PRESSURE, (0, 255, 0, 0)), (ecodes.ABS_MT_SLOT, (0, 9, 0, 0)) ] self.assertEqual(expected_ev_abs, actual_ev_abs) def test_get_touch_events_without_args_must_use_system_resolution(self): with patch.object( _uinput, '_get_system_resolution', spec_set=True, autospec=True) as mock_system_resolution: mock_system_resolution.return_value = ( 'system_res_x', 'system_res_y') events = _uinput._get_touch_events() ev_abs = events.get(ecodes.EV_ABS) self.assert_expected_ev_abs('system_res_x', 'system_res_y', ev_abs) def test_get_touch_events_with_args_must_use_given_resulution(self): events = _uinput._get_touch_events('given_res_x', 'given_res_y') ev_abs = events.get(ecodes.EV_ABS) self.assert_expected_ev_abs('given_res_x', 'given_res_y', ev_abs) class UInputTouchDeviceTestCase(tests.LogHandlerTestCase): """Test the integration with evdev.UInput for the touch device.""" def setUp(self): super(UInputTouchDeviceTestCase, self).setUp() self._number_of_slots = 9 # Return to the original device after the test. self.addCleanup( self.set_mouse_device, _uinput._UInputTouchDevice._device, _uinput._UInputTouchDevice._touch_fingers_in_use, _uinput._UInputTouchDevice._last_tracking_id) # Always start the tests without fingers in use. _uinput._UInputTouchDevice._touch_fingers_in_use = [] _uinput._UInputTouchDevice._last_tracking_id = 0 def set_mouse_device( self, device, touch_fingers_in_use, last_tracking_id): _uinput._UInputTouchDevice._device = device _uinput._UInputTouchDevice._touch_fingers_in_use = touch_fingers_in_use _uinput._UInputTouchDevice._last_tracking_id = last_tracking_id def get_touch_with_mocked_backend(self): dummy_x_resolution = 100 dummy_y_resolution = 100 _uinput._UInputTouchDevice._device = None touch = _uinput._UInputTouchDevice( res_x=dummy_x_resolution, res_y=dummy_y_resolution, device_class=Mock) touch._device.mock_add_spec(uinput.UInput, spec_set=True) return touch def assert_finger_down_emitted_write_and_syn( self, touch, slot, tracking_id, x, y): press_value = 1 expected_calls = [ call.write(ecodes.EV_ABS, ecodes.ABS_MT_SLOT, slot), call.write( ecodes.EV_ABS, ecodes.ABS_MT_TRACKING_ID, tracking_id), call.write( ecodes.EV_KEY, ecodes.BTN_TOUCH, press_value), call.write(ecodes.EV_ABS, ecodes.ABS_MT_POSITION_X, x), call.write(ecodes.EV_ABS, ecodes.ABS_MT_POSITION_Y, y), call.write(ecodes.EV_ABS, ecodes.ABS_MT_PRESSURE, 400), call.syn() ] self.assertEqual(expected_calls, touch._device.mock_calls) def assert_finger_move_emitted_write_and_syn(self, touch, slot, x, y): expected_calls = [ call.write(ecodes.EV_ABS, ecodes.ABS_MT_SLOT, slot), call.write(ecodes.EV_ABS, ecodes.ABS_MT_POSITION_X, x), call.write(ecodes.EV_ABS, ecodes.ABS_MT_POSITION_Y, y), call.syn() ] self.assertEqual(expected_calls, touch._device.mock_calls) def assert_finger_up_emitted_write_and_syn(self, touch, slot): lift_tracking_id = -1 release_value = 0 expected_calls = [ call.write(ecodes.EV_ABS, ecodes.ABS_MT_SLOT, slot), call.write( ecodes.EV_ABS, ecodes.ABS_MT_TRACKING_ID, lift_tracking_id), call.write( ecodes.EV_KEY, ecodes.BTN_TOUCH, release_value), call.syn() ] self.assertEqual(expected_calls, touch._device.mock_calls) def test_finger_down_must_use_free_slot(self): for slot in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) self.assert_finger_down_emitted_write_and_syn( touch, slot=slot, tracking_id=ANY, x=0, y=0) def test_finger_down_without_free_slots_must_raise_error(self): # Claim all the available slots. for slot in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch = self.get_touch_with_mocked_backend() # Try to use one more. error = self.assertRaises(RuntimeError, touch.finger_down, 11, 11) self.assertEqual( 'All available fingers have been used already.', str(error)) def test_finger_down_must_use_unique_tracking_id(self): for number in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) self.assert_finger_down_emitted_write_and_syn( touch, slot=ANY, tracking_id=number + 1, x=0, y=0) def test_finger_down_must_not_reuse_tracking_ids(self): # Claim and release all the available slots once. for number in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch.finger_up() touch = self.get_touch_with_mocked_backend() touch.finger_down(12, 12) self.assert_finger_down_emitted_write_and_syn( touch, slot=ANY, tracking_id=number + 2, x=12, y=12) def test_finger_down_with_finger_pressed_must_raise_error(self): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) error = self.assertRaises(RuntimeError, touch.finger_down, 0, 0) self.assertEqual( "Cannot press finger: it's already pressed.", str(error)) def test_finger_move_without_finger_pressed_must_raise_error(self): touch = self.get_touch_with_mocked_backend() error = self.assertRaises(RuntimeError, touch.finger_move, 10, 10) self.assertEqual( 'Attempting to move without finger being down.', str(error)) def test_finger_move_must_use_assigned_slot(self): for slot in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch._device.reset_mock() touch.finger_move(10, 10) self.assert_finger_move_emitted_write_and_syn( touch, slot=slot, x=10, y=10) def test_finger_move_must_reuse_assigned_slot(self): first_slot = 0 touch = self.get_touch_with_mocked_backend() touch.finger_down(1, 1) touch._device.reset_mock() touch.finger_move(13, 13) self.assert_finger_move_emitted_write_and_syn( touch, slot=first_slot, x=13, y=13) touch._device.reset_mock() touch.finger_move(14, 14) self.assert_finger_move_emitted_write_and_syn( touch, slot=first_slot, x=14, y=14) def test_finger_move_must_log_position_at_debug_level(self): self.root_logger.setLevel(logging.DEBUG) touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch.finger_move(10, 10) self.assertLogLevelContains( 'DEBUG', "Moving pointing 'finger' to position 10,10." ) self.assertLogLevelContains( 'DEBUG', "The pointing 'finger' is now at position 10,10." ) def test_finger_up_without_finger_pressed_must_raise_error(self): touch = self.get_touch_with_mocked_backend() error = self.assertRaises(RuntimeError, touch.finger_up) self.assertEqual( "Cannot release finger: it's not pressed.", str(error)) def test_finger_up_must_use_assigned_slot(self): fingers = [] for slot in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch._device.reset_mock() fingers.append(touch) for slot, touch in enumerate(fingers): touch.finger_up() self.assert_finger_up_emitted_write_and_syn(touch, slot=slot) touch._device.reset_mock() def test_finger_up_must_release_slot(self): fingers = [] # Claim all the available slots. for slot in range(self._number_of_slots): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) fingers.append(touch) slot_to_reuse = 3 fingers[slot_to_reuse].finger_up() touch = self.get_touch_with_mocked_backend() # Try to use one more. touch.finger_down(15, 15) self.assert_finger_down_emitted_write_and_syn( touch, slot=slot_to_reuse, tracking_id=ANY, x=15, y=15) def test_device_with_finger_down_must_be_pressed(self): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) self.assertTrue(touch.pressed) def test_device_without_finger_down_must_not_be_pressed(self): touch = self.get_touch_with_mocked_backend() self.assertFalse(touch.pressed) def test_device_after_finger_up_must_not_be_pressed(self): touch = self.get_touch_with_mocked_backend() touch.finger_down(0, 0) touch.finger_up() self.assertFalse(touch.pressed) def test_press_other_device_must_not_press_all_of_them(self): other_touch = self.get_touch_with_mocked_backend() other_touch.finger_down(0, 0) touch = self.get_touch_with_mocked_backend() self.assertFalse(touch.pressed) class UInputTouchBaseTestCase(TestCase): def setUp(self): super(UInputTouchBaseTestCase, self).setUp() # Mock the sleeps so we don't have to spend time actually sleeping. self.addCleanup(utilities.sleep.disable_mock) utilities.sleep.enable_mock() def get_touch_with_mocked_backend(self): touch = _uinput.Touch(device_class=Mock) touch._device.mock_add_spec(_uinput._UInputTouchDevice) return touch class UInputTouchFingerCoordinatesTestCase( testscenarios.TestWithScenarios, UInputTouchBaseTestCase): TEST_X_DESTINATION = 10 TEST_Y_DESTINATION = 10 scenarios = [ ('tap', { 'method': 'tap', 'args': (TEST_X_DESTINATION, TEST_Y_DESTINATION) }), ('press', { 'method': 'press', 'args': (TEST_X_DESTINATION, TEST_Y_DESTINATION) }), ('move', { 'method': 'move', 'args': (TEST_X_DESTINATION, TEST_Y_DESTINATION) }), ('drag', { 'method': 'drag', 'args': (0, 0, TEST_X_DESTINATION, TEST_Y_DESTINATION) }) ] def call_scenario_method(self, object_, method, *args): getattr(object_, method)(*self.args) def test_method_must_update_finger_coordinates(self): touch = self.get_touch_with_mocked_backend() self.call_scenario_method(touch, self.method, *self.args) self.assertEqual(touch.x, self.TEST_X_DESTINATION) self.assertEqual(touch.y, self.TEST_Y_DESTINATION) class UInputTouchTestCase(UInputTouchBaseTestCase): """Test UInput Touch helper for autopilot tests.""" def test_initial_coordinates_must_be_zero(self): touch = self.get_touch_with_mocked_backend() self.assertEqual(touch.x, 0) self.assertEqual(touch.y, 0) def test_tap_must_put_finger_down_then_sleep_and_then_put_finger_up(self): expected_calls = [ call.finger_down(0, 0), call.sleep(ANY), call.finger_up() ] touch = self.get_touch_with_mocked_backend() with patch('autopilot.input._uinput.sleep') as mock_sleep: touch._device.attach_mock(mock_sleep, 'sleep') touch.tap(0, 0) self.assertEqual(expected_calls, touch._device.mock_calls) def test_tap_object_must_put_finger_down_and_then_up_on_the_center(self): object_ = make_fake_object(center=True) expected_calls = [ call.finger_down(object_.center_x, object_.center_y), call.finger_up() ] touch = self.get_touch_with_mocked_backend() touch.tap_object(object_) self.assertEqual(expected_calls, touch._device.mock_calls) def test_press_must_put_finger_down(self): expected_calls = [call.finger_down(0, 0)] touch = self.get_touch_with_mocked_backend() touch.press(0, 0) self.assertEqual(expected_calls, touch._device.mock_calls) def test_release_must_put_finger_up(self): expected_calls = [call.finger_up()] touch = self.get_touch_with_mocked_backend() touch.release() self.assertEqual(expected_calls, touch._device.mock_calls) def test_move_must_move_finger(self): expected_calls = [call.finger_move(10, 10)] touch = self.get_touch_with_mocked_backend() touch.move(10, 10) self.assertEqual(expected_calls, touch._device.mock_calls) def test_move_must_move_with_specified_rate(self): expected_calls = [ call.finger_move(5, 5), call.finger_move(10, 10), call.finger_move(15, 15), ] touch = self.get_touch_with_mocked_backend() touch.move(15, 15, rate=5) self.assertEqual( expected_calls, touch._device.mock_calls) def test_move_without_rate_must_use_default(self): expected_calls = [ call.finger_move(10, 10), call.finger_move(20, 20), ] touch = self.get_touch_with_mocked_backend() touch.move(20, 20) self.assertEqual( expected_calls, touch._device.mock_calls) def test_move_to_same_place_must_not_move(self): expected_calls = [] touch = self.get_touch_with_mocked_backend() touch.move(0, 0) self.assertEqual(expected_calls, touch._device.mock_calls) def test_drag_must_call_finger_down_move_and_up(self): expected_calls = [ call.finger_down(0, 0), call.finger_move(10, 10), call.finger_up() ] touch = self.get_touch_with_mocked_backend() touch.drag(0, 0, 10, 10) self.assertEqual(expected_calls, touch._device.mock_calls) def test_tap_without_press_duration_must_sleep_default_time(self): touch = self.get_touch_with_mocked_backend() touch.tap(0, 0) self.assertEqual(utilities.sleep.total_time_slept(), 0.1) def test_tap_with_press_duration_must_sleep_specified_time(self): touch = self.get_touch_with_mocked_backend() touch.tap(0, 0, press_duration=10) self.assertEqual(utilities.sleep.total_time_slept(), 10) def test_tap_object_without_duration_must_call_tap_with_default_time(self): object_ = make_fake_object(center=True) touch = self.get_touch_with_mocked_backend() with patch.object(touch, 'tap') as mock_tap: touch.tap_object(object_) mock_tap.assert_called_once_with( object_.center_x, object_.center_y, press_duration=0.1, time_between_events=0.1 ) def test_tap_object_with_duration_must_call_tap_with_specified_time(self): object_ = make_fake_object(center=True) touch = self.get_touch_with_mocked_backend() with patch.object(touch, 'tap') as mock_tap: touch.tap_object( object_, press_duration=10 ) mock_tap.assert_called_once_with( object_.center_x, object_.center_y, press_duration=10, time_between_events=0.1 ) class MultipleUInputTouchBackend(_uinput._UInputTouchDevice): def __init__(self, res_x=100, res_y=100, device_class=Mock): super(MultipleUInputTouchBackend, self).__init__( res_x, res_y, device_class) class MultipleUInputTouchTestCase(TestCase): def setUp(self): super(MultipleUInputTouchTestCase, self).setUp() # Return to the original device after the test. self.addCleanup( self.set_mouse_device, _uinput._UInputTouchDevice._device, _uinput._UInputTouchDevice._touch_fingers_in_use, _uinput._UInputTouchDevice._last_tracking_id) def set_mouse_device( self, device, touch_fingers_in_use, last_tracking_id): _uinput._UInputTouchDevice._device = device _uinput._UInputTouchDevice._touch_fingers_in_use = touch_fingers_in_use _uinput._UInputTouchDevice._last_tracking_id = last_tracking_id def test_press_other_device_must_not_press_all_of_them(self): finger1 = _uinput.Touch(device_class=MultipleUInputTouchBackend) finger2 = _uinput.Touch(device_class=MultipleUInputTouchBackend) finger1.press(0, 0) self.addCleanup(finger1.release) self.assertFalse(finger2.pressed) class MoveWithAnimationUInputTouchTestCase( testscenarios.TestWithScenarios, TestCase): scenarios = [ ('move to top', dict( start_x=50, start_y=50, stop_x=50, stop_y=30, expected_moves=[call.finger_move(50, 40), call.finger_move(50, 30)])), ('move to bottom', dict( start_x=50, start_y=50, stop_x=50, stop_y=70, expected_moves=[call.finger_move(50, 60), call.finger_move(50, 70)])), ('move to left', dict( start_x=50, start_y=50, stop_x=30, stop_y=50, expected_moves=[call.finger_move(40, 50), call.finger_move(30, 50)])), ('move to right', dict( start_x=50, start_y=50, stop_x=70, stop_y=50, expected_moves=[call.finger_move(60, 50), call.finger_move(70, 50)])), ('move to top-left', dict( start_x=50, start_y=50, stop_x=30, stop_y=30, expected_moves=[call.finger_move(40, 40), call.finger_move(30, 30)])), ('move to top-right', dict( start_x=50, start_y=50, stop_x=70, stop_y=30, expected_moves=[call.finger_move(60, 40), call.finger_move(70, 30)])), ('move to bottom-left', dict( start_x=50, start_y=50, stop_x=30, stop_y=70, expected_moves=[call.finger_move(40, 60), call.finger_move(30, 70)])), ('move to bottom-right', dict( start_x=50, start_y=50, stop_x=70, stop_y=70, expected_moves=[call.finger_move(60, 60), call.finger_move(70, 70)])), ('move less than rate', dict( start_x=50, start_y=50, stop_x=55, stop_y=55, expected_moves=[call.finger_move(55, 55)])), ('move with last move less than rate', dict( start_x=50, start_y=50, stop_x=65, stop_y=65, expected_moves=[call.finger_move(60, 60), call.finger_move(65, 65)])), ] def setUp(self): super(MoveWithAnimationUInputTouchTestCase, self).setUp() # Mock the sleeps so we don't have to spend time actually sleeping. self.addCleanup(utilities.sleep.disable_mock) utilities.sleep.enable_mock() def get_touch_with_mocked_backend(self): touch = _uinput.Touch(device_class=Mock) touch._device.mock_add_spec( _uinput._UInputTouchDevice, spec_set=True) return touch def test_drag_moves(self): touch = self.get_touch_with_mocked_backend() touch.press(self.start_x, self.start_y) touch.move(self.stop_x, self.stop_y) expected_calls = ( [call.finger_down(self.start_x, self.start_y)] + self.expected_moves) self.assertEqual( expected_calls, touch._device.mock_calls) class PointerWithTouchBackendTestCase(TestCase): def get_pointer_with_touch_backend_with_mock_device(self): touch = _uinput.Touch(device_class=Mock) touch._device.mock_add_spec( _uinput._UInputTouchDevice, spec_set=True) pointer = autopilot.input.Pointer(touch) return pointer def test_initial_coordinates_must_be_zero(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() self.assertEqual(pointer.x, 0) self.assertEqual(pointer.y, 0) def test_drag_must_call_move_with_animation(self): test_rate = 2 test_time_between_events = 1 test_destination_x = 20 test_destination_y = 20 pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'move') as mock_move: pointer.drag( 0, 0, test_destination_x, test_destination_y, rate=test_rate, time_between_events=test_time_between_events) mock_move.assert_called_once_with( test_destination_x, test_destination_y, animate=True, rate=test_rate, time_between_events=test_time_between_events) def test_drag_with_rate(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'drag') as mock_drag: pointer.drag(0, 0, 20, 20, rate='test') mock_drag.assert_called_once_with( 0, 0, 20, 20, rate='test', time_between_events=0.01) def test_drag_with_time_between_events(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'drag') as mock_drag: pointer.drag(0, 0, 20, 20, time_between_events='test') mock_drag.assert_called_once_with( 0, 0, 20, 20, rate=10, time_between_events='test') def test_drag_with_default_parameters(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'drag') as mock_drag: pointer.drag(0, 0, 20, 20) mock_drag.assert_called_once_with( 0, 0, 20, 20, rate=10, time_between_events=0.01) def test_click_with_default_press_duration(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'tap') as mock_tap: pointer.click(1) mock_tap.assert_called_once_with( 0, 0, press_duration=0.1, time_between_events=0.1) def test_press_with_specified_press_duration(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() with patch.object(pointer._device, 'tap') as mock_tap: pointer.click(1, press_duration=10) mock_tap.assert_called_once_with( 0, 0, press_duration=10, time_between_events=0.1) def test_not_pressed_move_must_not_move_pointing_figer(self): """Test for moving the finger when it is not pressed. The move method on the pointer class must update the finger coordinates but it must not execute a move on the device. """ test_x_destination = 20 test_y_destination = 20 pointer = self.get_pointer_with_touch_backend_with_mock_device() pointer.move(10, 10) pointer._device._device.pressed = False with patch.object(pointer._device._device, 'finger_move') as mock_move: pointer.move(test_x_destination, test_y_destination) self.assertFalse(mock_move.called) self.assertEqual(pointer.x, test_x_destination) self.assertEqual(pointer.y, test_y_destination) def test_pressed_move_must_move_pointing_finger(self): test_x_destination = 20 test_y_destination = 20 pointer = self.get_pointer_with_touch_backend_with_mock_device() pointer.move(10, 10) pointer._device._device.pressed = True with patch.object(pointer._device._device, 'finger_move') as mock_move: pointer.move(test_x_destination, test_y_destination) mock_move.assert_called_once_with(20, 20) self.assertEqual(pointer.x, test_x_destination) self.assertEqual(pointer.y, test_y_destination) def test_press_must_put_finger_down_at_last_move_position(self): pointer = self.get_pointer_with_touch_backend_with_mock_device() pointer.move(10, 10) pointer.press() pointer._device._device.finger_down.assert_called_once_with(10, 10) class UInputPowerButtonTestCase(TestCase): def get_mock_hardware_keys_device(self): power_button = _uinput.UInputHardwareKeysDevice(device_class=Mock) power_button._device.mock_add_spec(uinput.UInput, spec_set=True) return power_button def assert_power_button_press_release_emitted_write_and_sync(self, calls): expected_calls = [ call.write(ecodes.EV_KEY, ecodes.KEY_POWER, 1), call.write(ecodes.EV_KEY, ecodes.KEY_POWER, 0), call.syn(), ] self.assertEquals(expected_calls, calls) def test_power_button_press_release_emitted_write_and_sync(self): device = self.get_mock_hardware_keys_device() device.press_and_release_power_button() self.assert_power_button_press_release_emitted_write_and_sync( device._device.mock_calls ) class KeyboardTestCase(unittest.TestCase): @patch('autopilot.input._pick_backend') def test_input_backends_default_order(self, pick_backend): k = Keyboard() k.create() backends = list(pick_backend.call_args[0][0].items()) self.assertTrue(backends[0][0] == 'X11') self.assertTrue(backends[1][0] == 'OSK') self.assertTrue(backends[2][0] == 'UInput') ./autopilot/tests/unit/test_custom_exceptions.py0000644000004100000410000000344014002063564022571 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals, IsInstance from autopilot.exceptions import BackendException class BackendExceptionTests(TestCase): def test_must_wrap_exception(self): """BackendException must be able to wrap another exception instance.""" err = BackendException(RuntimeError("Hello World")) self.assertThat(err.original_exception, IsInstance(RuntimeError)) self.assertThat(str(err.original_exception), Equals("Hello World")) def test_dunder_str(self): err = BackendException(RuntimeError("Hello World")) self.assertThat( str(err), Equals( "Error while initialising backend. Original exception was: " "Hello World")) def test_dunder_repr(self): err = BackendException(RuntimeError("Hello World")) self.assertThat( repr(err), Equals( "BackendException('Error while initialising backend. Original " "exception was: Hello World')")) ./autopilot/tests/unit/test_process.py0000644000004100000410000000561014002063564020475 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from gi.repository import GLib from unittest.mock import Mock, patch from testtools import TestCase, skipIf from testtools.matchers import ( Not, Raises, ) from autopilot.platform import model if model() == "Desktop": import autopilot.process._bamf as _b from autopilot.process._bamf import _launch_application @skipIf(model() != "Desktop", "Requires BAMF framework") class ProcessBamfTests(TestCase): def test_launch_application_attempts_launch_uris_as_manager_first(self): """_launch_application must attempt to use launch_uris_as_manager before trying to use launch_uris. """ with patch.object(_b.Gio.DesktopAppInfo, 'new') as process: process.launch_uris_as_manager.called_once_with( [], None, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.STDOUT_TO_DEV_NULL, None, None, None, None ) self.assertFalse(process.launch_uris.called) def test_launch_application_falls_back_to_earlier_ver_uri_call(self): """_launch_application must fallback to using launch_uris if the call to launch_uris_as_manager fails due to being an older version. """ test_desktop_file = self.getUniqueString() process = Mock() process.launch_uris_as_manager.side_effect = TypeError( "Argument 2 does not allow None as a value" ) with patch.object(_b.Gio.DesktopAppInfo, 'new', return_value=process): _launch_application(test_desktop_file, []) process.launch_uris.called_once_with([], None) def test_launch_application_doesnt_raise(self): test_desktop_file = self.getUniqueString() process = Mock() process.launch_uris_as_manager.side_effect = TypeError( "Argument 2 does not allow None as a value" ) with patch.object(_b.Gio.DesktopAppInfo, 'new', return_value=process): self.assertThat( lambda: _launch_application(test_desktop_file, []), Not(Raises()) ) ./autopilot/tests/unit/test_introspection.py0000644000004100000410000001400314002063564021713 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from dbus import String from unittest.mock import Mock from testtools import TestCase from testtools.matchers import ( Equals, IsInstance, raises, ) import autopilot.introspection._search as _s from autopilot.introspection.qt import QtObjectProxyMixin import autopilot.introspection as _i class GetDetailsFromStateDataTests(TestCase): fake_state_data = (String('/some/path'), dict(foo=123)) def test_returns_classname(self): class_name, _, _ = _s._get_details_from_state_data( self.fake_state_data ) self.assertThat(class_name, Equals('path')) def test_returns_path(self): _, path, _ = _s._get_details_from_state_data(self.fake_state_data) self.assertThat(path, Equals(b'/some/path')) def test_returned_path_is_bytestring(self): _, path, _ = _s._get_details_from_state_data(self.fake_state_data) self.assertThat(path, IsInstance(type(b''))) def test_returns_state_dict(self): _, _, state = _s._get_details_from_state_data(self.fake_state_data) self.assertThat(state, Equals(dict(foo=123))) class FooTests(TestCase): fake_data_with_ap_interface = """ """ fake_data_with_ap_and_qt_interfaces = """ """ def test_raises_RuntimeError_when_no_interface_is_found(self): self.assertThat( lambda: _s._get_proxy_bases_from_introspection_xml(""), raises(RuntimeError("Could not find Autopilot interface.")) ) def test_returns_ApplicationProxyObject_claws_for_base_interface(self): self.assertThat( _s._get_proxy_bases_from_introspection_xml( self.fake_data_with_ap_interface ), Equals(()) ) def test_returns_both_base_and_qt_interface(self): self.assertThat( _s._get_proxy_bases_from_introspection_xml( self.fake_data_with_ap_and_qt_interfaces ), Equals((QtObjectProxyMixin,)) ) class ExtendProxyBasesWithEmulatorBaseTests(TestCase): def test_default_emulator_base_name(self): bases = _s._extend_proxy_bases_with_emulator_base(tuple(), None) self.assertThat(len(bases), Equals(1)) self.assertThat(bases[0].__name__, Equals("DefaultEmulatorBase")) self.assertThat(bases[0].__bases__[0], Equals(_i.CustomEmulatorBase)) def test_appends_custom_emulator_base(self): existing_bases = ('token',) custom_emulator_base = Mock() new_bases = _s._extend_proxy_bases_with_emulator_base( existing_bases, custom_emulator_base ) self.assertThat( new_bases, Equals(existing_bases + (custom_emulator_base,)) ) ./autopilot/tests/unit/introspection_base.py0000644000004100000410000000246114002063564021653 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2016 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import Mock from collections import namedtuple X_DEFAULT = 0 Y_DEFAULT = 0 W_DEFAULT = 0 H_DEFAULT = 0 global_rect = namedtuple('globalRect', ['x', 'y', 'w', 'h']) class MockObject(Mock): def __init__(self, *args, **kwargs): super().__init__() for k, v in kwargs.items(): setattr(self, k, v) def get_properties(self): return self.__dict__ get_mock_object = MockObject def get_global_rect(x=X_DEFAULT, y=Y_DEFAULT, w=W_DEFAULT, h=H_DEFAULT): return global_rect(x, y, w, h) ./autopilot/tests/unit/test_introspection_dbus.py0000644000004100000410000002665214002063564022745 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import sys import tempfile import shutil import os.path from unittest.mock import patch, Mock from io import StringIO from textwrap import dedent from testtools import TestCase from testtools.matchers import ( Equals, Not, NotEquals, Raises, raises, ) from autopilot.exceptions import StateNotFoundError from autopilot.introspection import ( CustomEmulatorBase, dbus, is_element, ) from autopilot.introspection.dbus import ( _MockableDbusObject, _validate_object_properties, ) from autopilot.utilities import sleep from autopilot.tests.unit.introspection_base import ( W_DEFAULT, X_DEFAULT, Y_DEFAULT, get_mock_object, get_global_rect, ) class IntrospectionFeatureTests(TestCase): def test_custom_emulator_base_does_not_have_id(self): self.assertThat(hasattr(CustomEmulatorBase, '_id'), Equals(False)) def test_derived_emulator_bases_do_have_id(self): class MyEmulatorBase(CustomEmulatorBase): pass self.assertThat(hasattr(MyEmulatorBase, '_id'), Equals(True)) def test_derived_children_have_same_id(self): class MyEmulatorBase(CustomEmulatorBase): pass class MyEmulator(MyEmulatorBase): pass class MyEmulator2(MyEmulatorBase): pass self.assertThat(MyEmulatorBase._id, Equals(MyEmulator._id)) self.assertThat(MyEmulatorBase._id, Equals(MyEmulator2._id)) def test_children_have_different_ids(self): class MyEmulatorBase(CustomEmulatorBase): pass class MyEmulatorBase2(CustomEmulatorBase): pass self.assertThat(MyEmulatorBase._id, NotEquals(MyEmulatorBase2._id)) class DBusIntrospectionObjectTests(TestCase): def test_can_access_path_attribute(self): fake_object = dbus.DBusIntrospectionObject( dict(id=[0, 123], path=[0, '/some/path']), b'/root', Mock() ) with fake_object.no_automatic_refreshing(): self.assertThat(fake_object.path, Equals('/some/path')) def test_wait_until_destroyed_works(self): """wait_until_destroyed must return if no new state is found.""" fake_object = dbus.DBusIntrospectionObject( dict(id=[0, 123]), b'/root', Mock() ) fake_object._backend.execute_query_get_data.return_value = [] fake_object.wait_until_destroyed() self.assertThat(fake_object.wait_until_destroyed, Not(Raises())) def test_wait_until_destroyed_raises_RuntimeError(self): """wait_until_destroyed must raise RuntimeError if the object persists. """ fake_state = dict(id=[0, 123]) fake_object = dbus.DBusIntrospectionObject( fake_state, b'/root', Mock() ) fake_object._backend.execute_query_get_data.return_value = \ [fake_state] with sleep.mocked(): self.assertThat( lambda: fake_object.wait_until_destroyed(timeout=1), raises( RuntimeError("Object was not destroyed after 1 seconds") ), ) def test_base_class_provides_correct_query_name(self): self.assertThat( dbus.DBusIntrospectionObject.get_type_query_name(), Equals('ProxyBase') ) def test_inherited_uses_default_get_node_name(self): class TestCPO(dbus.DBusIntrospectionObject): pass self.assertThat( TestCPO.get_type_query_name(), Equals('TestCPO') ) def test_inherited_overwrites_node_name_is_correct(self): class TestCPO(dbus.DBusIntrospectionObject): @classmethod def get_type_query_name(cls): return "TestCPO" self.assertThat(TestCPO.get_type_query_name(), Equals("TestCPO")) class ProxyObjectPrintTreeTests(TestCase): def _print_test_fake_object(self): """common fake object for print_tree tests""" fake_object = dbus.DBusIntrospectionObject( dict(id=[0, 123], path=[0, '/some/path'], text=[0, 'Hello']), b'/some/path', Mock() ) # get_properties() always refreshes state, so can't use # no_automatic_refreshing() fake_object.refresh_state = lambda: None fake_object._execute_query = lambda q: [] return fake_object def test_print_tree_stdout(self): """print_tree with default output (stdout)""" fake_object = self._print_test_fake_object() orig_sys_stdout = sys.stdout sys.stdout = StringIO() try: fake_object.print_tree() result = sys.stdout.getvalue() finally: sys.stdout = orig_sys_stdout self.assertEqual(result, dedent("""\ == /some/path == id: 123 path: '/some/path' text: 'Hello' """)) def test_print_tree_exception(self): """print_tree with StateNotFound exception""" fake_object = self._print_test_fake_object() child = Mock() child.print_tree.side_effect = StateNotFoundError('child') with patch.object(fake_object, 'get_children', return_value=[child]): out = StringIO() print_func = lambda: fake_object.print_tree(out) self.assertThat(print_func, Not(Raises(StateNotFoundError))) self.assertEqual(out.getvalue(), dedent("""\ == /some/path == id: 123 path: '/some/path' text: 'Hello' Error: Object not found with name 'child'. {} """.format(StateNotFoundError._troubleshoot_url_message))) def test_print_tree_fileobj(self): """print_tree with file object output""" fake_object = self._print_test_fake_object() out = StringIO() fake_object.print_tree(out) self.assertEqual(out.getvalue(), dedent("""\ == /some/path == id: 123 path: '/some/path' text: 'Hello' """)) def test_print_tree_path(self): """print_tree with file path output""" fake_object = self._print_test_fake_object() workdir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, workdir) outfile = os.path.join(workdir, 'widgets.txt') fake_object.print_tree(outfile) with open(outfile) as f: result = f.read() self.assertEqual(result, dedent("""\ == /some/path == id: 123 path: '/some/path' text: 'Hello' """)) class GetTypeNameTests(TestCase): """Tests for the autopilot.introspection.dbus.get_type_name function.""" def test_returns_string(self): token = self.getUniqueString() self.assertEqual(token, dbus.get_type_name(token)) def test_returns_class_name(self): class FooBarBaz(object): pass self.assertEqual("FooBarBaz", dbus.get_type_name(FooBarBaz)) def test_get_type_name_returns_classname(self): class CustomCPO(dbus.DBusIntrospectionObject): pass type_name = dbus.get_type_name(CustomEmulatorBase) self.assertThat(type_name, Equals('ProxyBase')) def test_get_type_name_returns_custom_node_name(self): class CustomCPO(dbus.DBusIntrospectionObject): @classmethod def get_type_query_name(cls): return 'TestingCPO' type_name = dbus.get_type_name(CustomCPO) self.assertThat(type_name, Equals('TestingCPO')) def test_get_type_name_returns_classname_of_non_proxybase_classes(self): class Foo(object): pass self.assertEqual('Foo', dbus.get_type_name(Foo)) class IsElementTestCase(TestCase): def raise_state_not_found(self, should_raise=True): if should_raise: raise StateNotFoundError('Just throw the exception') def test_returns_false_if_not_element(self): self.assertFalse(is_element(self.raise_state_not_found)) def test_returns_true_if_element(self): self.assertTrue( is_element( self.raise_state_not_found, should_raise=False ) ) class IsElementMovingTestCase(TestCase): def setUp(self): super().setUp() self.dbus_object = _MockableDbusObject( get_mock_object(globalRect=get_global_rect()) ) def test_returns_true_if_x_changed(self): mock_object = get_mock_object( globalRect=get_global_rect(x=X_DEFAULT + 1) ) with self.dbus_object.mocked(mock_object) as mocked_dbus_object: self.assertTrue(mocked_dbus_object.is_moving()) def test_returns_true_if_y_changed(self): mock_object = get_mock_object( globalRect=get_global_rect(y=Y_DEFAULT + 1) ) with self.dbus_object.mocked(mock_object) as mocked_dbus_object: self.assertTrue(mocked_dbus_object.is_moving()) def test_returns_true_if_x_and_y_changed(self): mock_object = get_mock_object( globalRect=get_global_rect(x=X_DEFAULT + 1, y=Y_DEFAULT + 1) ) with self.dbus_object.mocked(mock_object) as mocked_dbus_object: self.assertTrue(mocked_dbus_object.is_moving()) def test_returns_false_if_x_and_y_not_changed(self): mock_object = get_mock_object(globalRect=get_global_rect()) with self.dbus_object.mocked(mock_object) as mocked_dbus_object: self.assertFalse(mocked_dbus_object.is_moving()) class ValidateObjectPropertiesTestCase(TestCase): def test_return_true_if_property_match(self): mock_object = get_mock_object(x=X_DEFAULT) self.assertTrue(_validate_object_properties(mock_object, x=X_DEFAULT)) def test_returns_true_if_all_properties_match(self): mock_object = get_mock_object(x=X_DEFAULT, y=Y_DEFAULT) self.assertTrue( _validate_object_properties(mock_object, x=X_DEFAULT, y=Y_DEFAULT) ) def test_return_false_if_property_not_match(self): mock_object = get_mock_object(x=X_DEFAULT + 1) self.assertFalse(_validate_object_properties(mock_object, x=X_DEFAULT)) def test_returns_false_if_property_invalid(self): mock_object = get_mock_object() self.assertFalse(_validate_object_properties(mock_object, x=X_DEFAULT)) def test_returns_false_if_any_property_not_match(self): mock_object = get_mock_object(x=X_DEFAULT, y=Y_DEFAULT, w=W_DEFAULT) self.assertFalse( _validate_object_properties( mock_object, x=X_DEFAULT, y=Y_DEFAULT, w=W_DEFAULT + 1 ) ) ./autopilot/tests/unit/test_matchers.py0000644000004100000410000002057314002063564020632 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from contextlib import contextmanager import dbus from testscenarios import TestWithScenarios from testtools import TestCase from testtools.matchers import ( Contains, Equals, Is, IsInstance, Mismatch, raises, ) from autopilot.introspection import backends from autopilot.introspection.dbus import DBusIntrospectionObject from autopilot.introspection.types import Color, ValueType from autopilot.matchers import Eventually from autopilot.utilities import sleep def make_fake_attribute_with_result(result, attribute_type='wait_for', typeid=None): """Make a fake attribute with the given result. This will either return a callable, or an attribute patched with a wait_for method, according to the current test scenario. """ class FakeObject(DBusIntrospectionObject): def __init__(self, props): super(FakeObject, self).__init__( props, b"/FakeObject", backends.FakeBackend( [(dbus.String('/FakeObject'), props)] ) ) if attribute_type == 'callable': return lambda: result elif attribute_type == 'wait_for': if isinstance(result, str): obj = FakeObject(dict(id=[0, 123], attr=[0, dbus.String(result)])) return obj.attr elif isinstance(result, bytes): obj = FakeObject( dict(id=[0, 123], attr=[0, dbus.UTF8String(result)]) ) return obj.attr elif typeid is not None: obj = FakeObject(dict(id=[0, 123], attr=[typeid] + result)) return obj.attr else: obj = FakeObject(dict(id=[0, 123], attr=[0, dbus.Boolean(result)])) return obj.attr class ObjectPatchingMatcherTests(TestCase): """Ensure the core functionality the matchers use is correct.""" def test_default_wait_for_args(self): """Ensure we can call wait_for with the correct arg.""" intro_obj = make_fake_attribute_with_result(False) intro_obj.wait_for(False) class MockedSleepTests(TestCase): def setUp(self): super(MockedSleepTests, self).setUp() sleep.enable_mock() self.addCleanup(sleep.disable_mock) @contextmanager def expected_runtime(self, tmin, tmax): try: yield finally: elapsed_time = sleep.total_time_slept() if not tmin <= elapsed_time <= tmax: raise AssertionError( "Runtime of %f is not between %f and %f" % (elapsed_time, tmin, tmax)) class EventuallyMatcherTests(TestWithScenarios, MockedSleepTests): scenarios = [ ('callable', dict(attribute_type='callable')), ('wait_for', dict(attribute_type='wait_for')), ] def test_eventually_matcher_returns_mismatch(self): """Eventually matcher must return a Mismatch.""" attr = make_fake_attribute_with_result(False, self.attribute_type) e = Eventually(Equals(True)).match(attr) self.assertThat(e, IsInstance(Mismatch)) def test_eventually_default_timeout(self): """Eventually matcher must default to 10 second timeout.""" attr = make_fake_attribute_with_result(False, self.attribute_type) with self.expected_runtime(9.5, 11.0): Eventually(Equals(True)).match(attr) def test_eventually_passes_immeadiately(self): """Eventually matcher must not wait if the assertion passes initially.""" attr = make_fake_attribute_with_result(True, self.attribute_type) with self.expected_runtime(0.0, 1.0): Eventually(Equals(True)).match(attr) def test_eventually_matcher_allows_non_default_timeout(self): """Eventually matcher must allow a non-default timeout value.""" attr = make_fake_attribute_with_result(False, self.attribute_type) with self.expected_runtime(4.5, 6.0): Eventually(Equals(True), timeout=5).match(attr) def test_mismatch_message_has_correct_timeout_value(self): """The mismatch value must have the correct timeout value in it.""" attr = make_fake_attribute_with_result(False, self.attribute_type) mismatch = Eventually(Equals(True), timeout=1).match(attr) self.assertThat( mismatch.describe(), Contains("After 1.0 seconds test")) def test_eventually_matcher_works_with_list_type(self): attr = make_fake_attribute_with_result( Color( dbus.Int32(1), dbus.Int32(2), dbus.Int32(3), dbus.Int32(4) ), self.attribute_type, typeid=ValueType.COLOR, ) mismatch = Eventually(Equals([1, 2, 3, 4])).match(attr) self.assertThat(mismatch, Is(None)) class EventuallyNonScenariodTests(MockedSleepTests): def test_eventually_matcher_raises_ValueError_on_unknown_kwargs(self): self.assertThat( lambda: Eventually(Equals(True), foo=123), raises(ValueError("Unknown keyword arguments: foo")) ) def test_eventually_matcher_raises_TypeError_on_non_matcher_argument(self): self.assertThat( lambda: Eventually(None), raises( TypeError( "Eventually must be called with a testtools " "matcher argument." ) ) ) def test_match_raises_TypeError_when_called_with_plain_attribute(self): eventually = Eventually(Equals(True)) self.assertThat( lambda: eventually.match(False), raises( TypeError( "Eventually is only usable with attributes that " "have a wait_for function or callable objects." ) ) ) def test_repr(self): eventually = Eventually(Equals(True)) self.assertEqual("Eventually Equals(True)", str(eventually)) def test_match_with_expected_value_unicode(self): """The expected unicode value matches new value string.""" attr = make_fake_attribute_with_result( '\u963f\u5e03\u4ece', 'wait_for') with self.expected_runtime(0.0, 1.0): Eventually(Equals("阿布从")).match(attr) def test_match_with_new_value_unicode(self): """new value with unicode must match expected value string.""" attr = make_fake_attribute_with_result(str("阿布从"), 'wait_for') with self.expected_runtime(0.0, 1.0): Eventually(Equals('\u963f\u5e03\u4ece')).match(attr) def test_mismatch_with_bool(self): """The mismatch value must fail boolean values.""" attr = make_fake_attribute_with_result(False, 'wait_for') mismatch = Eventually(Equals(True), timeout=1).match(attr) self.assertThat( mismatch.describe(), Contains("failed")) def test_mismatch_with_unicode(self): """The mismatch value must fail with str and unicode mix.""" attr = make_fake_attribute_with_result(str("阿布从1"), 'wait_for') mismatch = Eventually(Equals( '\u963f\u5e03\u4ece'), timeout=.5).match(attr) self.assertThat( mismatch.describe(), Contains('failed')) def test_mismatch_output_utf8(self): """The mismatch has utf output.""" self.skip("mismatch Contains returns ascii error") attr = make_fake_attribute_with_result(str("阿布从1"), 'wait_for') mismatch = Eventually(Equals( '\u963f\u5e03\u4ece'), timeout=.5).match(attr) self.assertThat( mismatch.describe(), Contains("阿布从11")) ./autopilot/tests/unit/test_vis_bus_enumerator.py0000644000004100000410000000543314002063564022735 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import patch, Mock from testtools import TestCase, skipUnless from textwrap import dedent from autopilot import have_vis if have_vis(): from autopilot.vis.dbus_search import XmlProcessor @skipUnless(have_vis(), "Tests require vis module to be installed") class BusEnumeratorXmlProcessorTest(TestCase): _example_connection_name = "com.autopilot.test" def test_invalid_xml_doesnt_raise_exception(self): xml = "" xml_processor = XmlProcessor() xml_processor(self._example_connection_name, "/", xml) @patch('autopilot.vis.dbus_search._logger') def test_invalid_xml_logs_details(self, logger_meth): xml = "" xml_processor = XmlProcessor() xml_processor(self._example_connection_name, "/", xml) logger_meth.warning.assert_called_once_with( 'Unable to parse XML response for com.autopilot.test (/)' ) def test_on_success_event_called(self): xml = dedent( '' '' '' ) success_callback = Mock() xml_processor = XmlProcessor() xml_processor.set_success_callback(success_callback) xml_processor(self._example_connection_name, "/", xml) success_callback.assert_called_with( self._example_connection_name, "/", "org.autopilot.DBus.example", ) def test_nodes_are_recursively_searched(self): xml = dedent( '' '' '' '' '' ) dbus_inspector = Mock() xml_processor = XmlProcessor() xml_processor.set_dbus_inspector(dbus_inspector) xml_processor(self._example_connection_name, "/", xml) dbus_inspector.assert_called_with( self._example_connection_name, "/example" ) ./autopilot/tests/unit/fixtures.py0000644000004100000410000000254714002063564017637 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Fixtures to be used in autopilot's unit test suite.""" from fixtures import Fixture from autopilot import globals as _g class AutopilotVerboseLogging(Fixture): """Set the autopilot verbose log flag.""" def __init__(self, verbose_logging=True): super().__init__() self._desired_state = verbose_logging def setUp(self): super().setUp() if _g.get_log_verbose() != self._desired_state: self.addCleanup( _g.set_log_verbose, _g.get_log_verbose() ) _g.set_log_verbose(self._desired_state) ./autopilot/tests/unit/test_run.py0000644000004100000410000011104414002063564017622 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from argparse import Namespace from unittest.mock import Mock, patch import logging import os.path from shutil import rmtree import subprocess import tempfile from testtools import TestCase, skipUnless from testtools.matchers import ( Contains, DirExists, Equals, FileExists, IsInstance, Not, raises, Raises, StartsWith, ) from contextlib import ExitStack from io import StringIO from autopilot import get_version_string, have_vis, run, _video class RunUtilityFunctionTests(TestCase): @patch('autopilot.run.autopilot.globals.set_debug_profile_fixture') def test_sets_when_correct_profile_found(self, patched_set_fixture): mock_profile = Mock() mock_profile.name = "verbose" parsed_args = Namespace(debug_profile="verbose") with patch.object( run, 'get_all_debug_profiles', lambda: {mock_profile}): run._configure_debug_profile(parsed_args) patched_set_fixture.assert_called_once_with(mock_profile) @patch('autopilot.run.autopilot.globals.set_debug_profile_fixture') def test_does_nothing_when_no_profile_found(self, patched_set_fixture): mock_profile = Mock() mock_profile.name = "verbose" parsed_args = Namespace(debug_profile="normal") with patch.object( run, 'get_all_debug_profiles', lambda: {mock_profile}): run._configure_debug_profile(parsed_args) self.assertFalse(patched_set_fixture.called) @patch('autopilot.run.autopilot.globals') def test_timeout_values_set_with_long_profile(self, patched_globals): args = Namespace(timeout_profile='long') run._configure_timeout_profile(args) patched_globals.set_default_timeout_period.assert_called_once_with( 20.0 ) patched_globals.set_long_timeout_period.assert_called_once_with(30.0) @patch('autopilot.run.autopilot.globals') def test_timeout_value_not_set_with_normal_profile(self, patched_globals): args = Namespace(timeout_profile='normal') run._configure_timeout_profile(args) self.assertFalse(patched_globals.set_default_timeout_period.called) self.assertFalse(patched_globals.set_long_timeout_period.called) @patch('autopilot.run.autopilot.globals') def test_test_tiemout_value_set_in_globals(self, patched_globals): args = Namespace(test_timeout=42) run._configure_test_timeout(args) patched_globals.set_test_timeout.assert_called_once_with(42) @patch.object(_video, '_have_video_recording_facilities', new=lambda: True) def test_correct_video_record_fixture_is_called_with_record_on(self): args = Namespace(record_directory='', record=True) _video.configure_video_recording(args) FixtureClass = _video.get_video_recording_fixture() fixture = FixtureClass(args) self.assertEqual(fixture.__class__.__name__, 'RMDVideoLogFixture') @patch.object(_video, '_have_video_recording_facilities', new=lambda: True) def test_correct_video_record_fixture_is_called_with_record_off(self): args = Namespace(record_directory='', record=False) _video.configure_video_recording(args) FixtureClass = _video.get_video_recording_fixture() fixture = FixtureClass(args) self.assertEqual(fixture.__class__.__name__, 'DoNothingFixture') @patch.object(_video, '_have_video_recording_facilities', new=lambda: True) def test_configure_video_record_directory_implies_record(self): token = self.getUniqueString() args = Namespace(record_directory=token, record=False) _video.configure_video_recording(args) FixtureClass = _video.get_video_recording_fixture() fixture = FixtureClass(args) self.assertEqual(fixture.__class__.__name__, 'RMDVideoLogFixture') @patch.object(_video, '_have_video_recording_facilities', new=lambda: True) def test_configure_video_recording_sets_default_dir(self): args = Namespace(record_directory='', record=True) _video.configure_video_recording(args) PartialFixture = _video.get_video_recording_fixture() partial_fixture = PartialFixture(None) self.assertEqual(partial_fixture.recording_directory, '/tmp/autopilot') @patch.object( _video, '_have_video_recording_facilities', new=lambda: False ) def test_configure_video_recording_raises_RuntimeError(self): args = Namespace(record_directory='', record=True) self.assertThat( lambda: _video.configure_video_recording(args), raises( RuntimeError( "The application 'recordmydesktop' needs to be installed " "to record failing jobs." ) ) ) def test_video_record_check_calls_subprocess_with_correct_args(self): with patch.object(_video.subprocess, 'call') as patched_call: _video._have_video_recording_facilities() patched_call.assert_called_once_with( ['which', 'recordmydesktop'], stdout=run.subprocess.PIPE ) def test_video_record_check_returns_true_on_zero_return_code(self): with patch.object(_video.subprocess, 'call') as patched_call: patched_call.return_value = 0 self.assertTrue(_video._have_video_recording_facilities()) def test_video_record_check_returns_false_on_nonzero_return_code(self): with patch.object(_video.subprocess, 'call') as patched_call: patched_call.return_value = 1 self.assertFalse(_video._have_video_recording_facilities()) def test_run_with_profiling_creates_profile_data_file(self): output_path = tempfile.mktemp() self.addCleanup(os.unlink, output_path) def empty_callable(): pass run._run_with_profiling(empty_callable, output_path) self.assertThat(output_path, FileExists()) def test_run_with_profiling_runs_callable(self): output_path = tempfile.mktemp() self.addCleanup(os.unlink, output_path) empty_callable = Mock() run._run_with_profiling(empty_callable, output_path) empty_callable.assert_called_once_with() class TestRunLaunchApp(TestCase): @patch.object(run, 'launch_process') def test_launch_app_launches_app_with_arguments(self, patched_launch_proc): app_name = self.getUniqueString() app_arguments = self.getUniqueString() fake_args = Namespace( mode='launch', application=[app_name, app_arguments], interface=None ) with patch.object( run, '_prepare_application_for_launch', return_value=(app_name, app_arguments) ): program = run.TestProgram(fake_args) program.run() patched_launch_proc.assert_called_once_with( app_name, app_arguments, capture_output=False ) def test_launch_app_exits_using_print_message_and_exit_error(self): app_name = self.getUniqueString() app_arguments = self.getUniqueString() error_message = "Cannot find application 'blah'" fake_args = Namespace( mode='launch', application=[app_name, app_arguments], interface=None ) with patch.object( run, '_prepare_application_for_launch', side_effect=RuntimeError(error_message) ): with patch.object( run, '_print_message_and_exit_error' ) as print_and_exit: run.TestProgram(fake_args).run() print_and_exit.assert_called_once_with( "Error: %s" % error_message ) @patch.object(run, 'launch_process') def test_launch_app_exits_with_message_on_failure(self, patched_launch_proc): # NOQA app_name = self.getUniqueString() app_arguments = self.getUniqueString() fake_args = Namespace( mode='launch', application=[app_name, app_arguments], interface=None ) with patch.object( run, '_prepare_application_for_launch', return_value=(app_name, app_arguments) ): with patch('sys.stdout', new=StringIO()) as stdout: patched_launch_proc.side_effect = RuntimeError( "Failure Message" ) program = run.TestProgram(fake_args) self.assertThat(lambda: program.run(), raises(SystemExit(1))) self.assertThat( stdout.getvalue(), Contains("Error: Failure Message") ) @skipUnless(have_vis(), "Requires vis module.") @patch('autopilot.vis.vis_main') def test_passes_testability_to_vis_main(self, patched_vis_main): args = Namespace( mode='vis', testability=True, enable_profile=False, ) program = run.TestProgram(args) program.run() patched_vis_main.assert_called_once_with(['-testability']) @skipUnless(have_vis(), "Requires vis module.") @patch('autopilot.vis.vis_main') def test_passes_empty_list_without_testability_set(self, patched_vis_main): args = Namespace( mode='vis', testability=False, enable_profile=False, ) program = run.TestProgram(args) program.run() patched_vis_main.assert_called_once_with([]) class TestRunLaunchAppHelpers(TestCase): """Tests for the 'autopilot launch' command""" def test_get_app_name_and_args_returns_app_name_passed_app_name(self): app_name = self.getUniqueString() launch_args = [app_name] self.assertThat( run._get_app_name_and_args(launch_args), Equals((app_name, [])) ) def test_get_app_name_and_args_returns_app_name_passed_arg_and_name(self): app_name = self.getUniqueString() app_arg = [self.getUniqueString()] launch_args = [app_name] + app_arg self.assertThat( run._get_app_name_and_args(launch_args), Equals((app_name, app_arg)) ) def test_get_app_name_and_args_returns_app_name_passed_args_and_name(self): app_name = self.getUniqueString() app_args = [self.getUniqueString(), self.getUniqueString()] launch_args = [app_name] + app_args self.assertThat( run._get_app_name_and_args(launch_args), Equals((app_name, app_args)) ) def test_application_name_is_full_path_True_when_is_abs_path(self): with patch.object(run.os.path, 'isabs', return_value=True): self.assertTrue(run._application_name_is_full_path("")) def test_application_name_is_full_path_True_when_path_exists(self): with patch.object(run.os.path, 'exists', return_value=True): self.assertTrue(run._application_name_is_full_path("")) def test_application_name_is_full_path_False_neither_abs_or_exists(self): with patch.object(run.os.path, 'exists', return_value=False): with patch.object(run.os.path, 'isabs', return_value=False): self.assertFalse(run._application_name_is_full_path("")) def test_get_applications_full_path_returns_same_when_full_path(self): app_name = self.getUniqueString() with patch.object( run, '_application_name_is_full_path', return_value=True ): self.assertThat( run._get_applications_full_path(app_name), Equals(app_name) ) def test_get_applications_full_path_calls_which_command_on_app_name(self): app_name = self.getUniqueString() full_path = "/usr/bin/%s" % app_name with patch.object( run.subprocess, 'check_output', return_value=full_path ): self.assertThat( run._get_applications_full_path(app_name), Equals(full_path) ) def test_get_applications_full_path_raises_valueerror_when_not_found(self): app_name = self.getUniqueString() expected_error = "Cannot find application '%s'" % (app_name) with patch.object( run.subprocess, 'check_output', side_effect=subprocess.CalledProcessError(1, "") ): self.assertThat( lambda: run._get_applications_full_path(app_name), raises(ValueError(expected_error)) ) def test_get_application_path_and_arguments_raises_for_unknown_app(self): app_name = self.getUniqueString() expected_error = "Cannot find application '{app_name}'".format( app_name=app_name ) self.assertThat( lambda: run._get_application_path_and_arguments([app_name]), raises(RuntimeError(expected_error)) ) def test_get_application_path_and_arguments_returns_app_and_args(self): app_name = self.getUniqueString() with patch.object( run, '_get_applications_full_path', side_effect=lambda arg: arg ): self.assertThat( run._get_application_path_and_arguments([app_name]), Equals((app_name, [])) ) def test_get_application_launcher_env_attempts_auto_selection(self): interface = "Auto" app_path = self.getUniqueString() test_launcher_env = self.getUniqueString() with patch.object( run, '_try_determine_launcher_env_or_raise', return_value=test_launcher_env ) as get_launcher: self.assertThat( run._get_application_launcher_env(interface, app_path), Equals(test_launcher_env) ) get_launcher.assert_called_once_with(app_path) def test_get_application_launcher_env_uses_string_hint_to_determine(self): interface = None app_path = self.getUniqueString() test_launcher_env = self.getUniqueString() with patch.object( run, '_get_app_env_from_string_hint', return_value=test_launcher_env ) as get_launcher: self.assertThat( run._get_application_launcher_env(interface, app_path), Equals(test_launcher_env) ) get_launcher.assert_called_once_with(interface) def test_get_application_launcher_env_returns_None_on_failure(self): interface = None app_path = self.getUniqueString() with patch.object( run, '_get_app_env_from_string_hint', return_value=None ): self.assertThat( run._get_application_launcher_env(interface, app_path), Equals(None) ) def test_try_determine_launcher_env_or_raise_raises_on_failure(self): app_name = self.getUniqueString() err_msg = self.getUniqueString() with patch.object( run, 'get_application_launcher_wrapper', side_effect=RuntimeError(err_msg) ): self.assertThat( lambda: run._try_determine_launcher_env_or_raise(app_name), raises( RuntimeError( "Error detecting launcher: {err}\n" "(Perhaps use the '-i' argument to specify an " "interface.)" .format(err=err_msg) ) ) ) def test_try_determine_launcher_env_or_raise_returns_launcher_wrapper_result(self): # NOQA app_name = self.getUniqueString() launcher_env = self.getUniqueString() with patch.object( run, 'get_application_launcher_wrapper', return_value=launcher_env ): self.assertThat( run._try_determine_launcher_env_or_raise(app_name), Equals(launcher_env) ) def test_raise_if_launcher_is_none_raises_on_none(self): app_name = self.getUniqueString() self.assertThat( lambda: run._raise_if_launcher_is_none(None, app_name), raises( RuntimeError( "Could not determine introspection type to use for " "application '{app_name}'.\n" "(Perhaps use the '-i' argument to specify an interface.)" .format(app_name=app_name) ) ) ) def test_raise_if_launcher_is_none_does_not_raise_on_none(self): launcher_env = self.getUniqueString() app_name = self.getUniqueString() run._raise_if_launcher_is_none(launcher_env, app_name) def test_prepare_launcher_environment_creates_launcher_env(self): interface = self.getUniqueString() app_name = self.getUniqueString() app_arguments = self.getUniqueString() with patch.object( run, '_get_application_launcher_env', ) as get_launcher: get_launcher.return_value.prepare_environment = lambda *args: args self.assertThat( run._prepare_launcher_environment( interface, app_name, app_arguments ), Equals((app_name, app_arguments)) ) def test_prepare_launcher_environment_checks_launcher_isnt_None(self): interface = self.getUniqueString() app_name = self.getUniqueString() app_arguments = self.getUniqueString() with patch.object( run, '_get_application_launcher_env', ) as get_launcher: get_launcher.return_value.prepare_environment = lambda *args: args with patch.object( run, '_raise_if_launcher_is_none' ) as launcher_check: run._prepare_launcher_environment( interface, app_name, app_arguments ) launcher_check.assert_called_once_with( get_launcher.return_value, app_name ) def test_print_message_and_exit_error_prints_message(self): err_msg = self.getUniqueString() with patch('sys.stdout', new=StringIO()) as stdout: try: run._print_message_and_exit_error(err_msg) except SystemExit: pass self.assertThat(stdout.getvalue(), Contains(err_msg)) def test_print_message_and_exit_error_exits_non_zero(self): self.assertThat( lambda: run._print_message_and_exit_error(""), raises(SystemExit(1)) ) def test_prepare_application_for_launch_returns_prepared_details(self): interface = self.getUniqueString() application = self.getUniqueString() app_name = self.getUniqueString() app_arguments = self.getUniqueString() with ExitStack() as stack: stack.enter_context( patch.object( run, '_get_application_path_and_arguments', return_value=(app_name, app_arguments) ) ) prepare_launcher = stack.enter_context( patch.object( run, '_prepare_launcher_environment', return_value=(app_name, app_arguments) ) ) self.assertThat( run._prepare_application_for_launch(application, interface), Equals((app_name, app_arguments)) ) prepare_launcher.assert_called_once_with( interface, app_name, app_arguments ) class LoggingSetupTests(TestCase): verbose_level_none = 0 verbose_level_v = 1 verbose_level_vv = 2 def test_run_logs_autopilot_version(self): with patch.object(run, 'log_autopilot_version') as log_version: fake_args = Namespace(mode=None) program = run.TestProgram(fake_args) program.run() log_version.assert_called_once_with() def test_log_autopilot_version_logs_current_version(self): current_version = get_version_string() with patch.object(run, 'get_root_logger') as fake_get_root_logger: run.log_autopilot_version() fake_get_root_logger.return_value.info.assert_called_once_with( current_version ) def test_get_root_logger_returns_logging_instance(self): logger = run.get_root_logger() self.assertThat(logger, IsInstance(logging.RootLogger)) def test_setup_logging_calls_get_root_logger(self): with patch.object(run, 'get_root_logger') as fake_get_root_logger: run.setup_logging(self.verbose_level_none) fake_get_root_logger.assert_called_once_with() def test_setup_logging_defaults_to_info_level(self): with patch.object(run, 'get_root_logger') as fake_get_logger: run.setup_logging(self.verbose_level_none) fake_get_logger.return_value.setLevel.assert_called_once_with( logging.INFO ) def test_setup_logging_keeps_root_logging_level_at_info_for_v(self): with patch.object(run, 'get_root_logger') as fake_get_logger: run.setup_logging(self.verbose_level_v) fake_get_logger.return_value.setLevel.assert_called_once_with( logging.INFO ) def test_setup_logging_sets_root_logging_level_to_debug_with_vv(self): with patch.object(run, 'get_root_logger') as fake_get_logger: run.setup_logging(self.verbose_level_vv) fake_get_logger.return_value.setLevel.assert_called_with( logging.DEBUG ) @patch.object(run.autopilot.globals, 'set_log_verbose') def test_setup_logging_calls_set_log_verbose_for_v(self, patch_set_log): with patch.object(run, 'get_root_logger'): run.setup_logging(self.verbose_level_v) patch_set_log.assert_called_once_with(True) @patch.object(run.autopilot.globals, 'set_log_verbose') def test_setup_logging_calls_set_log_verbose_for_vv(self, patch_set_log): with patch.object(run, 'get_root_logger'): run.setup_logging(self.verbose_level_vv) patch_set_log.assert_called_once_with(True) def test_set_null_log_handler(self): mock_root_logger = Mock() run.set_null_log_handler(mock_root_logger) self.assertThat( mock_root_logger.addHandler.call_args[0][0], IsInstance(logging.NullHandler) ) @patch.object(run, 'get_root_logger') def test_verbse_level_zero_sets_null_handler(self, fake_get_logger): with patch.object(run, 'set_null_log_handler') as fake_set_null: run.setup_logging(0) fake_set_null.assert_called_once_with( fake_get_logger.return_value ) def test_stderr_handler_sets_stream_handler_with_custom_formatter(self): mock_root_logger = Mock() run.set_stderr_stream_handler(mock_root_logger) self.assertThat(mock_root_logger.addHandler.call_count, Equals(1)) created_handler = mock_root_logger.addHandler.call_args[0][0] self.assertThat( created_handler, IsInstance(logging.StreamHandler) ) self.assertThat( created_handler.formatter, IsInstance(run.LogFormatter) ) @patch.object(run, 'get_root_logger') def test_verbose_level_one_sets_stream_handler(self, fake_get_logger): with patch.object(run, 'set_stderr_stream_handler') as stderr_handler: run.setup_logging(1) stderr_handler.assert_called_once_with( fake_get_logger.return_value ) def test_enable_debug_log_messages_sets_debugFilter_attr(self): with patch.object(run, 'DebugLogFilter') as patched_filter: patched_filter.debug_log_enabled = False run.enable_debug_log_messages() self.assertThat( patched_filter.debug_log_enabled, Equals(True) ) @patch.object(run, 'get_root_logger') def test_verbose_level_two_enables_debug_messages(self, fake_get_logger): with patch.object(run, 'enable_debug_log_messages') as enable_debug: run.setup_logging(2) enable_debug.assert_called_once_with() class OutputStreamTests(TestCase): def remove_tree_if_exists(self, path): if os.path.exists(path): rmtree(path) def test_get_log_file_path_returns_file_path(self): requested_path = tempfile.mktemp() result = run._get_log_file_path(requested_path) self.assertThat(result, Equals(requested_path)) def test_get_log_file_path_creates_nonexisting_directories(self): temp_dir = tempfile.mkdtemp() self.addCleanup(self.remove_tree_if_exists, temp_dir) dir_to_store_logs = os.path.join(temp_dir, 'some_directory') requested_path = os.path.join(dir_to_store_logs, 'my_log.txt') run._get_log_file_path(requested_path) self.assertThat(dir_to_store_logs, DirExists()) def test_returns_default_filename_when_passed_directory(self): temp_dir = tempfile.mkdtemp() self.addCleanup(self.remove_tree_if_exists, temp_dir) with patch.object(run, '_get_default_log_filename') as _get_default: result = run._get_log_file_path(temp_dir) _get_default.assert_called_once_with(temp_dir) self.assertThat(result, Equals(_get_default.return_value)) def test_get_default_log_filename_calls_print_fn(self): with patch.object(run, '_print_default_log_path') as patch_print: run._get_default_log_filename('/some/path') self.assertThat( patch_print.call_count, Equals(1) ) call_arg = patch_print.call_args[0][0] # shouldn't print the directory, since the user already explicitly # specified that. self.assertThat(call_arg, Not(StartsWith('/some/path'))) @patch.object(run, '_print_default_log_path') def test_get_default_filename_returns_sane_string(self, patched_print): with patch.object(run, 'node', return_value='hostname'): with patch.object(run, 'datetime') as mock_dt: mock_dt.now.return_value.strftime.return_value = 'date-part' self.assertThat( run._get_default_log_filename('/some/path'), Equals('/some/path/hostname_date-part.log') ) def test_get_output_stream_gets_stdout_with_no_logfile_specified(self): output_stream = run.get_output_stream('text', None) self.assertThat(output_stream.name, Equals('')) def test_get_output_stream_opens_correct_file(self): format = 'xml' output = tempfile.mktemp() self.addCleanup(os.unlink, output) output_stream = run.get_output_stream(format, output) self.assertThat(output_stream.name, Equals(output)) def test_text_mode_file_stream_opens_in_text_mode(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_text_mode_file_stream(path) self.assertThat(stream.mode, Equals('wb')) def test_text_mode_file_stream_accepts_text_type_only(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_text_mode_file_stream(path) self.assertThat( lambda: stream.write('Text!'), Not(Raises()) ) self.assertThat( lambda: stream.write(b'Bytes'), raises(TypeError) ) def test_binary_mode_file_stream_opens_in_binary_mode(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_binary_mode_file_stream(path) self.assertThat(stream.mode, Equals('wb')) def test_binary_mode_file_stream_accepts_text_only(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_binary_mode_file_stream(path) self.assertThat( lambda: stream.write('Text!'), Not(Raises()) ) self.assertThat( lambda: stream.write(b'Bytes'), raises(TypeError) ) def test_raw_binary_mode_file_stream_opens_in_binary_mode(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_raw_binary_mode_file_stream(path) self.assertThat(stream.mode, Equals('wb')) def test_raw_binary_mode_file_stream_accepts_bytes_only(self): path = tempfile.mktemp() self.addCleanup(os.unlink, path) stream = run._get_raw_binary_mode_file_stream(path) self.assertThat( lambda: stream.write(b'Bytes'), Not(Raises()) ) self.assertThat( lambda: stream.write('Text'), raises(TypeError) ) def test_xml_format_opens_text_mode_stream(self): output = tempfile.mktemp() format = 'xml' with patch.object(run, '_get_text_mode_file_stream') as pgts: run.get_output_stream(format, output) pgts.assert_called_once_with(output) def test_txt_format_opens_binary_mode_stream(self): output = tempfile.mktemp() format = 'text' with patch.object(run, '_get_binary_mode_file_stream') as pgbs: run.get_output_stream(format, output) pgbs.assert_called_once_with(output) def test_subunit_format_opens_raw_binary_mode_stream(self): output = tempfile.mktemp() format = 'subunit' with patch.object(run, '_get_raw_binary_mode_file_stream') as pgrbs: run.get_output_stream(format, output) pgrbs.assert_called_once_with(output) def test_print_log_file_location_prints_correct_message(self): path = self.getUniqueString() with patch('sys.stdout', new=StringIO()) as patched_stdout: run._print_default_log_path(path) output = patched_stdout.getvalue() expected = "Using default log filename: %s\n" % path self.assertThat(expected, Equals(output)) class TestProgramTests(TestCase): """Tests for the TestProgram class. These tests are a little ugly at the moment, and will continue to be so until we refactor the run module to make it more testable. """ def test_can_provide_args(self): fake_args = Namespace() program = run.TestProgram(fake_args) self.assertThat(program.args, Equals(fake_args)) def test_calls_parse_args_by_default(self): fake_args = Namespace() with patch.object(run, '_parse_arguments') as fake_parse_args: fake_parse_args.return_value = fake_args program = run.TestProgram() fake_parse_args.assert_called_once_with() self.assertThat(program.args, Equals(fake_args)) def test_run_calls_setup_logging_with_verbose_arg(self): fake_args = Namespace(verbose=1, mode='') program = run.TestProgram(fake_args) with patch.object(run, 'setup_logging') as patched_setup_logging: program.run() patched_setup_logging.assert_called_once_with(True) def test_list_command_calls_list_tests_method(self): fake_args = Namespace(mode='list') program = run.TestProgram(fake_args) with patch.object(program, 'list_tests') as patched_list_tests: program.run() patched_list_tests.assert_called_once_with() def test_run_command_calls_run_tests_method(self): fake_args = Namespace(mode='run') program = run.TestProgram(fake_args) with patch.object(program, 'run_tests') as patched_run_tests: program.run() patched_run_tests.assert_called_once_with() def test_vis_command_calls_run_vis_method(self): fake_args = Namespace(mode='vis') program = run.TestProgram(fake_args) with patch.object(program, 'run_vis') as patched_run_vis: program.run() patched_run_vis.assert_called_once_with() def test_vis_command_runs_under_profiling_if_profiling_is_enabled(self): fake_args = Namespace( mode='vis', enable_profile=True, testability=False, ) program = run.TestProgram(fake_args) with patch.object(run, '_run_with_profiling') as patched_run_profile: program.run() self.assertThat( patched_run_profile.call_count, Equals(1), ) def test_launch_command_calls_launch_app_method(self): fake_args = Namespace(mode='launch') program = run.TestProgram(fake_args) with patch.object(program, 'launch_app') as patched_launch_app: program.run() patched_launch_app.assert_called_once_with() def test_run_tests_calls_utility_functions(self): """The run_tests method must call all the utility functions. This test is somewhat ugly, and relies on a lot of mocks. This will be cleaned up once run has been completely refactored. """ fake_args = create_default_run_args() program = run.TestProgram(fake_args) mock_test_result = Mock() mock_test_result.wasSuccessful.return_value = True mock_test_suite = Mock() mock_test_suite.run.return_value = mock_test_result mock_construct_test_result = Mock() with ExitStack() as stack: load_tests = stack.enter_context( patch.object(run, 'load_test_suite_from_name') ) fake_construct = stack.enter_context( patch.object(run, 'construct_test_result') ) configure_debug = stack.enter_context( patch.object(run, '_configure_debug_profile') ) config_timeout = stack.enter_context( patch.object(run, '_configure_timeout_profile') ) config_test_timeout = stack.enter_context( patch.object(run, '_configure_test_timeout') ) load_tests.return_value = (mock_test_suite, False) fake_construct.return_value = mock_construct_test_result program.run() config_timeout.assert_called_once_with(fake_args) configure_debug.assert_called_once_with(fake_args) config_test_timeout.assert_called_once_with(fake_args) fake_construct.assert_called_once_with(fake_args) load_tests.assert_called_once_with(fake_args.suite) def test_dont_run_when_zero_tests_loaded(self): fake_args = create_default_run_args() program = run.TestProgram(fake_args) with patch('sys.stdout', new=StringIO()): self.assertRaisesRegexp(RuntimeError, 'Did not find any tests', program.run) def create_default_run_args(**kwargs): """Create a an argparse.Namespace object containing arguments required to make autopilot.run.TestProgram run a suite of tests. Every feature that can be turned off will be. Individual arguments can be specified with keyword arguments to this function. """ defaults = dict( random_order=False, debug_profile='normal', timeout_profile='normal', record_directory='', record=False, record_options='', verbose=False, mode='run', suite='foo', test_config='', test_timeout=0, ) defaults.update(kwargs) return Namespace(**defaults) ./autopilot/tests/unit/test_version_utility_fns.py0000644000004100000410000000661314002063564023141 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import patch import subprocess from testtools import TestCase from testtools.matchers import Equals from autopilot._info import ( _get_package_installed_version, _get_package_version, get_version_string, ) class VersionFnTests(TestCase): def test_package_version_returns_none_when_running_from_source(self): """_get_package_version must return None if we're not running in the system. """ with patch('autopilot._info._running_in_system', new=lambda: False): self.assertThat(_get_package_version(), Equals(None)) def test_get_package_installed_version_returns_None_on_error(self): """The _get_package_installed_version function must return None when subprocess raises an error while calling dpkg-query. """ def raise_error(*args, **kwargs): raise subprocess.CalledProcessError(1, "dpkg-query") with patch('subprocess.check_output', new=raise_error): self.assertThat(_get_package_installed_version(), Equals(None)) def test_get_package_installed_version_strips_command_output(self): """The _get_package_installed_version function must strip the output of the dpkg-query function. """ with patch('subprocess.check_output', new=lambda *a, **kwargs: "1.3daily13.05.22\n"): self.assertThat( _get_package_installed_version(), Equals("1.3daily13.05.22")) def test_get_version_string_shows_source_version(self): """The get_version_string function must only show the source version if the system version returns None. """ with patch('autopilot._info._get_package_version', new=lambda: None): with patch('autopilot._info._get_source_version', new=lambda: "1.3.1"): version_string = get_version_string() self.assertThat( version_string, Equals("Autopilot Source Version: 1.3.1")) def test_get_version_string_shows_both_versions(self): """The get_version_string function must show both source and package versions, when the package version is avaialble.capitalize """ with patch('autopilot._info._get_package_version', new=lambda: "1.3.1daily13.05.22"): with patch('autopilot._info._get_source_version', new=lambda: "1.3.1"): version_string = get_version_string() self.assertThat( version_string, Equals("Autopilot Source Version: 1.3.1\nAutopilot Package " "Version: 1.3.1daily13.05.22")) ./autopilot/tests/unit/test_display.py0000644000004100000410000000230514002063564020462 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2016 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import unittest from unittest.mock import patch from autopilot.display import Display class DisplayTestCase(unittest.TestCase): @patch('autopilot.display._pick_backend') def test_input_backends_default_order(self, pick_backend): d = Display() d.create() backends = list(pick_backend.call_args[0][0].items()) self.assertTrue(backends[0][0] == 'X11') self.assertTrue(backends[1][0] == 'UPA') ./autopilot/tests/unit/test_start_final_events.py0000644000004100000410000000650314002063564022713 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from testtools import TestCase from testtools.matchers import Equals, Contains from unittest.mock import patch, Mock from autopilot.utilities import ( CleanupRegistered, _cleanup_objects, action_on_test_start, action_on_test_end, ) from autopilot.utilities import on_test_started class StartFinalExecutionTests(TestCase): def test_conformant_class_is_added(self): class Conformant(CleanupRegistered): pass self.assertThat(_cleanup_objects, Contains(Conformant)) def test_on_test_start_and_end_methods_called(self): class Conformant(CleanupRegistered): """This class defines the classmethods to be called at test start/end. """ _on_start_test = False _on_end_test = False @classmethod def on_test_start(cls, test_instance): cls._on_start_test = True @classmethod def on_test_end(cls, test_instance): cls._on_end_test = True class InnerTest(TestCase): def setUp(self): super().setUp() on_test_started(self) def test_foo(self): pass test_run = InnerTest('test_foo').run() InnerTest('test_foo').run() self.assertThat(test_run.wasSuccessful(), Equals(True)) self.assertThat(Conformant._on_start_test, Equals(True)) self.assertThat(Conformant._on_end_test, Equals(True)) def test_action_on_test_start_reports_raised_exception(self): """Any Exceptions raised during action_on_test_start must be caught and reported. """ class Cleanup(object): def on_test_start(self, test_instance): raise IndexError obj = Cleanup() mockTestCase = Mock(spec=TestCase) with patch('autopilot.utilities._cleanup_objects', new=[obj]): action_on_test_start(mockTestCase) self.assertThat(mockTestCase._report_traceback.call_count, Equals(1)) def test_action_on_test_end_reports_raised_exception(self): """Any Exceptions raised during action_on_test_end must be caught and reported. """ class Cleanup(object): def on_test_end(self, test_instance): raise IndexError obj = Cleanup() mockTestCase = Mock(spec=TestCase) with patch('autopilot.utilities._cleanup_objects', new=[obj]): action_on_test_end(mockTestCase) self.assertThat(mockTestCase._report_traceback.call_count, Equals(1)) ./autopilot/tests/unit/test_introspection_object_registry.py0000644000004100000410000003205414002063564025177 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from unittest.mock import patch from testtools import TestCase from testtools.matchers import ( Equals, raises, ) from autopilot.introspection import CustomEmulatorBase from autopilot.introspection import _object_registry as object_registry from autopilot.introspection import _xpathselect as xpathselect class MakeIntrospectionObjectTests(TestCase): class DefaultSelector(CustomEmulatorBase): pass class AlwaysSelected(CustomEmulatorBase): @classmethod def validate_dbus_object(cls, path, state): """Validate always. :returns: True """ return True class NeverSelected(CustomEmulatorBase): @classmethod def validate_dbus_object(cls, path, state): """Validate never. :returns: False """ return False @patch.object(object_registry, '_try_custom_proxy_classes') @patch.object(object_registry, '_get_default_proxy_class') def test_get_proxy_object_class_return_from_list(self, gdpc, tcpc): """_get_proxy_object_class should return the value of _try_custom_proxy_classes if there is one.""" token = self.getUniqueString() tcpc.return_value = token gpoc_return = object_registry._get_proxy_object_class( "fake_id", # cannot set to none. None, None ) self.assertThat(gpoc_return, Equals(token)) self.assertFalse(gdpc.called) @patch.object(object_registry, '_try_custom_proxy_classes') def test_get_proxy_object_class_send_right_args(self, tcpc): """_get_proxy_object_class should send the right arguments to _try_custom_proxy_classes.""" class_dict = {'DefaultSelector': self.DefaultSelector} path = '/path/to/DefaultSelector' state = {} object_registry._get_proxy_object_class(class_dict, path, state) tcpc.assert_called_once_with(class_dict, path, state) @patch.object(object_registry, '_try_custom_proxy_classes') def test_get_proxy_object_class_not_handle_error(self, tcpc): """_get_proxy_object_class should not handle an exception raised by _try_custom_proxy_classes.""" tcpc.side_effect = ValueError self.assertThat( lambda: object_registry._get_proxy_object_class( "None", # Cannot be set to None, but don't care about value None, None ), raises(ValueError)) @patch.object(object_registry, '_try_custom_proxy_classes') @patch.object(object_registry, '_get_default_proxy_class') @patch.object(object_registry, 'get_classname_from_path') def test_get_proxy_object_class_call_default_call(self, gcfp, gdpc, tcpc): """_get_proxy_object_class should call _get_default_proxy_class if _try_custom_proxy_classes returns None.""" tcpc.return_value = None object_registry._get_proxy_object_class(None, None, None) self.assertTrue(gdpc.called) @patch.object(object_registry, '_try_custom_proxy_classes') @patch.object(object_registry, '_get_default_proxy_class') def test_get_proxy_object_class_default_args(self, gdpc, tcpc): """_get_proxy_object_class should pass the correct arguments to _get_default_proxy_class""" tcpc.return_value = None obj_id = 123 path = '/path/to/DefaultSelector' object_registry._get_proxy_object_class(obj_id, path, None) gdpc.assert_called_once_with( obj_id, xpathselect.get_classname_from_path(path) ) @patch.object(object_registry, '_try_custom_proxy_classes') @patch.object(object_registry, '_get_default_proxy_class') @patch.object(object_registry, 'get_classname_from_path') def test_get_proxy_object_class_default(self, gcfp, gdpc, tcpc): """_get_proxy_object_class should return the value of _get_default_proxy_class if _try_custom_proxy_classes returns None.""" token = self.getUniqueString() gdpc.return_value = token tcpc.return_value = None gpoc_return = object_registry._get_proxy_object_class( None, None, None, ) self.assertThat(gpoc_return, Equals(token)) def test_try_custom_proxy_classes_zero_results(self): """_try_custom_proxy_classes must return None if no classes match.""" proxy_class_dict = {'NeverSelected': self.NeverSelected} fake_id = self.getUniqueInteger() path = '/path/to/NeverSelected' state = {} with object_registry.patch_registry({fake_id: proxy_class_dict}): class_type = object_registry._try_custom_proxy_classes( fake_id, path, state ) self.assertThat(class_type, Equals(None)) def test_try_custom_proxy_classes_one_result(self): """_try_custom_proxy_classes must return the matching class if there is exacly 1.""" proxy_class_dict = {'DefaultSelector': self.DefaultSelector} fake_id = self.getUniqueInteger() path = '/path/to/DefaultSelector' state = {} with object_registry.patch_registry({fake_id: proxy_class_dict}): class_type = object_registry._try_custom_proxy_classes( fake_id, path, state ) self.assertThat(class_type, Equals(self.DefaultSelector)) def test_try_custom_proxy_classes_two_results(self): """_try_custom_proxy_classes must raise ValueError if multiple classes match.""" proxy_class_dict = {'DefaultSelector': self.DefaultSelector, 'AlwaysSelected': self.AlwaysSelected} path = '/path/to/DefaultSelector' state = {} object_id = self.getUniqueInteger() with object_registry.patch_registry({object_id: proxy_class_dict}): self.assertThat( lambda: object_registry._try_custom_proxy_classes( object_id, path, state ), raises(ValueError) ) @patch('autopilot.introspection._object_registry.get_debug_logger') def test_get_default_proxy_class_logging(self, gdl): """_get_default_proxy_class should log a message.""" object_registry._get_default_proxy_class(self.DefaultSelector, "None") gdl.assert_called_once_with() def test_get_default_proxy_class_base(self): """Subclass must return an emulator of base class.""" class SubclassedProxy(self.DefaultSelector): pass result = object_registry._get_default_proxy_class( SubclassedProxy, 'Object' ) self.assertTrue(result, Equals(self.DefaultSelector)) def test_get_default_proxy_class_base_instead_of_self(self): """Subclass must not use self if base class works.""" class SubclassedProxy(self.DefaultSelector): pass result = object_registry._get_default_proxy_class( SubclassedProxy, 'Object' ) self.assertFalse(issubclass(result, SubclassedProxy)) def test_get_default_proxy_class(self): """Must default to own class if no usable bases present.""" result = object_registry._get_default_proxy_class( self.DefaultSelector, 'Object' ) self.assertTrue(result, Equals(self.DefaultSelector)) def test_get_default_proxy_name(self): """Must default to own class if no usable bases present.""" token = self.getUniqueString() result = object_registry._get_default_proxy_class( self.DefaultSelector, token ) self.assertThat(result.__name__, Equals(token)) class ObjectRegistryPatchTests(TestCase): def test_patch_registry_sets_new_registry(self): new_registry = dict(foo=123) with object_registry.patch_registry(new_registry): self.assertEqual(object_registry._object_registry, new_registry) def test_patch_registry_undoes_patch(self): old_registry = object_registry._object_registry.copy() with object_registry.patch_registry({}): pass self.assertEqual(object_registry._object_registry, old_registry) def test_patch_registry_undoes_patch_when_exception_raised(self): def patch_reg(): with object_registry.patch_registry({}): raise RuntimeError() old_registry = object_registry._object_registry.copy() try: patch_reg() except RuntimeError: pass self.assertEqual(object_registry._object_registry, old_registry) def test_patch_registry_reraised_caught_exception(self): def patch_reg(): with object_registry.patch_registry({}): raise RuntimeError() self.assertThat(patch_reg, raises(RuntimeError())) def test_modifications_are_unwound(self): token = self.getUniqueString() with object_registry.patch_registry(dict()): object_registry._object_registry[token] = token self.assertFalse(token in object_registry._object_registry) class CombineBasesTests(TestCase): def test_returns_original_if_no_extensions(self): class Base(): pass class Sub(Base): pass self.assertEqual( object_registry._combine_base_and_extensions(Sub, ()), (Base,) ) def test_returns_addition_of_extension_classes(self): class Base(): pass class Sub(Base): pass class Ext1(): pass class Ext2(): pass mixed = object_registry._combine_base_and_extensions(Sub, (Ext1, Ext2)) self.assertIn(Ext1, mixed) self.assertIn(Ext2, mixed) def test_excludes_duplication_of_base_class_within_extension_classes(self): class Base(): pass class Sub(Base): pass class Ext1(): pass class Ext2(Ext1): pass self.assertEqual( object_registry._combine_base_and_extensions(Sub, (Ext2, Base)), (Ext2, Base,) ) def test_excludes_addition_of_extension_base_classes(self): class Base(): pass class Sub(Base): pass class Ext1(): pass class Ext2(Ext1): pass self.assertNotIn( object_registry._combine_base_and_extensions(Sub, (Ext2,)), Ext1 ) def test_excludes_addition_of_subject_class(self): class Base(): pass class Sub(Base): pass class Ext1(): pass self.assertEqual( object_registry._combine_base_and_extensions(Sub, (Ext1, Sub)), (Ext1, Base) ) def test_maintains_mro_order(self): class Base(): pass class Sub1(Base): pass class Sub2(Sub1, Base): pass class Ext1(): pass mixed = object_registry._combine_base_and_extensions(Sub2, (Ext1,)) self.assertLess( mixed.index(Sub1), mixed.index(Base) ) class MROSortOrderTests(TestCase): def test_returns_lower_values_for_lower_classes_in_mro(self): class Parent(): pass class Child(Parent): pass class GrandChild(Child): pass self.assertGreater( object_registry._get_mro_sort_order(Child), object_registry._get_mro_sort_order(Parent), ) self.assertGreater( object_registry._get_mro_sort_order(GrandChild), object_registry._get_mro_sort_order(Child), ) def test_return_higher_values_for_promoted_collections_in_ties(self): class Parent1(): pass class Child1(Parent1): pass class Parent2(): pass class Child2(Parent2): pass promoted_collection = (Parent1, Child1) self.assertGreater( object_registry._get_mro_sort_order( Child1, promoted_collection), object_registry._get_mro_sort_order( Child2, promoted_collection), ) ./autopilot/tests/unit/test_logging.py0000644000004100000410000000661014002063564020446 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging from unittest.mock import Mock import testtools from autopilot import tests from autopilot.logging import log_action from autopilot._logging import TestCaseLoggingFixture class ObjectWithLogDecorator(object): @log_action(logging.info) def do_something_without_docstring(self, *args, **kwargs): pass @log_action(logging.info) def do_something_with_docstring(self, *args, **kwargs): """Do something with docstring.""" pass @log_action(logging.info) def do_something_with_multiline_docstring(self, *args, **kwargs): """Do something with a multiline docstring. This should not be logged. """ pass class LoggingTestCase(tests.LogHandlerTestCase): def setUp(self): super(LoggingTestCase, self).setUp() self.root_logger.setLevel(logging.INFO) self.logged_object = ObjectWithLogDecorator() def test_logged_action_without_docstring(self): self.logged_object.do_something_without_docstring( 'arg1', 'arg2', arg3='arg3', arg4='arg4') self.assertLogLevelContains( 'INFO', "ObjectWithLogDecorator: do_something_without_docstring. " "Arguments ('arg1', 'arg2'). " "Keyword arguments: {'arg3': 'arg3', 'arg4': 'arg4'}.") def test_logged_action_with_docstring(self): self.logged_object.do_something_with_docstring( 'arg1', 'arg2', arg3='arg3', arg4='arg4') self.assertLogLevelContains( 'INFO', "ObjectWithLogDecorator: Do something with docstring. " "Arguments ('arg1', 'arg2'). " "Keyword arguments: {'arg3': 'arg3', 'arg4': 'arg4'}.") def test_logged_action_with_multiline_docstring(self): self.logged_object.do_something_with_multiline_docstring( 'arg1', 'arg2', arg3='arg3', arg4='arg4') self.assertLogLevelContains( 'INFO', "ObjectWithLogDecorator: " "Do something with a multiline docstring. " "Arguments ('arg1', 'arg2'). " "Keyword arguments: {'arg3': 'arg3', 'arg4': 'arg4'}.") class TestCaseLoggingFixtureTests(testtools.TestCase): def test_test_log_is_added(self): token = self.getUniqueString() add_detail_fn = Mock() fixture = TestCaseLoggingFixture("Test.id", add_detail_fn) fixture.setUp() logging.getLogger(__name__).info(token) fixture.cleanUp() self.assertEqual(1, add_detail_fn.call_count) self.assertEqual('test-log', add_detail_fn.call_args[0][0]) self.assertIn(token, add_detail_fn.call_args[0][1].as_text()) ./autopilot/tests/unit/test_platform.py0000644000004100000410000002310514002063564020642 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Tests for the autopilot platform code.""" from io import StringIO from testtools import TestCase from testtools.matchers import Equals from tempfile import NamedTemporaryFile from unittest.mock import patch import autopilot.platform as platform class PublicAPITests(TestCase): def setUp(self): super().setUp() @patch('autopilot.platform._PlatformDetector') def test_model_creates_platform_detector(self, mock_detector): platform.model() mock_detector.create.assert_called_once_with() @patch('autopilot.platform._PlatformDetector._cached_detector') def test_model_returns_correct_value(self, mock_detector): mock_detector.model = "test123" self.assertThat(platform.model(), Equals('test123')) @patch('autopilot.platform._PlatformDetector') def test_image_codename_creates_platform_detector(self, mock_detector): platform.image_codename() mock_detector.create.assert_called_once_with() @patch('autopilot.platform._PlatformDetector._cached_detector') def test_image_codename_returns_correct_value(self, mock_detector): mock_detector.image_codename = "test123" self.assertThat(platform.image_codename(), Equals('test123')) class PlatformGetProcessNameTests(TestCase): def test_returns_callable_value(self): test_callable = lambda: "foo" self.assertEqual("foo", platform._get_process_name(test_callable)) def test_returns_string(self): self.assertEqual("foo", platform._get_process_name("foo")) def test_raises_exception_if_unsupported_passed(self): self.assertRaises(ValueError, platform._get_process_name, 0) class PlatformDetectorTests(TestCase): def setUp(self): super(PlatformDetectorTests, self).setUp() # platform detector is cached, so make sure we destroy the cache before # each test runs, and after each test completes. self._destroy_platform_detector_cache() self.addCleanup(self._destroy_platform_detector_cache) def _destroy_platform_detector_cache(self): platform._PlatformDetector._cached_detector = None def test_platform_detector_is_cached(self): """Test that the platform detector is only created once.""" detector1 = platform._PlatformDetector.create() detector2 = platform._PlatformDetector.create() self.assertThat(id(detector1), Equals(id(detector2))) @patch('autopilot.platform._get_property_file') def test_default_model(self, mock_get_property_file): """The default model name must be 'Desktop'.""" mock_get_property_file.return_value = None detector = platform._PlatformDetector.create() self.assertThat(detector.model, Equals('Desktop')) @patch('autopilot.platform._get_property_file') def test_default_image_codename(self, mock_get_property_file): """The default image codename must be 'Desktop'.""" mock_get_property_file.return_value = None detector = platform._PlatformDetector.create() self.assertThat(detector.image_codename, Equals('Desktop')) @patch('autopilot.platform._get_property_file') def test_model_is_set_from_property_file(self, mock_get_property_file): """Detector must read product model from android properties file.""" mock_get_property_file.return_value = StringIO( "ro.product.model=test123") detector = platform._PlatformDetector.create() self.assertThat(detector.model, Equals('test123')) @patch('autopilot.platform._get_property_file', new=lambda: StringIO("")) def test_model_has_default_when_not_in_property_file(self): """Detector must use 'Desktop' as a default value for the model name when the property file exists, but does not contain a model description. """ detector = platform._PlatformDetector.create() self.assertThat(detector.model, Equals('Desktop')) @patch('autopilot.platform._get_property_file') def test_product_codename_is_set_from_property_file( self, mock_get_property_file): """Detector must read product model from android properties file.""" mock_get_property_file.return_value = StringIO( "ro.product.name=test123") detector = platform._PlatformDetector.create() self.assertThat(detector.image_codename, Equals('test123')) @patch('autopilot.platform._get_property_file', new=lambda: StringIO("")) def test_product_codename_has_default_when_not_in_property_file(self): """Detector must use 'Desktop' as a default value for the product codename when the property file exists, but does not contain a model description. """ detector = platform._PlatformDetector.create() self.assertThat(detector.image_codename, Equals('Desktop')) def test_has_correct_file_name(self): observed = platform._get_property_file_path() self.assertEqual("/system/build.prop", observed) def test_get_property_file_opens_path(self): token = self.getUniqueString() with NamedTemporaryFile(mode='w+') as f: f.write(token) f.flush() with patch('autopilot.platform._get_property_file_path') as p: p.return_value = f.name observed = platform._get_property_file().read() self.assertEqual(token, observed) @patch('autopilot.platform._get_property_file') def test_get_tablet_from_property_file( self, mock_get_property_file): """Detector must read tablet from android properties file.""" mock_get_property_file.return_value = StringIO( "ro.build.characteristics=tablet") detector = platform._PlatformDetector.create() self.assertThat(detector.is_tablet, Equals(True)) @patch('autopilot.platform._get_property_file') def test_get_not_tablet_from_property_file( self, mock_get_property_file): """Detector must read lack of tablet from android properties file.""" mock_get_property_file.return_value = StringIO( "ro.build.characteristics=nosdcard") detector = platform._PlatformDetector.create() self.assertThat(detector.is_tablet, Equals(False)) @patch('autopilot.platform._get_property_file') def test_tablet_without_property_file(self, mock_get_property_file): """Detector must return False for tablet when there is no properties file. """ mock_get_property_file.return_value = None detector = platform._PlatformDetector.create() self.assertThat(detector.is_tablet, Equals(False)) class BuildPropertyParserTests(TestCase): """Tests for the android build properties file parser.""" def test_empty_file_returns_empty_dictionary(self): """An empty file must result in an empty dictionary.""" prop_file = StringIO("") properties = platform._parse_build_properties_file(prop_file) self.assertThat(len(properties), Equals(0)) def test_whitespace_is_ignored(self): """Whitespace in build file must be ignored.""" prop_file = StringIO("\n\n\n\n\n") properties = platform._parse_build_properties_file(prop_file) self.assertThat(len(properties), Equals(0)) def test_comments_are_ignored(self): """Comments in build file must be ignored.""" prop_file = StringIO("# Hello World\n #Hello Again\n#####") properties = platform._parse_build_properties_file(prop_file) self.assertThat(len(properties), Equals(0)) def test_invalid_lines_are_ignored(self): """lines without ana ssignment must be ignored.""" prop_file = StringIO("Hello") properties = platform._parse_build_properties_file(prop_file) self.assertThat(len(properties), Equals(0)) def test_simple_value(self): """Test a simple a=b expression.""" prop_file = StringIO("a=b") properties = platform._parse_build_properties_file(prop_file) self.assertThat(properties, Equals(dict(a='b'))) def test_multiple_values(self): """Test several expressions over multiple lines.""" prop_file = StringIO("a=b\nb=23") properties = platform._parse_build_properties_file(prop_file) self.assertThat(properties, Equals(dict(a='b', b='23'))) def test_values_with_equals_in_them(self): """Test that we can parse values with a '=' in them.""" prop_file = StringIO("a=b=c") properties = platform._parse_build_properties_file(prop_file) self.assertThat(properties, Equals(dict(a='b=c'))) def test_dotted_values_work(self): """Test that we can use dotted values as the keys.""" prop_file = StringIO("ro.product.model=maguro") properties = platform._parse_build_properties_file(prop_file) self.assertThat(properties, Equals({'ro.product.model': 'maguro'})) ./autopilot/tests/unit/test_test_fixtures.py0000644000004100000410000002022314002063564021724 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from autopilot.tests.functional.fixtures import ( ExecutableScript, Timezone, TempDesktopFile, ) import os import os.path import stat from unittest.mock import patch from shutil import rmtree import tempfile from testtools import TestCase from testtools.matchers import Contains, EndsWith, Equals, FileContains class TempDesktopFileTests(TestCase): def test_setUp_creates_desktop_file(self): desktop_file_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, desktop_file_dir) with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): temp_desktop_file = TempDesktopFile() temp_desktop_file.setUp() desktop_file_path = temp_desktop_file.get_desktop_file_path() self.assertTrue(os.path.exists(desktop_file_path)) temp_desktop_file.cleanUp() self.assertFalse(os.path.exists(desktop_file_path)) def test_desktop_file_dir_returns_expected_directory(self): expected_directory = os.path.join( os.getenv('HOME'), '.local', 'share', 'applications' ) self.assertThat( TempDesktopFile._desktop_file_dir(), Equals(expected_directory) ) def test_ensure_desktop_dir_exists_returns_empty_string_when_exists(self): desktop_file_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, desktop_file_dir) with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): self.assertThat( TempDesktopFile._ensure_desktop_dir_exists(), Equals("") ) def test_ensure_desktop_dir_exists_creates_dir_when_needed(self): tmp_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, tmp_dir) desktop_file_dir = os.path.join( tmp_dir, '.local', 'share', 'applications' ) with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): TempDesktopFile._ensure_desktop_dir_exists() self.assertTrue(os.path.exists(desktop_file_dir)) def test_ensure_desktop_dir_exists_returns_path_to_delete(self): tmp_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, tmp_dir) desktop_file_dir = os.path.join( tmp_dir, '.local', 'share', 'applications' ) expected_to_remove = os.path.join(tmp_dir, '.local') with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): self.assertThat( TempDesktopFile._ensure_desktop_dir_exists(), Equals(expected_to_remove) ) def test_create_desktop_file_dir_returns_path_to_delete(self): tmp_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, tmp_dir) desktop_file_dir = os.path.join( tmp_dir, '.local', 'share', 'applications' ) expected_to_remove = os.path.join(tmp_dir, '.local') self.assertThat( TempDesktopFile._create_desktop_file_dir(desktop_file_dir), Equals(expected_to_remove) ) def test_create_desktop_file_dir_returns_empty_str_when_path_exists(self): desktop_file_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, desktop_file_dir) self.assertThat( TempDesktopFile._create_desktop_file_dir(desktop_file_dir), Equals("") ) def test_remove_desktop_file_removes_created_file_when_path_exists(self): test_created_path = self.getUniqueString() with patch('autopilot.tests.functional.fixtures.rmtree') as p_rmtree: TempDesktopFile._remove_desktop_file_components( test_created_path, "" ) p_rmtree.assert_called_once_with(test_created_path) def test_remove_desktop_file_removes_created_path(self): test_created_file = self.getUniqueString() with patch('autopilot.tests.functional.os.remove') as p_remove: TempDesktopFile._remove_desktop_file_components( "", test_created_file ) p_remove.assert_called_once_with(test_created_file) def test_create_desktop_file_creates_file_in_correct_place(self): desktop_file_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, desktop_file_dir) with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): desktop_file = TempDesktopFile._create_desktop_file("") path, head = os.path.split(desktop_file) self.assertThat(path, Equals(desktop_file_dir)) def test_create_desktop_file_writes_correct_data(self): desktop_file_dir = tempfile.mkdtemp(dir="/tmp") self.addCleanup(rmtree, desktop_file_dir) token = self.getUniqueString() with patch.object( TempDesktopFile, '_desktop_file_dir', return_value=desktop_file_dir ): desktop_file = TempDesktopFile._create_desktop_file(token) self.assertTrue(desktop_file.endswith('.desktop')) self.assertThat(desktop_file, FileContains(token)) def do_parameter_contents_test(self, matcher, **kwargs): fixture = self.useFixture(TempDesktopFile(**kwargs)) self.assertThat( fixture.get_desktop_file_path(), FileContains(matcher=matcher), ) def test_can_specify_exec_path(self): token = self.getUniqueString() self.do_parameter_contents_test( Contains("Exec="+token), exec_=token ) def test_can_specify_type(self): token = self.getUniqueString() self.do_parameter_contents_test( Contains("Type="+token), type=token ) def test_can_specify_name(self): token = self.getUniqueString() self.do_parameter_contents_test( Contains("Name="+token), name=token ) def test_can_specify_icon(self): token = self.getUniqueString() self.do_parameter_contents_test( Contains("Icon="+token), icon=token ) class ExecutableScriptTests(TestCase): def test_creates_file_with_content(self): token = self.getUniqueString() fixture = self.useFixture(ExecutableScript(script=token)) self.assertThat(fixture.path, FileContains(token)) def test_creates_file_with_correct_extension(self): token = self.getUniqueString() fixture = self.useFixture(ExecutableScript(script="", extension=token)) self.assertThat(fixture.path, EndsWith(token)) def test_creates_file_with_execute_bit_set(self): fixture = self.useFixture(ExecutableScript(script="")) self.assertTrue(os.stat(fixture.path).st_mode & stat.S_IXUSR) class TimezoneFixtureTests(TestCase): def test_sets_environment_variable_to_timezone(self): token = self.getUniqueString() self.useFixture(Timezone(token)) self.assertEqual(os.environ.get('TZ'), token) def test_resets_timezone_back_to_original(self): original_tz = os.environ.get('TZ', None) token = self.getUniqueString() with Timezone(token): pass # Trigger cleanup self.assertEqual(os.environ.get('TZ', None), original_tz) ./autopilot/tests/unit/test_types.py0000644000004100000410000007347414002063564020200 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from datetime import datetime, time from testscenarios import TestWithScenarios, multiply_scenarios from testtools import TestCase from testtools.matchers import Equals, IsInstance, NotEquals, raises import dbus from unittest.mock import patch, Mock from autopilot.tests.functional.fixtures import Timezone from autopilot.introspection.types import ( Color, create_value_instance, DateTime, PlainType, Point, Point3D, Rectangle, Size, Time, ValueType, _integer_repr, _boolean_repr, _text_repr, _bytes_repr, _dict_repr, _list_repr, _float_repr, _tuple_repr, _get_repr_callable_for_value_class, _boolean_str, _integer_str, ) from autopilot.introspection.dbus import DBusIntrospectionObject from autopilot.utilities import compatible_repr from dateutil import tz class PlainTypeTests(TestWithScenarios, TestCase): scenarios = [ ('bool true', dict(t=dbus.Boolean, v=True)), ('bool false', dict(t=dbus.Boolean, v=False)), ('byte', dict(t=dbus.Byte, v=12)), ('int16 +ve', dict(t=dbus.Int16, v=123)), ('int16 -ve', dict(t=dbus.Int16, v=-23000)), ('int32 +ve', dict(t=dbus.Int32, v=30000000)), ('int32 -ve', dict(t=dbus.Int32, v=-3002050)), ('int64 +ve', dict(t=dbus.Int64, v=9223372036854775807)), ('int64 -ve', dict(t=dbus.Int64, v=-9223372036854775807)), ('ascii string', dict(t=dbus.String, v="Hello World")), ('unicode string', dict(t=dbus.String, v="\u2603")), ('bytearray', dict(t=dbus.ByteArray, v=b"Hello World")), ('object path', dict(t=dbus.ObjectPath, v="/path/to/object")), ('dbus signature', dict(t=dbus.Signature, v="is")), ('dictionary', dict(t=dbus.Dictionary, v={'hello': 'world'})), ('double', dict(t=dbus.Double, v=3.1415)), ('struct', dict(t=dbus.Struct, v=('some', 42, 'value'))), ('array', dict(t=dbus.Array, v=['some', 42, 'value'])), ] def test_can_construct(self): p = PlainType(self.t(self.v)) self.assertThat(p, Equals(self.v)) self.assertThat(hasattr(p, 'wait_for'), Equals(True)) self.assertThat(p, IsInstance(self.t)) def test_repr(self): """repr for PlainType must be the same as the pythonic type.""" p = PlainType(self.t(self.v)) expected = repr(self.v) expected = expected.rstrip('L') self.assertThat(repr(p), Equals(expected)) def test_str(self): """str(p) for PlainType must be the same as the pythonic type.""" p = PlainType(self.t(self.v)) expected = str(self.v) observed = str(p) self.assertEqual(expected, observed) def test_wait_for_raises_RuntimeError(self): """The wait_for method must raise a RuntimeError if it's called.""" p = PlainType(self.t(self.v)) self.assertThat( lambda: p.wait_for(object()), raises(RuntimeError( "This variable was not constructed as part of " "an object. The wait_for method cannot be used." )) ) class RectangleTypeTests(TestCase): def test_can_construct_rectangle(self): r = Rectangle(1, 2, 3, 4) self.assertThat(r, IsInstance(dbus.Array)) def test_rectangle_has_xywh_properties(self): r = Rectangle(1, 2, 3, 4) self.assertThat(r.x, Equals(1)) self.assertThat(r.y, Equals(2)) self.assertThat(r.w, Equals(3)) self.assertThat(r.width, Equals(3)) self.assertThat(r.h, Equals(4)) self.assertThat(r.height, Equals(4)) def test_rectangle_has_slice_access(self): r = Rectangle(1, 2, 3, 4) self.assertThat(r[0], Equals(1)) self.assertThat(r[1], Equals(2)) self.assertThat(r[2], Equals(3)) self.assertThat(r[3], Equals(4)) def test_equality_with_rectangle(self): r1 = Rectangle(1, 2, 3, 4) r2 = Rectangle(1, 2, 3, 4) self.assertThat(r1, Equals(r2)) def test_equality_with_list(self): r1 = Rectangle(1, 2, 3, 4) r2 = [1, 2, 3, 4] self.assertThat(r1, Equals(r2)) def test_repr(self): expected = repr_type("Rectangle(1, 2, 3, 4)") observed = repr(Rectangle(1, 2, 3, 4)) self.assertEqual(expected, observed) def test_repr_equals_str(self): r = Rectangle(1, 2, 3, 4) self.assertEqual(repr(r), str(r)) class PointTypeTests(TestCase): def test_can_construct_point(self): r = Point(1, 2) self.assertThat(r, IsInstance(dbus.Array)) def test_point_has_xy_properties(self): r = Point(1, 2) self.assertThat(r.x, Equals(1)) self.assertThat(r.y, Equals(2)) def test_point_has_slice_access(self): r = Point(1, 2) self.assertThat(r[0], Equals(1)) self.assertThat(r[1], Equals(2)) def test_equality_with_point(self): p1 = Point(1, 2) p2 = Point(1, 2) self.assertThat(p1, Equals(p2)) def test_equality_with_list(self): p1 = Point(1, 2) p2 = [1, 2] self.assertThat(p1, Equals(p2)) def test_repr(self): expected = repr_type('Point(1, 2)') observed = repr(Point(1, 2)) self.assertEqual(expected, observed) def test_repr_equals_str(self): p = Point(1, 2) self.assertEqual(repr(p), str(p)) class SizeTypeTests(TestCase): def test_can_construct_size(self): r = Size(1, 2) self.assertThat(r, IsInstance(dbus.Array)) def test_size_has_wh_properties(self): r = Size(1, 2) self.assertThat(r.w, Equals(1)) self.assertThat(r.width, Equals(1)) self.assertThat(r.h, Equals(2)) self.assertThat(r.height, Equals(2)) def test_size_has_slice_access(self): r = Size(1, 2) self.assertThat(r[0], Equals(1)) self.assertThat(r[1], Equals(2)) def test_equality_with_size(self): s1 = Size(50, 100) s2 = Size(50, 100) self.assertThat(s1, Equals(s2)) def test_equality_with_list(self): s1 = Size(50, 100) s2 = [50, 100] self.assertThat(s1, Equals(s2)) def test_repr(self): expected = repr_type('Size(1, 2)') observed = repr(Size(1, 2)) self.assertEqual(expected, observed) def test_repr_equals_str(self): s = Size(3, 4) self.assertEqual(repr(s), str(s)) class ColorTypeTests(TestCase): def test_can_construct_color(self): r = Color(123, 234, 55, 255) self.assertThat(r, IsInstance(dbus.Array)) def test_color_has_rgba_properties(self): r = Color(123, 234, 55, 255) self.assertThat(r.red, Equals(123)) self.assertThat(r.green, Equals(234)) self.assertThat(r.blue, Equals(55)) self.assertThat(r.alpha, Equals(255)) def test_color_has_slice_access(self): r = Color(123, 234, 55, 255) self.assertThat(r[0], Equals(123)) self.assertThat(r[1], Equals(234)) self.assertThat(r[2], Equals(55)) self.assertThat(r[3], Equals(255)) def test_eqiality_with_color(self): c1 = Color(123, 234, 55, 255) c2 = Color(123, 234, 55, 255) self.assertThat(c1, Equals(c2)) def test_eqiality_with_list(self): c1 = Color(123, 234, 55, 255) c2 = [123, 234, 55, 255] self.assertThat(c1, Equals(c2)) def test_repr(self): expected = repr_type('Color(1, 2, 3, 4)') observed = repr(Color(1, 2, 3, 4)) self.assertEqual(expected, observed) def test_repr_equals_str(self): c = Color(255, 255, 255, 0) self.assertEqual(repr(c), str(c)) def unable_to_handle_timestamp(timestamp): """Return false if the platform can handle timestamps larger than 32bit limit. """ try: datetime.fromtimestamp(timestamp) return False except: return True class DateTimeCreationTests(TestCase): timestamp = 1405382400 # No significance, just a timestamp def test_can_construct_datetime(self): dt = DateTime(self.timestamp) self.assertThat(dt, IsInstance(dbus.Array)) def test_datetime_has_slice_access(self): dt = DateTime(self.timestamp) self.assertThat(dt[0], Equals(self.timestamp)) def test_datetime_has_properties(self): dt = DateTime(self.timestamp) self.assertTrue(hasattr(dt, 'timestamp')) self.assertTrue(hasattr(dt, 'year')) self.assertTrue(hasattr(dt, 'month')) self.assertTrue(hasattr(dt, 'day')) self.assertTrue(hasattr(dt, 'hour')) self.assertTrue(hasattr(dt, 'minute')) self.assertTrue(hasattr(dt, 'second')) def test_repr(self): # Use a well known timezone for comparison self.useFixture(Timezone('UTC')) dt = DateTime(self.timestamp) observed = repr(dt) expected = "DateTime({:%Y-%m-%d %H:%M:%S})".format( datetime.fromtimestamp(self.timestamp) ) self.assertEqual(expected, observed) def test_repr_equals_str(self): dt = DateTime(self.timestamp) self.assertEqual(repr(dt), str(dt)) def test_can_create_DateTime_using_large_timestamp(self): """Must be able to create a DateTime object using a timestamp larger than the 32bit time_t limit. """ # Use a well known timezone for comparison self.useFixture(Timezone('UTC')) large_timestamp = 2**32+1 dt = DateTime(large_timestamp) self.assertEqual(dt.year, 2106) self.assertEqual(dt.month, 2) self.assertEqual(dt.day, 7) self.assertEqual(dt.hour, 6) self.assertEqual(dt.minute, 28) self.assertEqual(dt.second, 17) self.assertEqual(dt.timestamp, large_timestamp) class DateTimeTests(TestWithScenarios, TestCase): timestamps = [ # This timestamp uncovered an issue during development. ('Explicit US/Pacific test', dict( timestamp=1090123200 )), ('September 2014', dict( timestamp=1411992000 )), ('NZ DST example', dict( timestamp=2047570047 )), ('Winter', dict( timestamp=1389744000 )), ('Summer', dict( timestamp=1405382400 )), ('32bit max', dict( timestamp=2**32+1 )), ('32bit limit', dict( timestamp=2983579200 )), ] timezones = [ ('UTC', dict( timezone='UTC' )), ('London', dict( timezone='Europe/London' )), ('New Zealand', dict( timezone='NZ', )), ('Pacific', dict( timezone='US/Pacific' )), ('Hongkong', dict( timezone='Hongkong' )), ('Moscow', dict( timezone='Europe/Moscow' )), ('Copenhagen', dict( timezone='Europe/Copenhagen', )), ] scenarios = multiply_scenarios(timestamps, timezones) def skip_if_timestamp_too_large(self, timestamp): if unable_to_handle_timestamp(self.timestamp): self.skip("Timestamp to large for platform time_t") def test_datetime_properties_have_correct_values(self): self.skip_if_timestamp_too_large(self.timestamp) self.useFixture(Timezone(self.timezone)) dt1 = DateTime(self.timestamp) dt2 = datetime.fromtimestamp(self.timestamp, tz.gettz()) self.assertThat(dt1.year, Equals(dt2.year)) self.assertThat(dt1.month, Equals(dt2.month)) self.assertThat(dt1.day, Equals(dt2.day)) self.assertThat(dt1.hour, Equals(dt2.hour)) self.assertThat(dt1.minute, Equals(dt2.minute)) self.assertThat(dt1.second, Equals(dt2.second)) self.assertThat(dt1.timestamp, Equals(dt2.timestamp())) def test_equality_with_datetime(self): self.skip_if_timestamp_too_large(self.timestamp) self.useFixture(Timezone(self.timezone)) dt1 = DateTime(self.timestamp) dt2 = datetime( dt1.year, dt1.month, dt1.day, dt1.hour, dt1.minute, dt1.second ) self.assertThat(dt1, Equals(dt2)) def test_equality_with_list(self): self.skip_if_timestamp_too_large(self.timestamp) self.useFixture(Timezone(self.timezone)) dt1 = DateTime(self.timestamp) dt2 = [self.timestamp] self.assertThat(dt1, Equals(dt2)) def test_equality_with_datetime_object(self): self.skip_if_timestamp_too_large(self.timestamp) self.useFixture(Timezone(self.timezone)) dt1 = DateTime(self.timestamp) dt2 = datetime.fromtimestamp(self.timestamp, tz.gettz()) dt3 = datetime.fromtimestamp(self.timestamp + 1, tz.gettz()) self.assertThat(dt1, Equals(dt2)) self.assertThat(dt1, NotEquals(dt3)) def test_can_convert_to_datetime(self): self.skip_if_timestamp_too_large(self.timestamp) dt1 = DateTime(self.timestamp) self.assertThat(dt1.datetime, IsInstance(datetime)) class TimeTests(TestCase): def test_can_construct_time(self): dt = Time(0, 0, 0, 0) self.assertThat(dt, IsInstance(dbus.Array)) def test_time_has_slice_access(self): dt = Time(0, 1, 2, 3) self.assertThat(dt[0], Equals(0)) self.assertThat(dt[1], Equals(1)) self.assertThat(dt[2], Equals(2)) self.assertThat(dt[3], Equals(3)) def test_time_has_properties(self): dt = Time(0, 1, 2, 3) self.assertThat(dt.hour, Equals(0)) self.assertThat(dt.minute, Equals(1)) self.assertThat(dt.second, Equals(2)) self.assertThat(dt.millisecond, Equals(3)) def test_equality_with_time(self): dt1 = Time(0, 1, 2, 3) dt2 = Time(0, 1, 2, 3) dt3 = Time(4, 1, 2, 3) self.assertThat(dt1, Equals(dt2)) self.assertThat(dt1, NotEquals(dt3)) def test_equality_with_real_time(self): dt1 = Time(2, 3, 4, 5) dt2 = time(2, 3, 4, 5000) dt3 = time(5, 4, 3, 2000) self.assertThat(dt1, Equals(dt2)) self.assertThat(dt1, NotEquals(dt3)) def test_can_convert_to_time(self): dt1 = Time(1, 2, 3, 4) self.assertThat(dt1.time, IsInstance(time)) def test_repr(self): expected = repr_type('Time(01:02:03.004)') observed = repr(Time(1, 2, 3, 4)) self.assertEqual(expected, observed) def test_repr_equals_str(self): t = Time(2, 3, 4, 5) self.assertEqual(repr(t), str(t)) class Point3DTypeTests(TestCase): def test_can_construct_point3d(self): r = Point3D(1, 2, 3) self.assertThat(r, IsInstance(dbus.Array)) def test_point3d_has_xyz_properties(self): r = Point3D(1, 2, 3) self.assertThat(r.x, Equals(1)) self.assertThat(r.y, Equals(2)) self.assertThat(r.z, Equals(3)) def test_point3d_has_slice_access(self): r = Point3D(1, 2, 3) self.assertThat(r[0], Equals(1)) self.assertThat(r[1], Equals(2)) self.assertThat(r[2], Equals(3)) def test_equality_with_point3d(self): p1 = Point3D(1, 2, 3) p2 = Point3D(1, 2, 3) self.assertThat(p1, Equals(p2)) def test_inequality_with_point3d(self): p1 = Point3D(1, 2, 3) p2 = Point3D(1, 2, 4) self.assertThat(p1, NotEquals(p2)) def test_equality_with_list(self): p1 = Point3D(1, 2, 3) p2 = [1, 2, 3] self.assertThat(p1, Equals(p2)) def test_inequality_with_list(self): p1 = Point3D(1, 2, 3) p2 = [1, 2, 4] self.assertThat(p1, NotEquals(p2)) def test_repr(self): expected = repr_type('Point3D(1, 2, 3)') observed = repr(Point3D(1, 2, 3)) self.assertEqual(expected, observed) def test_repr_equals_str(self): p3d = Point3D(1, 2, 3) self.assertEqual(repr(p3d), str(p3d)) class CreateValueInstanceTests(TestCase): """Tests to check that create_value_instance does the right thing.""" def test_plain_string(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.String("Hello")]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals("Hello")) self.assertThat(attr, IsInstance(PlainType)) def test_plain_boolean(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.Boolean(False)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(False)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_int16(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.Int16(-2**14)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(-2**14)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_int32(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.Int32(-2**30)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(-2**30)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_int64(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.Int64(-2**40)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(-2**40)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_uint16(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.UInt16(2**14)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(2**14)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_uint32(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.UInt32(2**30)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(2**30)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_uint64(self): data = dbus.Array([dbus.Int32(ValueType.PLAIN), dbus.UInt64(2**40)]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(2**40)) self.assertThat(attr, IsInstance(PlainType)) def test_plain_array(self): data = dbus.Array([ dbus.Int32(ValueType.PLAIN), dbus.Array([ dbus.String("Hello"), dbus.String("World") ]) ]) attr = create_value_instance(data, None, None) self.assertThat(attr, Equals(["Hello", "World"])) self.assertThat(attr, IsInstance(PlainType)) def test_rectangle(self): data = dbus.Array( [ dbus.Int32(ValueType.RECTANGLE), dbus.Int32(0), dbus.Int32(10), dbus.Int32(20), dbus.Int32(30), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Rectangle)) def test_invalid_rectangle(self): data = dbus.Array( [ dbus.Int32(ValueType.RECTANGLE), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Rectangle must be constructed with 4 arguments, not 1") )) def test_color(self): data = dbus.Array( [ dbus.Int32(ValueType.COLOR), dbus.Int32(10), dbus.Int32(20), dbus.Int32(230), dbus.Int32(255), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Color)) def test_invalid_color(self): data = dbus.Array( [ dbus.Int32(ValueType.COLOR), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Color must be constructed with 4 arguments, not 1") )) def test_point(self): data = dbus.Array( [ dbus.Int32(ValueType.POINT), dbus.Int32(0), dbus.Int32(10), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Point)) def test_invalid_point(self): data = dbus.Array( [ dbus.Int32(ValueType.POINT), dbus.Int32(0), dbus.Int32(0), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Point must be constructed with 2 arguments, not 3") )) def test_size(self): data = dbus.Array( [ dbus.Int32(ValueType.SIZE), dbus.Int32(0), dbus.Int32(10), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Size)) def test_invalid_size(self): data = dbus.Array( [ dbus.Int32(ValueType.SIZE), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Size must be constructed with 2 arguments, not 1") )) def test_date_time(self): data = dbus.Array( [ dbus.Int32(ValueType.DATETIME), dbus.Int32(0), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(DateTime)) def test_invalid_date_time(self): data = dbus.Array( [ dbus.Int32(ValueType.DATETIME), dbus.Int32(0), dbus.Int32(0), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("DateTime must be constructed with 1 arguments, not 3") )) def test_time(self): data = dbus.Array( [ dbus.Int32(ValueType.TIME), dbus.Int32(0), dbus.Int32(0), dbus.Int32(0), dbus.Int32(0), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Time)) def test_invalid_time(self): data = dbus.Array( [ dbus.Int32(ValueType.TIME), dbus.Int32(0), dbus.Int32(0), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Time must be constructed with 4 arguments, not 3") )) def test_unknown_type_id(self): """Unknown type Ids should result in a plain type, along with a log message. """ data = dbus.Array( [ dbus.Int32(543), dbus.Int32(0), dbus.Boolean(False), dbus.String("Hello World") ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(PlainType)) self.assertThat(attr, IsInstance(dbus.Array)) self.assertThat(attr, Equals([0, False, "Hello World"])) def test_invalid_no_data(self): data = dbus.Array( [ dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Cannot create attribute, no data supplied") )) def test_point3d(self): data = dbus.Array( [ dbus.Int32(ValueType.POINT3D), dbus.Int32(0), dbus.Int32(10), dbus.Int32(20), ] ) attr = create_value_instance(data, None, None) self.assertThat(attr, IsInstance(Point3D)) def test_invalid_point3d(self): data = dbus.Array( [ dbus.Int32(ValueType.POINT3D), dbus.Int32(0), dbus.Int32(0), ] ) fn = lambda: create_value_instance(data, None, None) self.assertThat(fn, raises( ValueError("Point3D must be constructed with 3 arguments, not 2") )) class DBusIntrospectionObjectTests(TestCase): @patch('autopilot.introspection.dbus._logger.warning') def test_dbus_introspection_object_logs_bad_data(self, error_logger): """The DBusIntrospectionObject class must log an error when it gets bad data from the autopilot backend. """ DBusIntrospectionObject( dict(foo=[0], id=[0, 42]), b'/some/dummy/path', Mock() ) error_logger.assert_called_once_with( "While constructing attribute '%s.%s': %s", "ProxyBase", "foo", "Cannot create attribute, no data supplied" ) class TypeReprTests(TestCase): def test_integer_repr(self): expected = repr_type('42') observed = _integer_repr(42) self.assertEqual(expected, observed) def test_dbus_int_types_all_work(self): expected = repr_type('42') int_types = ( dbus.Byte, dbus.Int16, dbus.Int32, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64, ) for t in int_types: observed = _integer_repr(t(42)) self.assertEqual(expected, observed) def test_get_repr_gets_integer_repr_for_all_integer_types(self): int_types = ( dbus.Byte, dbus.Int16, dbus.Int32, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64, ) for t in int_types: observed = _get_repr_callable_for_value_class(t) self.assertEqual(_integer_repr, observed) def test_boolean_repr_true(self): expected = repr_type('True') for values in (True, dbus.Boolean(True)): observed = _boolean_repr(True) self.assertEqual(expected, observed) def test_boolean_repr_false(self): expected = repr_type('False') for values in (False, dbus.Boolean(False)): observed = _boolean_repr(False) self.assertEqual(expected, observed) def test_get_repr_gets_boolean_repr_for_dbus_boolean_type(self): observed = _get_repr_callable_for_value_class(dbus.Boolean) self.assertEqual(_boolean_repr, observed) def test_text_repr_handles_dbus_string(self): unicode_text = "plɹoʍ ollǝɥ" observed = _text_repr(dbus.String(unicode_text)) self.assertEqual(repr(unicode_text), observed) def test_text_repr_handles_dbus_object_path(self): path = "/path/to/some/object" observed = _text_repr(dbus.ObjectPath(path)) self.assertEqual(repr(path), observed) def test_binry_repr_handles_dbys_byte_array(self): data = b'Some bytes' observed = _bytes_repr(dbus.ByteArray(data)) self.assertEqual(repr(data), observed) def test_get_repr_gets_bytes_repr_for_dbus_byte_array(self): observed = _get_repr_callable_for_value_class(dbus.ByteArray) self.assertEqual(_bytes_repr, observed) def test_dict_repr_handles_dbus_dictionary(self): token = dict(foo='bar') observed = _dict_repr(dbus.Dictionary(token)) self.assertEqual(repr(token), observed) def test_get_repr_gets_dict_repr_on_dbus_dictionary(self): observed = _get_repr_callable_for_value_class(dbus.Dictionary) self.assertEqual(_dict_repr, observed) def test_float_repr_handles_dbus_double(self): token = 1.2345 observed = _float_repr(token) self.assertEqual(repr(token), observed) def test_get_repr_gets_float_repr_on_dbus_double(self): observed = _get_repr_callable_for_value_class(dbus.Double) self.assertEqual(_float_repr, observed) def test_tuple_repr_handles_dbus_struct(self): data = (1, 2, 3) observed = _tuple_repr(dbus.Struct(data)) self.assertEqual(repr(data), observed) def test_get_repr_gets_tuple_repr_on_dbus_struct(self): observed = _get_repr_callable_for_value_class(dbus.Struct) self.assertEqual(_tuple_repr, observed) def test_list_repr_handles_dbus_array(self): data = [1, 2, 3] observed = _list_repr(dbus.Array(data)) self.assertEqual(repr(data), observed) def test_get_repr_gets_list_repr_on_dbus_array(self): observed = _get_repr_callable_for_value_class(dbus.Array) self.assertEqual(_list_repr, observed) class TypeStrTests(TestCase): def test_boolean_str_handles_dbus_boolean(self): observed = _boolean_str(dbus.Boolean(False)) self.assertEqual(str(False), observed) def test_integer_str_handles_dbus_byte(self): observed = _integer_str(dbus.Byte(14)) self.assertEqual(str(14), observed) def repr_type(value): """Convert a text or bytes object into the appropriate return type for the __repr__ method.""" return compatible_repr(lambda: value)() ./autopilot/tests/unit/test_testresults.py0000644000004100000410000002600514002063564021421 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import codecs from unittest.mock import Mock, patch import os import tempfile from fixtures import FakeLogger from testtools import TestCase, PlaceHolder from testtools.content import Content, ContentType, text_content from testtools.matchers import Contains, raises, NotEquals from testscenarios import WithScenarios import unittest from autopilot import testresult from autopilot import run from autopilot.testcase import multiply_scenarios from autopilot.tests.unit.fixtures import AutopilotVerboseLogging class LoggedTestResultDecoratorTests(TestCase): def construct_simple_content_object(self): return text_content(self.getUniqueString()) def test_can_construct(self): testresult.LoggedTestResultDecorator(Mock()) def test_addSuccess_calls_decorated_test(self): wrapped = Mock() result = testresult.LoggedTestResultDecorator(wrapped) fake_test = PlaceHolder('fake_test') fake_details = self.construct_simple_content_object() result.addSuccess(fake_test, fake_details) wrapped.addSuccess.assert_called_once_with( fake_test, details=fake_details ) def test_addError_calls_decorated_test(self): wrapped = Mock() result = testresult.LoggedTestResultDecorator(wrapped) fake_test = PlaceHolder('fake_test') fake_error = object() fake_details = self.construct_simple_content_object() result.addError(fake_test, fake_error, fake_details) wrapped.addError.assert_called_once_with( fake_test, fake_error, details=fake_details ) def test_addFailure_calls_decorated_test(self): wrapped = Mock() result = testresult.LoggedTestResultDecorator(wrapped) fake_test = PlaceHolder('fake_test') fake_error = object() fake_details = self.construct_simple_content_object() result.addFailure(fake_test, fake_error, fake_details) wrapped.addFailure.assert_called_once_with( fake_test, fake_error, details=fake_details ) def test_log_details_handles_binary_data(self): fake_details = dict( TestBinary=Content(ContentType('image', 'png'), lambda: b'') ) result = testresult.LoggedTestResultDecorator(None) result._log_details(0, fake_details) def test_log_details_logs_binary_attachment_details(self): fake_test = Mock() fake_test.getDetails = lambda: dict( TestBinary=Content(ContentType('image', 'png'), lambda: b'') ) result = testresult.LoggedTestResultDecorator(None) with patch.object(result, '_log') as p_log: result._log_details(0, fake_test) p_log.assert_called_once_with( 0, "Binary attachment: \"{name}\" ({type})".format( name="TestBinary", type="image/png" ) ) class TestResultLogMessageTests(WithScenarios, TestCase): scenarios = multiply_scenarios( # Scenarios for each format we support: [(f, dict(format=f)) for f in testresult.get_output_formats().keys()], # Scenarios for each test outcome: [ ('success', dict(outcome='addSuccess', log='OK: %s')), ('error', dict(outcome='addError', log='ERROR: %s')), ('fail', dict(outcome='addFailure', log='FAIL: %s')), ( 'unexpected success', dict( outcome='addUnexpectedSuccess', log='UNEXPECTED SUCCESS: %s', ) ), ('skip', dict(outcome='addSkip', log='SKIP: %s')), ( 'expected failure', dict( outcome='addExpectedFailure', log='EXPECTED FAILURE: %s', ) ), ] ) def make_result_object(self): output_path = tempfile.mktemp() self.addCleanup(remove_if_exists, output_path) result_constructor = testresult.get_output_formats()[self.format] return result_constructor( stream=run.get_output_stream(self.format, output_path), failfast=False, ) def test_outcome_logs(self): test_id = self.getUniqueString() test = PlaceHolder(test_id, outcome=self.outcome) result = self.make_result_object() result.startTestRun() self.useFixture(AutopilotVerboseLogging()) with FakeLogger() as log: test.run(result) self.assertThat(log.output, Contains(self.log % test_id)) class OutputFormatFactoryTests(TestCase): def test_has_text_format(self): self.assertTrue('text' in testresult.get_output_formats()) def test_has_xml_format(self): self.assertTrue('xml' in testresult.get_output_formats()) def test_has_subunit_format(self): self.assertTrue('subunit' in testresult.get_output_formats()) def test_default_format_is_available(self): self.assertThat( testresult.get_output_formats(), Contains(testresult.get_default_format()) ) class TestResultOutputStreamTests(WithScenarios, TestCase): scenarios = [ (f, dict(format=f)) for f in testresult.get_output_formats().keys() ] def get_supported_options(self, **kwargs): """Get a dictionary of all supported keyword arguments for the current result class. Pass in keyword arguments to override default options. """ output_path = tempfile.mktemp() self.addCleanup(remove_if_exists, output_path) options = { 'stream': run.get_output_stream(self.format, output_path), 'failfast': False } options.update(kwargs) return options def run_test_with_result(self, test_suite, **kwargs): """Run the given test with the current result object. Returns the test result and output file path. Use keyword arguments to alter result object options. """ ResultClass = testresult.get_output_formats()[self.format] result_options = self.get_supported_options(**kwargs) output_path = result_options['stream'].name result = ResultClass(**result_options) result.startTestRun() test_result = test_suite.run(result) result.stopTestRun() result_options['stream'].flush() return test_result, output_path def test_factory_function_is_a_callable(self): self.assertTrue( callable(testresult.get_output_formats()[self.format]) ) def test_factory_callable_raises_on_unknown_kwargs(self): factory_fn = testresult.get_output_formats()[self.format] options = self.get_supported_options() options['unknown_kwarg'] = True self.assertThat( lambda: factory_fn(**options), raises(ValueError) ) def test_creates_non_empty_file_on_passing_test(self): class PassingTests(TestCase): def test_passes(self): pass test_result, output_path = self.run_test_with_result( PassingTests('test_passes') ) self.assertTrue(test_result.wasSuccessful()) self.assertThat(open(output_path, 'rb').read(), NotEquals(b'')) def test_creates_non_empty_file_on_failing_test(self): class FailingTests(TestCase): def test_fails(self): self.fail("Failing Test: ") test_result, output_path = self.run_test_with_result( FailingTests('test_fails') ) self.assertFalse(test_result.wasSuccessful()) self.assertThat(open(output_path, 'rb').read(), NotEquals(b'')) def test_creates_non_empty_file_on_erroring_test(self): class ErroringTests(TestCase): def test_errors(self): raise RuntimeError("Uncaught Exception!") test_result, output_path = self.run_test_with_result( ErroringTests('test_errors') ) self.assertFalse(test_result.wasSuccessful()) self.assertThat(open(output_path, 'rb').read(), NotEquals(b'')) def test_creates_non_empty_log_file_when_failing_with_unicode(self): class FailingTests(TestCase): def test_fails_unicode(self): self.fail( '\xa1pl\u0279oM \u01ddpo\u0254\u0131u\u2229 oll\u01ddH' ) test_result, output_path = self.run_test_with_result( FailingTests('test_fails_unicode') ) # We need to specify 'errors="ignore"' because subunit write non-valid # unicode data. log_contents = codecs.open( output_path, 'r', encoding='utf-8', errors='ignore' ).read() self.assertFalse(test_result.wasSuccessful()) self.assertThat( log_contents, Contains('\xa1pl\u0279oM \u01ddpo\u0254\u0131u\u2229 oll\u01ddH') ) def test_result_object_supports_many_tests(self): class ManyFailingTests(TestCase): def test_fail1(self): self.fail("Failing test") def test_fail2(self): self.fail("Failing test") suite = unittest.TestSuite( tests=( ManyFailingTests('test_fail1'), ManyFailingTests('test_fail2'), ) ) test_result, output_path = self.run_test_with_result(suite) self.assertFalse(test_result.wasSuccessful()) self.assertEqual(2, test_result.testsRun) def test_result_object_supports_failfast(self): class ManyFailingTests(TestCase): def test_fail1(self): self.fail("Failing test") def test_fail2(self): self.fail("Failing test") suite = unittest.TestSuite( tests=( ManyFailingTests('test_fail1'), ManyFailingTests('test_fail2'), ) ) test_result, output_path = self.run_test_with_result( suite, failfast=True ) self.assertFalse(test_result.wasSuccessful()) self.assertEqual(1, test_result.testsRun) def remove_if_exists(path): if os.path.exists(path): os.remove(path) ./autopilot/tests/unit/test_content.py0000644000004100000410000001102614002063564020467 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from tempfile import NamedTemporaryFile from unittest.mock import Mock, patch import os from testtools import TestCase from testtools.matchers import Contains, Equals, Not, Raises from autopilot.content import follow_file class FileFollowerTests(TestCase): def test_follow_file_adds_addDetail_cleanup(self): fake_test = Mock() with NamedTemporaryFile() as f: follow_file(f.name, fake_test) self.assertTrue(fake_test.addCleanup.called) fake_test.addCleanup.call_args[0][0]() self.assertTrue(fake_test.addDetail.called) def test_follow_file_content_object_contains_new_file_data(self): fake_test = Mock() with NamedTemporaryFile() as f: follow_file(f.name, fake_test) f.write(b"Hello") f.flush() fake_test.addCleanup.call_args[0][0]() actual = fake_test.addDetail.call_args[0][1].as_text() self.assertEqual("Hello", actual) def test_follow_file_does_not_contain_old_file_data(self): fake_test = Mock() with NamedTemporaryFile() as f: f.write(b"Hello") f.flush() follow_file(f.name, fake_test) f.write(b"World") f.flush() fake_test.addCleanup.call_args[0][0]() actual = fake_test.addDetail.call_args[0][1].as_text() self.assertEqual("World", actual) def test_follow_file_uses_filename_by_default(self): fake_test = Mock() with NamedTemporaryFile() as f: follow_file(f.name, fake_test) fake_test.addCleanup.call_args[0][0]() actual = fake_test.addDetail.call_args[0][0] self.assertEqual(f.name, actual) def test_follow_file_uses_content_name(self): fake_test = Mock() content_name = self.getUniqueString() with NamedTemporaryFile() as f: follow_file(f.name, fake_test, content_name) fake_test.addCleanup.call_args[0][0]() actual = fake_test.addDetail.call_args[0][0] self.assertEqual(content_name, actual) def test_follow_file_does_not_raise_on_IOError(self): fake_test = Mock() content_name = self.getUniqueString() with NamedTemporaryFile() as f: os.chmod(f.name, 0) self.assertThat( lambda: follow_file(f.name, fake_test, content_name), Not(Raises()) ) def test_follow_file_logs_error_on_IOError(self): fake_test = Mock() content_name = self.getUniqueString() with NamedTemporaryFile() as f: os.chmod(f.name, 0) with patch('autopilot.content._logger') as fake_logger: follow_file(f.name, fake_test, content_name) fake_logger.error.assert_called_once_with( "Could not add content object '%s' due to IO Error: %s", content_name, "[Errno 13] Permission denied: '%s'" % f.name ) def test_follow_file_returns_empty_content_object_on_error(self): fake_test = Mock() content_name = self.getUniqueString() with NamedTemporaryFile() as f: os.chmod(f.name, 0) content_obj = follow_file(f.name, fake_test, content_name) self.assertThat(content_obj.as_text(), Equals('')) def test_real_test_has_detail_added(self): with NamedTemporaryFile() as f: class FakeTest(TestCase): def test_foo(self): follow_file(f.name, self) f.write(b"Hello") f.flush() test = FakeTest('test_foo') result = test.run() self.assertTrue(result.wasSuccessful) self.assertThat(test.getDetails(), Contains(f.name)) ./autopilot/tests/unit/test_application_launcher.py0000644000004100000410000004461214002063567023213 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013,2017 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from contextlib import ExitStack from gi.repository import GLib import signal import subprocess from testtools import TestCase from testtools.matchers import ( Contains, Equals, GreaterThan, HasLength, IsInstance, MatchesListwise, Not, raises, ) from testtools.content import text_content from unittest.mock import MagicMock, Mock, patch from autopilot.application import ( NormalApplicationLauncher, ) from autopilot.application._environment import ( GtkApplicationEnvironment, QtApplicationEnvironment, ) import autopilot.application._launcher as _l from autopilot.application._launcher import ( ApplicationLauncher, get_application_launcher_wrapper, launch_process, _attempt_kill_pid, _get_app_env_from_string_hint, _get_application_environment, _get_application_path, _is_process_running, _kill_process, ) from autopilot.utilities import sleep class ApplicationLauncherTests(TestCase): def test_raises_on_attempt_to_use_launch(self): self.assertThat( lambda: ApplicationLauncher(self.addDetail).launch(), raises( NotImplementedError("Sub-classes must implement this method.") ) ) def test_init_uses_default_values(self): launcher = ApplicationLauncher() self.assertEqual(launcher.caseAddDetail, launcher.addDetail) self.assertEqual(launcher.proxy_base, None) self.assertEqual(launcher.dbus_bus, 'session') def test_init_uses_passed_values(self): case_addDetail = self.getUniqueString() emulator_base = self.getUniqueString() dbus_bus = self.getUniqueString() launcher = ApplicationLauncher( case_addDetail=case_addDetail, emulator_base=emulator_base, dbus_bus=dbus_bus, ) self.assertEqual(launcher.caseAddDetail, case_addDetail) self.assertEqual(launcher.proxy_base, emulator_base) self.assertEqual(launcher.dbus_bus, dbus_bus) @patch('autopilot.application._launcher.fixtures.EnvironmentVariable') def test_setUp_patches_environment(self, ev): self.useFixture(ApplicationLauncher(dbus_bus='')) ev.assert_called_with('DBUS_SESSION_BUS_ADDRESS', '') class NormalApplicationLauncherTests(TestCase): def test_kill_process_and_attach_logs(self): mock_addDetail = Mock() app_launcher = NormalApplicationLauncher(mock_addDetail) with patch.object( _l, '_kill_process', return_value=("stdout", "stderr", 0) ): app_launcher._kill_process_and_attach_logs(0, 'app') self.assertThat( mock_addDetail.call_args_list, MatchesListwise([ Equals( [('process-return-code (app)', text_content('0')), {}] ), Equals( [('process-stdout (app)', text_content('stdout')), {}] ), Equals( [('process-stderr (app)', text_content('stderr')), {}] ), ]) ) def test_setup_environment_returns_prepare_environment_return_value(self): app_launcher = self.useFixture(NormalApplicationLauncher()) with patch.object(_l, '_get_application_environment') as gae: self.assertThat( app_launcher._setup_environment( self.getUniqueString(), None, []), Equals(gae.return_value.prepare_environment.return_value) ) @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_call_to_get_application_path(self, gap, _): """Test that NormalApplicationLauncher.launch calls _get_application_path with the arguments it was passed,""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process'): with patch.object(launcher, '_setup_environment') as se: se.return_value = ('', []) token = self.getUniqueString() launcher.launch(token) gap.assert_called_once_with(token) @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_call_to_setup_environment(self, gap, _): """Test the NornmalApplicationLauncher.launch calls self._setup_environment with the correct application path from _get_application_path and the arguments passed to it.""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process'): with patch.object(launcher, '_setup_environment') as se: se.return_value = ('', []) token_a = self.getUniqueString() token_b = self.getUniqueString() token_c = self.getUniqueString() launcher.launch(token_a, arguments=[token_b, token_c]) se.assert_called_once_with( gap.return_value, None, [token_b, token_c], ) @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_call_to_launch_application_process(self, _, __): """Test that NormalApplicationLauncher.launch calls launch_application_process with the return values of setup_environment.""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process') as lap: with patch.object(launcher, '_setup_environment') as se: token_a = self.getUniqueString() token_b = self.getUniqueString() token_c = self.getUniqueString() se.return_value = (token_a, [token_b, token_c]) launcher.launch('', arguments=['', '']) lap.assert_called_once_with( token_a, True, None, [token_b, token_c], ) @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_gets_correct_proxy_object(self, _, gpofep): """Test that NormalApplicationLauncher.launch calls get_proxy_object_for_existing_process with the correct return values of other functions.""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process') as lap: with patch.object(launcher, '_setup_environment') as se: se.return_value = ('', []) launcher.launch('') gpofep.assert_called_once_with(process=lap.return_value, pid=lap.return_value.pid, emulator_base=None, dbus_bus='session') @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_sets_process_of_proxy_object(self, _, gpofep): """Test that NormalApplicationLauncher.launch returns the proxy object returned by get_proxy_object_for_existing_process.""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process') as lap: with patch.object(launcher, '_setup_environment') as se: se.return_value = ('', []) launcher.launch('') set_process = gpofep.return_value.set_process set_process.assert_called_once_with(lap.return_value) @patch('autopilot.application._launcher.' 'get_proxy_object_for_existing_process') @patch('autopilot.application._launcher._get_application_path') def test_launch_returns_proxy_object(self, _, gpofep): """Test that NormalApplicationLauncher.launch returns the proxy object returned by get_proxy_object_for_existing_process.""" launcher = NormalApplicationLauncher() with patch.object(launcher, '_launch_application_process'): with patch.object(launcher, '_setup_environment') as se: se.return_value = ('', []) result = launcher.launch('') self.assertEqual(result, gpofep.return_value) def test_launch_application_process(self): """The _launch_application_process method must return the process object, must add the _kill_process_and_attach_logs method to the fixture cleanups, and must call the launch_process function with the correct arguments. """ launcher = NormalApplicationLauncher(self.addDetail) launcher.setUp() expected_process_return = self.getUniqueString() with patch.object( _l, 'launch_process', return_value=expected_process_return ) as patched_launch_process: process = launcher._launch_application_process( "/foo/bar", False, None, []) self.assertThat(process, Equals(expected_process_return)) self.assertThat( [f[0] for f in launcher._cleanups._cleanups], Contains(launcher._kill_process_and_attach_logs) ) patched_launch_process.assert_called_with( "/foo/bar", [], False, cwd=None ) class ApplicationLauncherInternalTests(TestCase): def test_get_app_env_from_string_hint_returns_qt_env(self): self.assertThat( _get_app_env_from_string_hint('QT'), IsInstance(QtApplicationEnvironment) ) def test_get_app_env_from_string_hint_returns_gtk_env(self): self.assertThat( _get_app_env_from_string_hint('GTK'), IsInstance(GtkApplicationEnvironment) ) def test_get_app_env_from_string_hint_raises_on_unknown(self): self.assertThat( lambda: _get_app_env_from_string_hint('FOO'), raises(ValueError("Unknown hint string: FOO")) ) def test_get_application_environment_uses_app_type_argument(self): with patch.object(_l, '_get_app_env_from_string_hint') as from_hint: _get_application_environment(app_type="app_type") from_hint.assert_called_with("app_type") def test_get_application_environment_uses_app_path_argument(self): with patch.object( _l, 'get_application_launcher_wrapper' ) as patched_wrapper: _get_application_environment(app_path="app_path") patched_wrapper.assert_called_with("app_path") def test_get_application_environment_raises_runtime_with_no_args(self): self.assertThat( lambda: _get_application_environment(), raises( ValueError( "Must specify either app_type or app_path." ) ) ) def test_get_application_environment_raises_on_app_type_error(self): unknown_app_type = self.getUniqueString() with patch.object( _l, '_get_app_env_from_string_hint', side_effect=ValueError() ): self.assertThat( lambda: _get_application_environment( app_type=unknown_app_type ), raises(RuntimeError( "Autopilot could not determine the correct introspection " "type to use. You can specify this by providing app_type." )) ) def test_get_application_environment_raises_on_app_path_error(self): unknown_app_path = self.getUniqueString() with patch.object( _l, 'get_application_launcher_wrapper', side_effect=RuntimeError() ): self.assertThat( lambda: _get_application_environment( app_path=unknown_app_path ), raises(RuntimeError( "Autopilot could not determine the correct introspection " "type to use. You can specify this by providing app_type." )) ) @patch.object(_l.os, 'killpg') def test_attempt_kill_pid_logs_if_process_already_exited(self, killpg): killpg.side_effect = OSError() with patch.object(_l, '_logger') as patched_log: _attempt_kill_pid(0) patched_log.info.assert_called_with( "Appears process has already exited." ) @patch.object(_l, '_attempt_kill_pid') def test_kill_process_succeeds(self, patched_kill_pid): mock_process = Mock() mock_process.returncode = 0 mock_process.communicate.return_value = ("", "",) with patch.object( _l, '_is_process_running', return_value=False ): self.assertThat(_kill_process(mock_process), Equals(("", "", 0))) @patch.object(_l, '_attempt_kill_pid') def test_kill_process_tries_again(self, patched_kill_pid): with sleep.mocked(): mock_process = Mock() mock_process.pid = 123 mock_process.communicate.return_value = ("", "",) with patch.object( _l, '_is_process_running', return_value=True ) as proc_running: _kill_process(mock_process) self.assertThat(proc_running.call_count, GreaterThan(1)) self.assertThat(patched_kill_pid.call_count, Equals(2)) patched_kill_pid.assert_called_with(123, signal.SIGKILL) @patch.object(_l.subprocess, 'Popen') def test_launch_process_uses_arguments(self, popen): launch_process("testapp", ["arg1", "arg2"]) self.assertThat( popen.call_args_list[0][0], Contains(['testapp', 'arg1', 'arg2']) ) @patch.object(_l.subprocess, 'Popen') def test_launch_process_default_capture_is_false(self, popen): launch_process("testapp", []) self.assertThat( popen.call_args[1]['stderr'], Equals(None) ) self.assertThat( popen.call_args[1]['stdout'], Equals(None) ) @patch.object(_l.subprocess, 'Popen') def test_launch_process_can_set_capture_output(self, popen): launch_process("testapp", [], capture_output=True) self.assertThat( popen.call_args[1]['stderr'], Not(Equals(None)) ) self.assertThat( popen.call_args[1]['stdout'], Not(Equals(None)) ) @patch.object(_l.subprocess, 'check_output') def test_get_application_launcher_wrapper_finds_qt(self, check_output): check_output.return_value = "LIBQTCORE" self.assertThat( get_application_launcher_wrapper("/fake/app/path"), IsInstance(QtApplicationEnvironment) ) @patch.object(_l.subprocess, 'check_output') def test_get_application_launcher_wrapper_finds_gtk(self, check_output): check_output.return_value = "LIBGTK" self.assertThat( get_application_launcher_wrapper("/fake/app/path"), IsInstance(GtkApplicationEnvironment) ) @patch.object(_l.subprocess, 'check_output') def test_get_application_path_returns_stripped_path(self, check_output): check_output.return_value = "/foo/bar " self.assertThat(_get_application_path("bar"), Equals('/foo/bar')) check_output.assert_called_with( ['which', 'bar'], universal_newlines=True ) def test_get_application_path_raises_when_cant_find_app(self): test_path = self.getUniqueString() expected_error = "Unable to find path for application {app}: Command"\ " '['which', '{app}']' returned non-zero exit "\ "status 1.".format(app=test_path) with patch.object(_l.subprocess, 'check_output') as check_output: check_output.side_effect = subprocess.CalledProcessError( 1, ['which', test_path] ) self.assertThat( lambda: _get_application_path(test_path), raises(ValueError(expected_error)) ) def test_get_application_launcher_wrapper_raises_runtimeerror(self): test_path = self.getUniqueString() expected_error = "Command '['ldd', '%s']' returned non-zero exit"\ " status 1." % test_path with patch.object(_l.subprocess, 'check_output') as check_output: check_output.side_effect = subprocess.CalledProcessError( 1, ['ldd', test_path] ) self.assertThat( lambda: get_application_launcher_wrapper(test_path), raises(RuntimeError(expected_error)) ) def test_get_application_launcher_wrapper_returns_none_for_unknown(self): with patch.object(_l.subprocess, 'check_output') as check_output: check_output.return_value = self.getUniqueString() self.assertThat( get_application_launcher_wrapper(""), Equals(None) ) @patch.object(_l.psutil, 'pid_exists') def test_is_process_running_checks_with_pid(self, pid_exists): pid_exists.return_value = True self.assertThat(_is_process_running(123), Equals(True)) pid_exists.assert_called_with(123) ./autopilot/introspection/0000755000004100000410000000000014002063564016163 5ustar www-datawww-data./autopilot/introspection/qt.py0000644000004100000410000001345014002063564017164 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Classes and tools to support Qt introspection.""" import functools class QtSignalWatcher(object): """A utility class to make watching Qt signals easy.""" def __init__(self, proxy, signal_name): """Initialise the signal watcher. :param QtObjectProxyMixin proxy: :param string signal_name: Name of the signal being monitored. 'proxy' is an instance of QtObjectProxyMixin. 'signal_name' is the name of the signal being monitored. Do not construct this object yourself. Instead, call 'watch_signal' on a QtObjectProxyMixin instance. """ self._proxy = proxy self.signal_name = signal_name self._data = None def _refresh(self): self._data = self._proxy.get_signal_emissions(self.signal_name) @property def num_emissions(self): """Get the number of times the signal has been emitted since we started monitoring it. """ self._refresh() return len(self._data) @property def was_emitted(self): """True if the signal was emitted at least once.""" self._refresh() return len(self._data) > 0 class QtObjectProxyMixin(object): """A class containing methods specific to querying Qt applications.""" def _get_qt_iface(self): """Get the autopilot Qt-specific interface for the specified service name and object path. """ return self._backend.ipc_address.qt_introspection_iface @property def slots(self): """An object that contains all the slots available to be called in this object.""" if getattr(self, '_slots', None) is None: self._slots = QtSlotProxy(self) return self._slots def watch_signal(self, signal_name): """Start watching the 'signal_name' signal on this object. signal_name must be the C++ signal signature, as is usually used within the Qt 'SIGNAL' macro. Examples of valid signal names are: * 'clicked(bool)' * 'pressed()' A list of valid signal names can be retrieved from 'get_signals()'. If an invalid signal name is given ValueError will be raised. This method returns a QtSignalWatcher instance. By default, no signals are monitored. You must call this method once for each signal you are interested in. """ valid_signals = self.get_signals() if signal_name not in valid_signals: raise ValueError( "Signal name %r is not in the valid signal list of %r" % (signal_name, valid_signals)) self._get_qt_iface().RegisterSignalInterest(self.id, signal_name) return QtSignalWatcher(self, signal_name) def get_signal_emissions(self, signal_name): """Get a list of all the emissions of the 'signal_name' signal. If signal_name is not a valid signal, ValueError is raised. The QtSignalWatcher class provides a more convenient API than calling this method directly. A QtSignalWatcher instance is returned from 'watch_signal'. Each item in the returned list is a tuple containing the arguments in the emission (possibly an empty list if the signal has no arguments). If the signal was not emitted, the list will be empty. You must first call 'watch_signal(signal_name)' in order to monitor this signal. Note: Some signal arguments may not be marshallable over DBus. If this is the case, they will be omitted from the argument list. """ valid_signals = self.get_signals() if signal_name not in valid_signals: raise ValueError( "Signal name %r is not in the valid signal list of %r" % (signal_name, valid_signals)) return self._get_qt_iface().GetSignalEmissions(self.id, signal_name) def get_signals(self): """Get a list of the signals available on this object.""" dbus_signal_list = self._get_qt_iface().ListSignals(self.id) if dbus_signal_list is not None: return [str(sig) for sig in dbus_signal_list] else: return [] def get_slots(self): """Get a list of the slots available on this object.""" dbus_slot_list = self._get_qt_iface().ListMethods(self.id) if dbus_slot_list is not None: return [str(sig) for sig in dbus_slot_list] else: return [] class QtSlotProxy(object): """A class that transparently calls slots in a Qt object.""" def __init__(self, qt_mixin): self._dbus_iface = qt_mixin._get_qt_iface() self._object_id = qt_mixin.id methods = self._dbus_iface.ListMethods(self._object_id) for method_name in methods: method = functools.partial(self._call_method, method_name) stripped_method_name = method_name[:method_name.find('(')] setattr(self, stripped_method_name, method) def _call_method(self, name, *args): self._dbus_iface.InvokeMethod(self._object_id, name, args) ./autopilot/introspection/utilities.py0000644000004100000410000001064714002063564020560 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import os from contextlib import contextmanager from dbus import Interface from autopilot.utilities import process_iter def _pid_is_running(pid): """Check for the existence of a currently running PID. :returns: **True** if PID is running **False** otherwise. """ return os.path.exists("/proc/%d" % pid) def _get_bus_connections_pid(bus, connection_name): """Returns the pid for the connection **connection_name** on **bus** :raises: **DBusException** if connection_name is invalid etc. """ bus_obj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus') bus_iface = Interface(bus_obj, 'org.freedesktop.DBus') return bus_iface.GetConnectionUnixProcessID(connection_name) def translate_state_keys(state_dict): """Translates the *state_dict* passed in so the keys are usable as python attributes.""" return {k.replace('-', '_'): v for k, v in state_dict.items()} def sort_by_keys(instances, sort_keys): """Sorts DBus object instances by requested keys.""" def get_sort_key(item): sort_key = [] for sk in sort_keys: if not isinstance(sk, str): raise ValueError( 'Parameter `sort_keys` must be a list of strings' ) value = item for key in sk.split('.'): value = getattr(value, key) sort_key.append(value) return sort_key if sort_keys and not isinstance(sort_keys, list): raise ValueError('Parameter `sort_keys` must be a list.') if len(instances) > 1 and sort_keys: return sorted(instances, key=get_sort_key) return instances class ProcessUtil: """Helper class to manipulate running processes.""" @contextmanager def mocked(self, fake_processes): """Enable mocking for the ProcessUtil class Also mocks all calls to autopilot.utilities.process_iter. One may use it like:: from autopilot.introspection.utilities import ProcessUtil process_util = ProcessUtil() with process_util.mocked([{'pid': -9, 'name': 'xx'}]): self.assertThat( process_util.get_pid_for_process('xx'), Equals(-9) ) ) """ process_iter.enable_mock(fake_processes) try: yield self finally: process_iter.disable_mock() def _query_pids_for_process(self, process_name): if not isinstance(process_name, str): raise ValueError('Process name must be a string.') pids = [process.pid for process in process_iter() if process.name() == process_name] if not pids: raise ValueError('Process \'{}\' not running'.format(process_name)) return pids def get_pid_for_process(self, process_name): """Returns the PID associated with a process name. :param process_name: Process name to get PID for. This must be a string. :return: PID of the requested process. """ pids = self._query_pids_for_process(process_name) if len(pids) > 1: raise ValueError( 'More than one PID exists for process \'{}\''.format( process_name ) ) return pids[0] def get_pids_for_process(self, process_name): """Returns PID(s) associated with a process name. :param process_name: Process name to get PID(s) for. :return: A list containing the PID(s) of the requested process. """ return self._query_pids_for_process(process_name) process_util = ProcessUtil() ./autopilot/introspection/__init__.py0000644000004100000410000000432214002063564020275 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Package for introspection object support and search. This package contains the methods and classes that are of use for accessing dbus proxy objects and creating Custom Proxy Object classes. For retrieving proxy objects for already existing processes use :meth:`~autopilot.introspection.get_proxy_object_for_existing_process` This takes search criteria and return a proxy object that can be queried and introspected. For creating your own Custom Proxy Classes use :class:`autopilot.introspection.CustomEmulatorBase` .. seealso:: The tutorial section :ref:`custom_proxy_classes` for further details on using 'CustomEmulatorBase' to write custom proxy classes. """ from autopilot.introspection.dbus import CustomEmulatorBase, is_element from autopilot.introspection._xpathselect import ( get_classname_from_path, get_path_root, ) from autopilot.exceptions import ProcessSearchError from autopilot.introspection._search import ( get_proxy_object_for_existing_process, get_proxy_object_for_existing_process_by_name, ) # TODO: Remove ProcessSearchError from here once all our clients have stopped # using it from this location. __all__ = [ 'CustomEmulatorBase', 'is_element', 'get_classname_from_path', 'get_path_root', 'ProxyBase', 'ProcessSearchError', 'get_proxy_object_for_existing_process', 'get_proxy_object_for_existing_process_by_name', ] ProxyBase = CustomEmulatorBase ProxyBase.__name__ = 'ProxyBase' ./autopilot/introspection/backends.py0000644000004100000410000002623614002063564020320 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Backend IPC interface for autopilot. This module contains two primative classes that Autopilot uses for it's IPC routines. The first is the DBusAddress class. This contains knowledge of how to talk dbus to a particular application exposed over dbus. In the future, this interface could be provided by some alternative IPC mechanism. The second class is the Backend class. This holds a reference to a DBusAddress class, and contains code that turns a query object into proxy classes. """ from collections import namedtuple import dbus import logging from autopilot.dbus_handler import ( get_session_bus, get_system_bus, get_custom_bus, ) from autopilot.introspection.constants import ( AP_INTROSPECTION_IFACE, CURRENT_WIRE_PROTOCOL_VERSION, DBUS_INTROSPECTION_IFACE, QT_AUTOPILOT_IFACE, ) from autopilot.utilities import Timer from autopilot.introspection.utilities import ( _pid_is_running, _get_bus_connections_pid, ) from autopilot.introspection._object_registry import _get_proxy_object_class _logger = logging.getLogger(__name__) class WireProtocolVersionMismatch(RuntimeError): """Wire protocols mismatch.""" class DBusAddress(object): """Store information about an Autopilot dbus backend, from keyword arguments.""" _checked_backends = [] AddrTuple = namedtuple( 'AddressTuple', ['bus', 'connection', 'object_path']) @staticmethod def SessionBus(connection, object_path): """Construct a DBusAddress that backs on to the session bus.""" return DBusAddress(get_session_bus(), connection, object_path) @staticmethod def SystemBus(connection, object_path): """Construct a DBusAddress that backs on to the system bus.""" return DBusAddress(get_system_bus(), connection, object_path) @staticmethod def CustomBus(bus_address, connection, object_path): """Construct a DBusAddress that backs on to a custom bus. :param bus_address: A string representing the address of the dbus bus to connect to. """ return DBusAddress( get_custom_bus(bus_address), connection, object_path) def __init__(self, bus, connection, object_path): """Construct a DBusAddress instance. :param bus: A valid DBus bus object. :param connection: A string connection name to look at, or None to search all dbus connections for objects that resemble an autopilot conection. :param object_path: The path to the object that provides the autopilot interface, or None to search for the object. """ # We cannot evaluate kwargs for accuracy now, since this class will be # created at module import time, at which point the bus backend # probably does not exist yet. self._addr_tuple = DBusAddress.AddrTuple(bus, connection, object_path) @property def introspection_iface(self): if not isinstance(self._addr_tuple.connection, str): raise TypeError("Service name must be a string.") if not isinstance(self._addr_tuple.object_path, str): raise TypeError("Object name must be a string") if not self._check_pid_running(): raise RuntimeError( "Lost dbus backend communication. It appears the " "application under test exited before the test " "finished!" ) proxy_obj = self._addr_tuple.bus.get_object( self._addr_tuple.connection, self._addr_tuple.object_path ) iface = dbus.Interface(proxy_obj, AP_INTROSPECTION_IFACE) if self._addr_tuple not in DBusAddress._checked_backends: try: self._check_version(iface) except WireProtocolVersionMismatch: raise else: DBusAddress._checked_backends.append(self._addr_tuple) return iface def _check_version(self, iface): """Check the wire protocol version on 'iface', and raise an error if the version does not match what we were expecting. """ try: version = iface.GetVersion() except dbus.DBusException: version = "1.2" if version != CURRENT_WIRE_PROTOCOL_VERSION: raise WireProtocolVersionMismatch( "Wire protocol mismatch at %r: is %s, expecting %s" % ( self, version, CURRENT_WIRE_PROTOCOL_VERSION) ) def _check_pid_running(self): try: process_pid = _get_bus_connections_pid( self._addr_tuple.bus, self._addr_tuple.connection ) return _pid_is_running(process_pid) except dbus.DBusException as e: if e.get_dbus_name() == \ 'org.freedesktop.DBus.Error.NameHasNoOwner': return False else: raise @property def dbus_introspection_iface(self): dbus_object = self._addr_tuple.bus.get_object( self._addr_tuple.connection, self._addr_tuple.object_path ) return dbus.Interface(dbus_object, DBUS_INTROSPECTION_IFACE) @property def qt_introspection_iface(self): proxy_obj = self._addr_tuple.bus.get_object( self._addr_tuple.connection, self._addr_tuple.object_path ) return dbus.Interface(proxy_obj, QT_AUTOPILOT_IFACE) def __hash__(self): return hash(self._addr_tuple) def __eq__(self, other): return self._addr_tuple.bus == other._addr_tuple.bus and \ self._addr_tuple.connection == other._addr_tuple.connection and \ self._addr_tuple.object_path == other._addr_tuple.object_path def __ne__(self, other): return (self._addr_tuple.object_path != other._addr_tuple.object_path or self._addr_tuple.connection != other._addr_tuple.connection or self._addr_tuple.bus != other._addr_tuple.bus) def __str__(self): return repr(self) def __repr__(self): if self._addr_tuple.bus._bus_type == dbus.Bus.TYPE_SESSION: name = "session" elif self._addr_tuple.bus._bus_type == dbus.Bus.TYPE_SYSTEM: name = "system" else: name = "custom" return "<%s bus %s %s>" % ( name, self._addr_tuple.connection, self._addr_tuple.object_path) class Backend(object): """A Backend object that works with an ipc address interface. Will raise a RunTimeError if the dbus backend communication is lost.""" def __init__(self, ipc_address): self.ipc_address = ipc_address def execute_query_get_data(self, query): """Execute 'query', return the raw dbus reply.""" with Timer("GetState %r" % query): try: data = self.ipc_address.introspection_iface.GetState( query.server_query_bytes() ) except dbus.DBusException as e: desired_exception = 'org.freedesktop.DBus.Error.ServiceUnknown' if e.get_dbus_name() != desired_exception: raise else: raise RuntimeError( "Lost dbus backend communication. It appears the " "application under test exited before the test " "finished!" ) if len(data) > 15: _logger.warning( "Your query '%r' returned a lot of data (%d items). This " "is likely to be slow. You may want to consider optimising" " your query to return fewer items.", query, len(data) ) return data def execute_query_get_proxy_instances(self, query, id): """Execute 'query', returning proxy instances.""" data = self.execute_query_get_data(query) objects = [ make_introspection_object( t, type(self)(self.ipc_address), id, ) for t in data ] if query.needs_client_side_filtering(): return list(filter( lambda i: _object_passes_filters( i, **query.get_client_side_filters() ), objects )) return objects class FakeBackend(Backend): """A backend that always returns fake data, useful for testing.""" def __init__(self, fake_ipc_return_data): """Create a new FakeBackend instance. If this backend creates any proxy objects, they will be created with a FakeBackend with the same fake ipc return data. :param fake_ipc_return_data: The data you want to pretend was returned by the applicatoin under test. This must be in the correct protocol format, or the results are undefined. """ super(FakeBackend, self).__init__(fake_ipc_return_data) self.fake_ipc_return_data = fake_ipc_return_data def execute_query_get_data(self, query): return self.fake_ipc_return_data def make_introspection_object(dbus_tuple, backend, object_id): """Make an introspection object given a DBus tuple of (path, state_dict). :param dbus_tuple: A two-item iterable containing a dbus.String object that contains the object path, and a dbus.Dictionary object that contains the objects state dictionary. :param backend: An instance of the Backend class. :returns: A proxy object that derives from DBusIntrospectionObject :raises ValueError: if more than one class is appropriate for this dbus_tuple """ path, state = dbus_tuple path = path.encode('utf-8') class_object = _get_proxy_object_class(object_id, path, state) return class_object(state, path, backend) def _object_passes_filters(instance, **kwargs): """Return true if *instance* satisifies all the filters present in kwargs.""" with instance.no_automatic_refreshing(): for attr, val in kwargs.items(): if not hasattr(instance, attr) or getattr(instance, attr) != val: # Either attribute is not present, or is present but with # the wrong value - don't add this instance to the results # list. return False return True ./autopilot/introspection/_object_registry.py0000644000004100000410000002425414002063564022101 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Object registry. This is an internal module, and is not supposed to be used directly. This module contains the object registry, which keeps track to the various classes we use when creating proxy classes. The object registry allows test authors to write their own classes to be used instead of the generic one that autopilot creates. This module contains two global dictionaries, both are keys by unique connection id's (UUID objects). The values are described below. * ``_object_registry`` contains dictionaries of class names (as strings) to class objects. Custom proxy classes defined by test authors will end up with their class object in this dictionary. This is used when we want to create a proxy instance - if nothing matches in this dictionary then we create a generic proxy instance instead. * ``_proxy_extensions`` contains a tuple of extension classes to mix in to *every proxy class*. This is used to extend the proxy API on a per-connection basis. For example, Qt-based apps allow us to monitor signals and slots in the application, but Gtk apps do not. """ from uuid import uuid4 from autopilot.introspection._xpathselect import get_classname_from_path from autopilot.utilities import get_debug_logger from contextlib import contextmanager _object_registry = {} _proxy_extensions = {} def register_extension_classes_for_proxy_base(proxy_base, extensions): global _proxy_extensions _proxy_extensions[proxy_base._id] = (proxy_base,) + extensions def _get_proxy_bases_for_id(id): global _proxy_extensions return _proxy_extensions.get(id, ()) class IntrospectableObjectMetaclass(type): """Metaclass to insert appropriate classes into the object registry.""" def __new__(cls, classname, bases, classdict): """Create a new proxy class, possibly adding it to the object registry. Test authors may derive a class from DBusIntrospectionObject or the CustomEmulatorBase alias and use this as their 'emulator base'. This class will be given a unique '_id' attribute. That attribute is the first level index into the object registry. It's used so we can have custom proxy classes for more than one process at the same time and avoid clashes in the dictionary. """ cls_id = None for base in bases: if hasattr(base, '_id'): cls_id = base._id break else: # Ignore classes that are in the autopilot class heirarchy: if classname not in ( 'ApplicationProxyObject', 'CustomEmulatorBase', 'DBusIntrospectionObject', 'DBusIntrospectionObjectBase', ): # Add the '_id' attribute as a class attr: cls_id = classdict['_id'] = uuid4() # use the bases passed to us, but extend it with whatever is stored in # the proxy_extensions dictionary. extensions = _get_proxy_bases_for_id(cls_id) for extension in extensions: if extension not in bases: bases += (extension,) # make the object. Nothing special here. class_object = type.__new__(cls, classname, bases, classdict) if not classdict.get('__generated', False): # If the newly made object has an id, add it to the object # registry. if getattr(class_object, '_id', None) is not None: if class_object._id in _object_registry: _object_registry[class_object._id][classname] = \ class_object else: _object_registry[class_object._id] = \ {classname: class_object} # in all cases, return the class unchanged. return class_object DBusIntrospectionObjectBase = IntrospectableObjectMetaclass( 'DBusIntrospectionObjectBase', (object,), {} ) def _get_proxy_object_class(object_id, path, state): """Return a custom proxy class, from the object registry or the default. This function first inspects the object registry using the object_id passed in. The object_id will be unique to all custom proxy classes for the same application. If that fails, we create a class on the fly based on the default class. :param object_id: The _id attribute of the class doing the lookup. This is used to index into the object registry to retrieve the dict of proxy classes to try. :param path: dbus path :param state: dbus state :returns: appropriate custom proxy class :raises ValueError: if more than one class in the dict matches """ class_type = _try_custom_proxy_classes(object_id, path, state) return class_type or _get_default_proxy_class( object_id, get_classname_from_path(path) ) def _try_custom_proxy_classes(object_id, path, state): """Identify which custom proxy class matches the dbus path and state. If more than one class in proxy_class_dict matches, raise an exception. :param object_id: id to use to get the dict of proxy classes to try :param path: dbus path :param state: dbus state dict :returns: matching custom proxy class :raises ValueError: if more than one class matches """ proxy_class_dict = _object_registry[object_id] possible_classes = [c for c in proxy_class_dict.values() if c.validate_dbus_object(path, state)] if len(possible_classes) > 1: raise ValueError( 'More than one custom proxy class matches this object: ' 'Matching classes are: %s. State is %s. Path is %s.' % ( ','.join([repr(c) for c in possible_classes]), repr(state), path, ) ) if len(possible_classes) == 1: extended_proxy_bases = _get_proxy_bases_for_id(object_id) mixed = _combine_base_and_extensions( possible_classes[0], extended_proxy_bases ) possible_classes[0].__bases__ = mixed return possible_classes[0] return None def _combine_base_and_extensions(kls, extensions): """Returns the bases of the given class augmented with extensions In order to get the right bases tuple, the given class is removed from the result (to prevent cyclic dependencies), there's only one occurrence of each final base class in the result and the result is ordered following the inheritance order (classes lower in the inheritance tree are listed before in the resulting tuple) :param kls: class for which we are combining bases and extensions :param extensions: tuple of extensions to be added to kls' bases :returns: bases tuple for kls, including its former bases and the extensions """ # set of bases + extensions removing the original class to prevent # TypeError: a __bases__ item causes an inheritance cycle unique_bases = {x for x in kls.__bases__ + extensions if x != kls} # sort them taking into account inheritance to prevent # TypeError: Cannot create a consistent method resolution order (MRO) return tuple( sorted( unique_bases, key=lambda cls: _get_mro_sort_order(cls, extensions), reverse=True ) ) def _get_mro_sort_order(cls, promoted_collection=()): """Returns the comparable numerical order for the given class honouring its MRO It accepts an optional parameter for promoting classes in a certain group, this can give more control over the sorting when two classes have the a MRO of the same length :param cls: the subject class :param promoted_collection: tuple of classes which must be promoted :returns: comparable numerical order, higher for classes with MROs of greater length """ # Multiplying by 2 the lenght of the MRO list gives the chance to promote # items in the promoted_collection by adding them 1 later: non promoted # classes will have even scores and promoted classes with MRO of the same # length will have odd scores one point higher order = 2 * len(cls.mro()) if cls in promoted_collection: order += 1 return order def _get_default_proxy_class(id, name): """Return a custom proxy object class of the default or a base class. We want the object to inherit from the class that is set as the emulator base class, not the class that is doing the selecting. Using the passed id we retrieve the relevant bases from the object registry. :param id: The object id (_id attribute) of the class doing the lookup. :param name: name of new class :returns: custom proxy object class """ get_debug_logger().warning( "Generating introspection instance for type '%s' based on generic " "class.", name) if isinstance(name, bytes): name = name.decode('utf-8') return type(name, _get_proxy_bases_for_id(id), dict(__generated=True)) @contextmanager def patch_registry(new_registry): """A utility context manager that allows us to patch the object registry. Within the scope of the context manager, the object registry will be set to the 'new_registry' value passed in. When the scope exits, the old object registry will be restored. """ global _object_registry old_registry = _object_registry _object_registry = new_registry try: yield except Exception: raise finally: _object_registry = old_registry ./autopilot/introspection/_search.py0000644000004100000410000007406514002063564020155 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Private module for searching dbus for useful connections.""" import dbus import logging import os import psutil import subprocess from functools import partial from operator import methodcaller from autopilot import dbus_handler from autopilot._timeout import Timeout from autopilot.exceptions import ProcessSearchError from autopilot.introspection import backends from autopilot.introspection import constants from autopilot.introspection import dbus as ap_dbus from autopilot.introspection import _object_registry from autopilot.introspection._xpathselect import get_classname_from_path from autopilot.introspection.backends import WireProtocolVersionMismatch from autopilot.introspection.utilities import ( _get_bus_connections_pid, _pid_is_running, process_util, ) from autopilot.utilities import deprecated logger = logging.getLogger(__name__) @deprecated('get_proxy_object_for_existing_process') def get_autopilot_proxy_object_for_process( process, emulator_base, dbus_bus='session' ): """Return the autopilot proxy object for the given *process*. :raises RuntimeError: if no autopilot interface was found. """ pid = process.pid proxy_obj = get_proxy_object_for_existing_process( pid, process=process, emulator_base=emulator_base, dbus_bus=dbus_bus, ) proxy_obj.set_process(process) return proxy_obj def get_proxy_object_for_existing_process(**kwargs): """Return a single proxy object for an application that is already running (i.e. launched outside of Autopilot). Searches the given bus (supplied by the kwarg **dbus_bus**) for an application matching the search criteria (also supplied in kwargs, see further down for explaination on what these can be.) Returns a proxy object created using the supplied custom emulator **emulator_base** (which defaults to None). This function take kwargs arguments containing search parameter values to use when searching for the target application. **Possible search criteria**: *(unless specified otherwise these parameters default to None)* :param pid: The PID of the application to search for. :param process: The process of the application to search for. If provided only the pid of the process is used in the search, but if the process exits before the search is complete it is used to supply details provided by the process object. :param connection_name: A string containing the DBus connection name to use with the search criteria. :param application_name: A string containing the applications name to search for. :param object_path: A string containing the object path to use as the search criteria. Defaults to: :py:data:`autopilot.introspection.constants.AUTOPILOT_PATH`. **Non-search parameters:** :param dbus_bus: The DBus bus to search for the application. Must be a string containing either 'session', 'system' or the custom buses name (i.e. 'unix:abstract=/tmp/dbus-IgothuMHNk'). Defaults to 'session' :param emulator_base: The custom emulator to use when creating the resulting proxy object. Defaults to None **Exceptions possibly thrown by this function:** :raises ProcessSearchError: If no search criteria match. :raises RuntimeError: If the search criteria results in many matches. :raises RuntimeError: If both ``process`` and ``pid`` are supplied, but ``process.pid != pid``. **Examples:** Retrieving an application on the system bus where the applications PID is known:: app_proxy = get_proxy_object_for_existing_process(pid=app_pid) Multiple criteria are allowed, for instance you could search on **pid** and **connection_name**:: app_proxy = get_proxy_object_for_existing_process( pid=app_pid, connection_name='org.gnome.Gedit' ) If the application from the previous example was on the system bus:: app_proxy = get_proxy_object_for_existing_process( dbus_bus='system', pid=app_pid, connection_name='org.gnome.Gedit' ) It is possible to search for the application given just the applications name. An example for an application running on a custom bus searching using the applications name:: app_proxy = get_proxy_object_for_existing_process( application_name='qmlscene', dbus_bus='unix:abstract=/tmp/dbus-IgothuMHNk' ) """ # Pop off non-search stuff. dbus_bus = _get_dbus_bus_from_string(kwargs.pop('dbus_bus', 'session')) process = kwargs.pop('process', None) emulator_base = kwargs.pop('emulator_base', None) # Force default object_path kwargs['object_path'] = kwargs.get('object_path', constants.AUTOPILOT_PATH) # Special handling of pid. pid = _check_process_and_pid_details(process, kwargs.get('pid', None)) if pid is not None: kwargs['pid'] = pid matcher_function = _filter_function_from_search_params(kwargs) connections = _find_matching_connections( dbus_bus, matcher_function, process ) if pid is not None: # Due to the filtering including children parents, if there exists a # top-level pid, take that instead of any children that may have # matched. connections = _filter_parent_pids_from_children( pid, connections, dbus_bus ) _raise_if_not_single_result( connections, _get_search_criteria_string_representation(**kwargs) ) object_path = kwargs['object_path'] connection_name = connections[0] return _make_proxy_object( _get_dbus_address_object(connection_name, object_path, dbus_bus), emulator_base ) def get_proxy_object_for_existing_process_by_name( process_name, emulator_base=None ): """Return the proxy object for a process by its name. :param process_name: name of the process to get proxy object. This must be a string. :param emulator_base: emulator base to use with the custom proxy object. :raises ValueError: if process not running or more than one PIDs associated with the process. :return: proxy object for the requested process. """ pid = process_util.get_pid_for_process(process_name) return get_proxy_object_for_existing_process( pid=pid, emulator_base=emulator_base ) def _map_connection_to_pid(connection, dbus_bus): try: return _get_bus_connections_pid(dbus_bus, connection) except dbus.DBusException as e: logger.info( "dbus.DBusException while attempting to get PID for %s: %r" % (connection, e)) def _filter_parent_pids_from_children( pid, connections, dbus_bus, _connection_pid_fn=_map_connection_to_pid): """Return any connections that have an actual pid matching the requested and aren't just a child of that requested pid. :param pid: Pid passed in for matching :param connections: List of connections to filter :param dbus_bus: Dbus object that the connections are contained. :param _connection_pid_fn: Function that takes 2 args 'connection' 'dbus object' that returns the pid (or None if not found) of the connection on that dbus bus. (Note: Useful for testing.) :returns: List of suitable connections (e.g. returns what was passed if no connections match the pid (i.e. all matches are children)). """ for conn in connections: if pid == _connection_pid_fn(conn, dbus_bus): logger.info('Found the parent pid, ignoring any others.') return [conn] return connections def _get_dbus_bus_from_string(dbus_string): if dbus_string == 'session': return dbus_handler.get_session_bus() elif dbus_string == 'system': return dbus_handler.get_system_bus() else: return dbus_handler.get_custom_bus(dbus_string) def _check_process_and_pid_details(process=None, pid=None): """Do error checking on process and pid specification. :raises RuntimeError: if both process and pid are specified, but the process's 'pid' attribute is different to the pid attribute specified. :raises ProcessSearchError: if the process specified is not running. :returns: the pid to use in all search queries. """ if process is not None: if pid is None: pid = process.pid elif pid != process.pid: raise RuntimeError("Supplied PID and process.pid do not match.") if pid is not None and not _pid_is_running(pid): raise ProcessSearchError("PID %d could not be found" % pid) return pid def _filter_function_from_search_params(search_params, filter_lookup=None): filters = _mandatory_filters() + _filters_from_search_parameters( search_params, filter_lookup ) return _filter_function_with_sorted_filters(filters, search_params) def _mandatory_filters(): """Returns a list of Filters that are considered mandatory regardless of the search parameters supplied by the user. """ return [ ConnectionIsNotOurConnection, ConnectionIsNotOrgFreedesktopDBus ] def _filter_lookup_map(): return dict( connection_name=ConnectionHasName, application_name=ConnectionHasAppName, object_path=ConnectionHasPathWithAPInterface, pid=ConnectionHasPid, ) def _filters_from_search_parameters(parameters, filter_lookup=None): parameter_filter_lookup = filter_lookup or _filter_lookup_map() try: filter_list = list({ parameter_filter_lookup[key] for key in parameters.keys() }) return filter_list except KeyError as e: raise KeyError( "Search parameter %s doesn't have a corresponding filter in %r" % (e, parameter_filter_lookup), ) def _filter_function_with_sorted_filters(filters, search_params): """Returns a callable filter function that will take the argument (dbus_tuple). The returned filter function will be bound to use a prioritised filter list and the supplied search parameters dictionary. """ sorted_filter_list = _priority_sort_filters(filters) return partial(_filter_runner, sorted_filter_list, search_params) def _priority_sort_filters(filter_list): return sorted(filter_list, key=methodcaller('priority'), reverse=True) def _filter_runner(filter_list, search_parameters, dbus_tuple): """Helper function to run filters over dbus connections. :param filter_list: List of filters to call matches on passing the provided dbus details and search parameters. :param dbus_tuple: 2 length tuple containing (bus, connection_name) where bus is a SessionBus, SystemBus or BusConnection object and connection_name is a string. :param search_parameters: Dictionary of search parameters that the filters will consume to make their decisions. """ if not filter_list: raise ValueError("Filter list must not be empty") return all( f.matches(dbus_tuple, search_parameters) for f in filter_list ) def _find_matching_connections(bus, connection_matcher, process=None): """Returns a list of connection names that have passed the connection_matcher. :param dbus_bus: A DBus bus object to search (i.e. SessionBus, SystemBus or BusConnection) :param connection_matcher: Callable that takes a connection name and returns True if it is what we're looking for, False otherwise. :param process: (optional) A process object that we're looking for it's dbus connection. Used to ensure that the process is in fact still running while we're searching for it. """ for _ in Timeout.default(): _get_child_pids.reset_cache() _raise_if_process_has_exited(process) connections = bus.list_names() valid_connections = [ c for c in connections if connection_matcher((bus, c)) ] if len(valid_connections) >= 1: return _dedupe_connections_on_pid(valid_connections, bus) return [] def _raise_if_process_has_exited(process): """Raises ProcessSearchError if process is no longer running.""" if process is not None and not _process_is_running(process): return_code = process.poll() raise ProcessSearchError( "Process exited with exit code: %d" % return_code ) def _process_is_running(process): return process.poll() is None def _dedupe_connections_on_pid(valid_connections, bus): seen_pids = [] deduped_connections = [] for connection in valid_connections: pid = _get_bus_connections_pid(bus, connection) if pid not in seen_pids: seen_pids.append(pid) deduped_connections.append(connection) return deduped_connections def _raise_if_not_single_result(connections, criteria_string): if connections is None or len(connections) == 0: raise ProcessSearchError( "Search criteria (%s) returned no results" % (criteria_string) ) if len(connections) > 1: raise RuntimeError( "Search criteria (%s) returned multiple results" % (criteria_string) ) def _get_search_criteria_string_representation(**kwargs): # Some slight re-naming for process objects if kwargs.get('process') is not None: kwargs['process_object'] = "%r" % kwargs.pop('process') return ", ".join([ "%s = %r" % (k.replace("_", " "), v) for k, v in kwargs.items() ]) def _make_proxy_object(dbus_address, emulator_base): """Returns a root proxy object given a DBus service name. :param dbus_address: The DBusAddress object we're querying. :param emulator_base: The emulator base object (or None), as provided by the user. """ # make sure we always have an emulator base. Either use the one the user # gave us, or make one: emulator_base = emulator_base or _make_default_emulator_base() _raise_if_base_class_not_actually_base(emulator_base) # Get the dbus introspection Xml for the backend. intro_xml = _get_introspection_xml_from_backend(dbus_address) try: # Figure out if the backend has any extension methods, and return # classes that understand how to use each of those extensions: extension_classes = _get_proxy_bases_from_introspection_xml(intro_xml) # Register those base classes for everything that will derive from this # emulator base class. _object_registry.register_extension_classes_for_proxy_base( emulator_base, extension_classes, ) except RuntimeError as e: e.args = ( "Could not find Autopilot interface on dbus address '%s'." % dbus_address, ) raise e cls_name, path, cls_state = _get_proxy_object_class_name_and_state( dbus_address ) proxy_class = _object_registry._get_proxy_object_class( emulator_base._id, path, cls_state ) # For this object only, add the ApplicationProxy class, since it's the # root of the tree. Ideally this would be nicer... if ApplicationProxyObject not in proxy_class.__bases__: proxy_class.__bases__ += (ApplicationProxyObject, ) return proxy_class(cls_state, path, backends.Backend(dbus_address)) def _make_default_emulator_base(): """Make a default base class for all proxy classes to derive from.""" return type("DefaultEmulatorBase", (ap_dbus.DBusIntrospectionObject,), {}) WRONG_CPO_CLASS_MSG = '''\ base_class: {passed} does not appear to be the actual base CPO class. Perhaps you meant to use: {actual}.''' def _raise_if_base_class_not_actually_base(base_class): """Raises ValueError if the provided base_class is not actually the base_class To ensure that the expected base classes are used when creating proxy objects. :param base_class: The base class to check. :raises ValueError: The actual base class is not the one provided """ actual_base_class = base_class for cls in base_class.mro(): if hasattr(cls, '_id'): actual_base_class = cls if actual_base_class != base_class: raise( ValueError( WRONG_CPO_CLASS_MSG.format( passed=base_class, actual=actual_base_class ) ) ) def _make_proxy_object_async( data_source, emulator_base, reply_handler, error_handler): """Make a proxy object for a dbus backend. Similar to :meth:`_make_proxy_object` except this method runs asynchronously and must have a reply_handler callable set. The reply_handler will be called with a single argument: The proxy object. """ # Note: read this function backwards! # # Due to the callbacks, I need to define the end of the callback chain # first, so start reading from the bottom of the function upwards, and # it'll make a whole lot more sense. # Final phase: We have all the information we need, now we construct # everything. This phase has no dbus calls, and so is very fast: def build_proxy(introspection_xml, cls_name, path, cls_state): # Figure out if the backend has any extension methods, and return # classes that understand how to use each of those extensions: extension_classes = _get_proxy_bases_from_introspection_xml( introspection_xml ) # Register those base classes for everything that will derive from this # emulator base class. _object_registry.register_extension_classes_for_proxy_base( emulator_base, extension_classes, ) proxy_class = _object_registry._get_proxy_object_class( emulator_base._id, path, cls_state ) reply_handler( proxy_class(cls_state, path, backends.Backend(data_source)) ) # Phase 2: We recieve the introspection string, and make an asynchronous # dbus call to get the state information for the root of this applicaiton. def get_root_state(introspection_xml): _get_proxy_object_class_name_and_state( data_source, reply_handler=partial(build_proxy, introspection_xml), error_handler=error_handler, ) # Phase 1: Make an asynchronous dbus call to get the introspection xml # from the data_source provided for us. emulator_base = emulator_base or _make_default_emulator_base() _get_introspection_xml_from_backend( data_source, reply_handler=get_root_state, error_handler=error_handler ) def _get_introspection_xml_from_backend( backend, reply_handler=None, error_handler=None): """Get DBus Introspection xml from a backend. :param backend: The backend object to query. :param reply_handler: If set, makes a dbus async call, and the result will be sent to reply_handler. This must be a callable object. :param error_handler: If set, this callable will recieve any errors, and the call will be made asyncronously. :returns: A string containing introspection xml, if called synchronously. :raises ValueError: if one, but not both of 'reply_handler' and 'error_handler' are set. """ if callable(reply_handler) and callable(error_handler): backend.dbus_introspection_iface.Introspect( reply_handler=reply_handler, error_handler=error_handler, ) elif reply_handler or error_handler: raise ValueError( "Both 'reply_handler' and 'error_handler' must be set." ) else: return backend.dbus_introspection_iface.Introspect() def _get_proxy_object_class_name_and_state( backend, reply_handler=None, error_handler=None): """Get details about this autopilot backend via a dbus GetState call. :param reply_handler: A callable that must accept three positional arguments, which correspond to the return value of this function when called synchronously. :param error_handler: A callable which will recieve any dbus errors, should they occur. :raises ValueError: if one, but not both of reply_handler and error_handler are set. :returns: A tuple containing the class name of the root of the introspection tree, the full path to the root of the introspection tree, and the state dictionary of the root node in the introspection tree. """ if callable(reply_handler) and callable(error_handler): # Async call: # Since we get an array of state, and we only care about the first one # we use a lambda to unpack it and get the details we want. backend.introspection_iface.GetState( "/", reply_handler=lambda r: reply_handler( *_get_details_from_state_data(r[0]) ), error_handler=error_handler, ) elif reply_handler or error_handler: raise ValueError( "Both 'reply_handler' and 'error_handler' must be set." ) else: # Sync call state = backend.introspection_iface.GetState("/")[0] return _get_details_from_state_data(state) def _get_details_from_state_data(state_data): """Get details from a state data array. Returns class name, path, and state dictionary. """ object_path, object_state = state_data return ( get_classname_from_path(object_path), object_path.encode('utf-8'), object_state, ) def _get_proxy_bases_from_introspection_xml(introspection_xml): """Return tuple of the base classes to use when creating a proxy object. Currently this works by looking for certain interface names in the XML. In the future we may want to parse the XML and perform more rigerous checks. :param introspection_xml: An xml string that describes the exported object on the dbus backend. This determines which capabilities are present in the backend, and therefore which base classes should be used to create the proxy object. :raises RuntimeError: if the autopilot interface cannot be found. """ bases = [] if constants.AP_INTROSPECTION_IFACE not in introspection_xml: raise RuntimeError("Could not find Autopilot interface.") if constants.QT_AUTOPILOT_IFACE in introspection_xml: from autopilot.introspection.qt import QtObjectProxyMixin bases.append(QtObjectProxyMixin) return tuple(bases) class ApplicationProxyObject(object): """A class that better supports query data from an application.""" def __init__(self): self._process = None def set_process(self, process): """Set the subprocess.Popen object of the process that this is a proxy for. You should never normally need to call this method. """ self._process = process @property def pid(self): return self._process.pid @property def process(self): return self._process @deprecated( "the AutopilotTestCase launch_test_application method to handle" " cleanup of launched applications." ) def kill_application(self): """Kill the running process that this is a proxy for using 'kill `pid`'.""" subprocess.call(["kill", "%d" % self._process.pid]) def _extend_proxy_bases_with_emulator_base(proxy_bases, emulator_base): if emulator_base is None: emulator_base = type( 'DefaultEmulatorBase', (ap_dbus.CustomEmulatorBase,), {} ) return proxy_bases + (emulator_base, ) def _get_dbus_address_object(connection_name, object_path, bus): return backends.DBusAddress(bus, connection_name, object_path) class _cached_get_child_pids(object): """Get a list of all child process Ids, for the given parent. Since we call this often, and it's a very expensive call, we optimise this such that the return value will be cached for each scan through the dbus bus. Calling reset_cache() at the end of each dbus scan will ensure that you get fresh values on the next call. """ def __init__(self): self._cached_result = None def __call__(self, pid): if self._cached_result is None: self._cached_result = [ p.pid for p in psutil.Process(pid).children(recursive=True) ] return self._cached_result def reset_cache(self): self._cached_result = None _get_child_pids = _cached_get_child_pids() # Filters class ConnectionIsNotOrgFreedesktopDBus(object): """Not interested in any connections with names 'org.freedesktop.DBus'.""" @classmethod def priority(cls): """A connection with this name will never be valid.""" return 13 @classmethod def matches(cls, dbus_tuple, params): bus, connection_name = dbus_tuple return connection_name != 'org.freedesktop.DBus' class ConnectionIsNotOurConnection(object): """Ensure we're not inspecting our own bus connection.""" @classmethod def priority(cls): """The connection from this process will never be valid.""" return 12 @classmethod def matches(cls, dbus_tuple, params): try: bus, connection_name = dbus_tuple bus_pid = _get_bus_connections_pid(bus, connection_name) return bus_pid != os.getpid() except dbus.DBusException: return False class ConnectionHasName(object): """Ensure connection_name within dbus_tuple is the name we want.""" @classmethod def priority(cls): """Connection name is easy to check for and if not valid, nothing else will be. """ return 11 @classmethod def matches(cls, dbus_tuple, params): """Returns true if the connection name in dbus_tuple is the name in the search criteria params. """ requested_connection_name = params['connection_name'] bus, connection_name = dbus_tuple return connection_name == requested_connection_name class ConnectionHasPid(object): """Match a connection based on the connections pid.""" @classmethod def priority(cls): return 9 @classmethod def matches(cls, dbus_tuple, params): """Match a connection based on the connections pid. :raises KeyError: if the pid parameter isn't passed in params. """ pid = params['pid'] bus, connection_name = dbus_tuple try: bus_pid = _get_bus_connections_pid(bus, connection_name) except dbus.DBusException as e: logger.info( "dbus.DBusException while attempting to get PID for %s: %r" % (connection_name, e)) return False eligible_pids = [pid] + _get_child_pids(pid) return bus_pid in eligible_pids class ConnectionHasPathWithAPInterface(object): """Ensure that the connection exposes the Autopilot interface.""" @classmethod def priority(cls): return 8 @classmethod def matches(cls, dbus_tuple, params): """Ensure the connection has the path that we expect to be there. :raises KeyError: if the object_path parameter isn't included in params. """ try: bus, connection_name = dbus_tuple path = params['object_path'] obj = bus.get_object(connection_name, path) dbus.Interface( obj, 'com.canonical.Autopilot.Introspection' ).GetVersion() return True except dbus.DBusException: return False class ConnectionHasAppName(object): """Ensure the connection has the requested Application name.""" @classmethod def priority(cls): return 0 @classmethod def matches(cls, dbus_tuple, params): """Returns True if dbus_tuple has the required application name. Can be provided an optional object_path parameter. This defaults to :py:data:`autopilot.introspection.constants.AUTOPILOT_PATH` if not provided. This filter should only activated if the application_name is provided in the search criteria. :raises KeyError: if the 'application_name' parameter isn't passed in params """ requested_app_name = params['application_name'] object_path = params.get('object_path', constants.AUTOPILOT_PATH) bus, connection_name = dbus_tuple try: app_name = cls._get_application_name( bus, connection_name, object_path ) return app_name == requested_app_name except WireProtocolVersionMismatch: return False @classmethod def _get_application_name(cls, bus, connection_name, object_path): dbus_object = _get_dbus_address_object( connection_name, object_path, bus ) return cls._get_application_name_from_dbus_address(dbus_object) @classmethod def _get_application_name_from_dbus_address(cls, dbus_address): """Return the application name from a dbus_address object.""" return get_classname_from_path( dbus_address.introspection_iface.GetState('/')[0][0] ) ./autopilot/introspection/constants.py0000644000004100000410000000213714002063564020554 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Package for the constants used for introspection""" AUTOPILOT_PATH = "/com/canonical/Autopilot/Introspection" QT_AUTOPILOT_IFACE = 'com.canonical.Autopilot.Qt' AP_INTROSPECTION_IFACE = 'com.canonical.Autopilot.Introspection' DBUS_INTROSPECTION_IFACE = 'org.freedesktop.DBus.Introspectable' CURRENT_WIRE_PROTOCOL_VERSION = "1.4" ./autopilot/introspection/types.py0000644000004100000410000006000714002063564017704 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """ Autopilot proxy type support. ============================= This module defines the classes that are used for all attributes on proxy objects. All proxy objects contain attributes that transparently mirror the values present in the application under test. Autopilot takes care of keeping these values up to date. Object attributes fall into two categories. Attributes that are a single string, boolean, or integer property are sent directly across DBus. These are called "plain" types, and are stored in autopilot as instnaces of the :class:`PlainType` class. Attributes that are more complex (a rectangle, for example) are called "complex" types, and are split into several component values, sent across dbus, and are then reconstituted in autopilot into useful objects. """ import pytz from datetime import datetime, time, timedelta from dateutil.tz import gettz import dbus import logging from testtools.matchers import Equals from autopilot.introspection.utilities import translate_state_keys from autopilot.utilities import sleep, compatible_repr _logger = logging.getLogger(__name__) class ValueType(object): """Store constants for different special types that autopilot understands. DO NOT add items here unless you have documented them correctly in docs/appendix/protocol.rst. """ PLAIN = 0 RECTANGLE = 1 POINT = 2 SIZE = 3 COLOR = 4 DATETIME = 5 TIME = 6 POINT3D = 7 UNKNOWN = -1 def create_value_instance(value, parent, name): """Create an object that exposes the interesing part of the value specified, given the value_type_id. :param parent: The object this attribute belongs to. :param name: The name of this attribute. :param value: The value array from DBus. """ type_dict = { ValueType.PLAIN: _make_plain_type, ValueType.RECTANGLE: Rectangle, ValueType.COLOR: Color, ValueType.POINT: Point, ValueType.SIZE: Size, ValueType.DATETIME: DateTime, ValueType.TIME: Time, ValueType.POINT3D: Point3D, ValueType.UNKNOWN: _make_plain_type, } type_id = value[0] value = value[1:] if type_id not in type_dict: _logger.warning("Unknown type id %d", type_id) type_id = ValueType.UNKNOWN type_class = type_dict.get(type_id, None) if type_id == ValueType.UNKNOWN: value = [dbus.Array(value)] if len(value) == 0: raise ValueError("Cannot create attribute, no data supplied") return type_class(*value, parent=parent, name=name) class TypeBase(object): def wait_for(self, expected_value, timeout=10): """Wait up to 10 seconds for our value to change to *expected_value*. *expected_value* can be a testtools.matcher. Matcher subclass (like LessThan, for example), or an ordinary value. This works by refreshing the value using repeated dbus calls. :raises AssertionError: if the attribute was not equal to the expected value after 10 seconds. :raises RuntimeError: if the attribute you called this on was not constructed as part of an object. """ # It's guaranteed that our value is up to date, since __getattr__ # calls refresh_state. This if statement stops us waiting if the # value is already what we expect: if self == expected_value: return if self.name is None or self.parent is None: raise RuntimeError( "This variable was not constructed as part of " "an object. The wait_for method cannot be used." ) def make_unicode(value): if isinstance(value, bytes): return value.decode('utf8') return value if hasattr(expected_value, 'expected'): expected_value.expected = make_unicode(expected_value.expected) # unfortunately not all testtools matchers derive from the Matcher # class, so we can't use issubclass, isinstance for this: match_fun = getattr(expected_value, 'match', None) is_matcher = match_fun and callable(match_fun) if not is_matcher: expected_value = Equals(expected_value) time_left = timeout while True: # TODO: These next three lines are duplicated from the parent... # can we just have this code once somewhere? _, new_state = self.parent._get_new_state() new_state = translate_state_keys(new_state) new_value = new_state[self.name][1:] if len(new_value) == 1: new_value = make_unicode(new_value[0]) # Support for testtools.matcher classes: mismatch = expected_value.match(new_value) if mismatch: failure_msg = mismatch.describe() else: self.parent._set_properties(new_state) return if time_left >= 1: sleep(1) time_left -= 1 else: sleep(time_left) break raise AssertionError( "After %.1f seconds test on %s.%s failed: %s" % ( timeout, self.parent.__class__.__name__, self.name, failure_msg)) class PlainType(TypeBase): """Plain type support in autopilot proxy objects. Instances of this class will be used for all plain attrubites. The word "plain" in this context means anything that's marshalled as a string, boolean or integer type across dbus. Instances of these classes can be used just like the underlying type. For example, given an object property called 'length' that is marshalled over dbus as an integer value, the following will be true:: >>> isinstance(object.length, PlainType) True >>> isinstance(object.length, int) True >>> print(object.length) 123 >>> print(object.length + 32) 155 However, a special case exists for boolean values: because you cannot subclass from the 'bool' type, the following check will fail ( ``object.visible`` is a boolean property):: >>> isinstance(object.visible, bool) False However boolean values will behave exactly as you expect them to. """ def __new__(cls, value, parent=None, name=None): return _make_plain_type(value, parent=parent, name=name) def _get_repr_callable_for_value_class(cls): repr_map = { dbus.Byte: _integer_repr, dbus.Int16: _integer_repr, dbus.Int32: _integer_repr, dbus.UInt16: _integer_repr, dbus.UInt32: _integer_repr, dbus.Int64: _integer_repr, dbus.UInt64: _integer_repr, dbus.String: _text_repr, dbus.ObjectPath: _text_repr, dbus.Signature: _text_repr, dbus.ByteArray: _bytes_repr, dbus.Boolean: _boolean_repr, dbus.Dictionary: _dict_repr, dbus.Double: _float_repr, dbus.Struct: _tuple_repr, dbus.Array: _list_repr, } return repr_map.get(cls, None) def _get_str_callable_for_value_class(cls): str_map = { dbus.Boolean: _boolean_str, dbus.Byte: _integer_str, } return str_map.get(cls, None) @compatible_repr def _integer_repr(self): return str(int(self)) def _create_generic_repr(target_type): return compatible_repr(lambda self: repr(target_type(self))) _bytes_repr = _create_generic_repr(bytes) _text_repr = _create_generic_repr(str) _dict_repr = _create_generic_repr(dict) _list_repr = _create_generic_repr(list) _tuple_repr = _create_generic_repr(tuple) _float_repr = _create_generic_repr(float) _boolean_repr = _create_generic_repr(bool) def _create_generic_str(target_type): return compatible_repr(lambda self: str(target_type(self))) _boolean_str = _create_generic_str(bool) _integer_str = _integer_repr def _make_plain_type(value, parent=None, name=None): new_type = _get_plain_type_class(type(value), parent, name) return new_type(value) # Thomi 2014-03-27: dbus types are immutable, which means that we cannot set # parent and name on the instances we create. This means we have to set them # as type attributes, which means that this cache doesn't speed things up that # much. Ideally we'd not rely on the dbus types at all, and simply transform # them into our own types, but that's work for a separate branch. # # Further to the above, we cannot cache these results, since the hash for # the parent parameter is almost always the same, leading to incorrect cache # hits. We really need to implement our own types here I think. def _get_plain_type_class(value_class, parent, name): new_type_name = value_class.__name__ new_type_bases = (value_class, PlainType) new_type_dict = dict(parent=parent, name=name) repr_callable = _get_repr_callable_for_value_class(value_class) if repr_callable: new_type_dict['__repr__'] = repr_callable str_callable = _get_str_callable_for_value_class(value_class) if str_callable: new_type_dict['__str__'] = str_callable return type(new_type_name, new_type_bases, new_type_dict) def _array_packed_type(num_args): """Return a base class that accepts 'num_args' and is packed into a dbus Array type. """ class _ArrayPackedType(dbus.Array, TypeBase): def __init__(self, *args, **kwargs): if len(args) != self._required_arg_count: raise ValueError( "%s must be constructed with %d arguments, not %d" % ( self.__class__.__name__, self._required_arg_count, len(args) ) ) super(_ArrayPackedType, self).__init__(args) # TODO: pop instead of get, and raise on unknown kwarg self.parent = kwargs.get("parent", None) self.name = kwargs.get("name", None) return type( "_ArrayPackedType_{}".format(num_args), (_ArrayPackedType,), dict(_required_arg_count=num_args) ) class Rectangle(_array_packed_type(4)): """The RectangleType class represents a rectangle in cartesian space. To construct a rectangle, pass the x, y, width and height parameters in to the class constructor:: my_rect = Rectangle(12,13,100,150) These attributes can be accessed either using named attributes, or via sequence indexes:: >>>my_rect = Rectangle(12,13,100,150) >>> my_rect.x == my_rect[0] == 12 True >>> my_rect.y == my_rect[1] == 13 True >>> my_rect.w == my_rect[2] == 100 True >>> my_rect.h == my_rect[3] == 150 True You may also access the width and height values using the ``width`` and ``height`` properties:: >>> my_rect.width == my_rect.w True >>> my_rect.height == my_rect.h True Rectangles can be compared using ``==`` and ``!=``, either to another Rectangle instance, or to any mutable sequence type:: >>> my_rect == [12, 13, 100, 150] True >>> my_rect != Rectangle(1,2,3,4) True """ @property def x(self): return self[0] @property def y(self): return self[1] @property def w(self): return self[2] @property def width(self): return self[2] @property def h(self): return self[3] @property def height(self): return self[3] @compatible_repr def __repr__(self): coords = ', '.join((str(c) for c in self)) return 'Rectangle(%s)' % (coords) class Point(_array_packed_type(2)): """The Point class represents a 2D point in cartesian space. To construct a Point, pass in the x, y parameters to the class constructor:: >>> my_point = Point(50,100) These attributes can be accessed either using named attributes, or via sequence indexes:: >>> my_point.x == my_point[0] == 50 True >>> my_point.y == my_point[1] == 100 True Point instances can be compared using ``==`` and ``!=``, either to another Point instance, or to any mutable sequence type with the correct number of items:: >>> my_point == [50, 100] True >>> my_point != Point(5, 10) True """ @property def x(self): return self[0] @property def y(self): return self[1] @compatible_repr def __repr__(self): return 'Point(%d, %d)' % (self.x, self.y) class Size(_array_packed_type(2)): """The Size class represents a 2D size in cartesian space. To construct a Size, pass in the width, height parameters to the class constructor:: >>> my_size = Size(50,100) These attributes can be accessed either using named attributes, or via sequence indexes:: >>> my_size.width == my_size.w == my_size[0] == 50 True >>> my_size.height == my_size.h == my_size[1] == 100 True Size instances can be compared using ``==`` and ``!=``, either to another Size instance, or to any mutable sequence type with the correct number of items:: >>> my_size == [50, 100] True >>> my_size != Size(5, 10) True """ @property def w(self): return self[0] @property def width(self): return self[0] @property def h(self): return self[1] @property def height(self): return self[1] @compatible_repr def __repr__(self): return 'Size(%d, %d)' % (self.w, self.h) class Color(_array_packed_type(4)): """The Color class represents an RGBA Color. To construct a Color, pass in the red, green, blue and alpha parameters to the class constructor:: >>> my_color = Color(50, 100, 200, 255) These attributes can be accessed either using named attributes, or via sequence indexes:: >>> my_color.red == my_color[0] == 50 True >>> my_color.green == my_color[1] == 100 True >>> my_color.blue == my_color[2] == 200 True >>> my_color.alpha == my_color[3] == 255 True Color instances can be compared using ``==`` and ``!=``, either to another Color instance, or to any mutable sequence type with the correct number of items:: >>> my_color == [50, 100, 200, 255] True >>> my_color != Color(5, 10, 0, 0) True """ @property def red(self): return self[0] @property def green(self): return self[1] @property def blue(self): return self[2] @property def alpha(self): return self[3] @compatible_repr def __repr__(self): return 'Color(%d, %d, %d, %d)' % ( self.red, self.green, self.blue, self.alpha ) class DateTime(_array_packed_type(1)): """The DateTime class represents a date and time in the UTC timezone. DateTime is constructed by passing a unix timestamp in to the constructor. The incoming timestamp is assumed to be in UTC. .. note:: This class expects the passed in timestamp to be in UTC but will display the resulting date and time in local time (using the local timezone). This is done to mimic the behaviour of most applications which will display date and time in local time by default Timestamps are expressed as the number of seconds since 1970-01-01T00:00:00 in the UTC timezone:: >>> my_dt = DateTime(1377209927) This timestamp can always be accessed either using index access or via a named property:: >>> my_dt[0] == my_dt.timestamp == 1377209927 True DateTime objects also expose the usual named properties you would expect on a date/time object:: >>> my_dt.year 2013 >>> my_dt.month 8 >>> my_dt.day 22 >>> my_dt.hour 22 >>> my_dt.minute 18 >>> my_dt.second 47 Two DateTime objects can be compared for equality:: >>> my_dt == DateTime(1377209927) True You can also compare a DateTime with any mutable sequence type containing the timestamp (although this probably isn't very useful for test authors):: >>> my_dt == [1377209927] True Finally, you can also compare a DateTime instance with a python datetime instance:: >>> my_datetime = datetime.datetime.utcfromtimestamp(1377209927) True .. note:: Autopilot supports dates beyond 2038 on 32-bit platforms. To achieve this the underlying mechanisms require to work with timezone aware datetime objects. This means that the following won't always be true (due to the naive timestamp not having the correct daylight-savings time details):: >>> # This time stamp is within DST in the 'Europe/London' timezone >>> dst_ts = 1405382400 >>> os.environ['TZ'] ='Europe/London' >>> time.tzset() >>> datetime.fromtimestamp(dst_ts).hour == DateTime(dst_ts).hour False But this will work:: >>> from dateutil.tz import gettz >>> datetime.fromtimestamp( dst_ts, gettz()).hour == DateTime(dst_ts).hour True And this will always work to:: >>> dt1 = DateTime(nz_dst_timestamp) >>> dt2 = datetime( dt1.year, dt1.month, dt1.day, dt1.hour, dt1.minute, dt1.second ) >>> dt1 == dt2 True .. note:: DateTime.timestamp() will not always equal the passed in timestamp. To paraphrase a message from [http://bugs.python.org/msg229393] "datetime.timestamp is supposed to be inverse of datetime.fromtimestamp(), but since the later is not monotonic, no such inverse exists in the strict mathematical sense." DateTime instances can be converted to datetime instances:: >>> isinstance(my_dt.datetime, datetime.datetime) True """ def __init__(self, *args, **kwargs): super(DateTime, self).__init__(*args, **kwargs) # Using timedelta in this manner is a workaround so that we can support # timestamps larger than the 32bit time_t limit on 32bit hardware. # We then apply the timezone information to this to get the correct # datetime. # # Note. self[0] is a UTC timestamp utc = pytz.timezone('UTC') EPOCH = datetime(1970, 1, 1, tzinfo=utc) utc_dt = EPOCH + timedelta(seconds=self[0]) self._cached_dt = utc_dt.astimezone(gettz()) @property def year(self): return self._cached_dt.year @property def month(self): return self._cached_dt.month @property def day(self): return self._cached_dt.day @property def hour(self): return self._cached_dt.hour @property def minute(self): return self._cached_dt.minute @property def second(self): return self._cached_dt.second @property def timestamp(self): return self._cached_dt.timestamp() @property def datetime(self): return self._cached_dt def __eq__(self, other): # A little 'magic' here, if the datetime object to test against is # naive, use the tzinfo from the cached datetime (just for the # comparison) if isinstance(other, datetime): if other.tzinfo is None: return other.replace( tzinfo=self._cached_dt.tzinfo ) == self._cached_dt return other == self._cached_dt return super(DateTime, self).__eq__(other) @compatible_repr def __repr__(self): return 'DateTime(%d-%02d-%02d %02d:%02d:%02d)' % ( self.year, self.month, self.day, self.hour, self.minute, self.second ) class Time(_array_packed_type(4)): """The Time class represents a time, without a date component. You can construct a Time instnace by passing the hours, minutes, seconds, and milliseconds to the class constructor:: >>> my_time = Time(12, 34, 01, 23) The values passed in must be valid for their positions (ie..- 0-23 for hours, 0-59 for minutes and seconds, and 0-999 for milliseconds). Passing invalid values will cause a ValueError to be raised. The hours, minutes, seconds, and milliseconds can be accessed using either index access or named properties:: >>> my_time.hours == my_time[0] == 12 True >>> my_time.minutes == my_time[1] == 34 True >>> my_time.seconds == my_time[2] == 01 True >>> my_time.milliseconds == my_time[3] == 23 True Time instances can be compared to other time instances, any mutable sequence containing four integers, or datetime.time instances:: >>> my_time == Time(12, 34, 01, 23) True >>> my_time == Time(1,2,3,4) False >>> my_time == [12, 34, 01, 23] True >>> my_time == datetime.time(12, 34, 01, 23000) True Note that the Time class stores milliseconds, while the ``datettime.time`` class stores microseconds. Finally, you can get a ``datetime.time`` instance from a Time instance:: >>> isinstance(my_time.time, datetime.time) True """ def __init__(self, *args, **kwargs): super(Time, self).__init__(*args, **kwargs) # datetime.time uses microseconds, instead of mulliseconds: self._cached_time = time(self[0], self[1], self[2], self[3] * 1000) @property def hour(self): return self._cached_time.hour @property def minute(self): return self._cached_time.minute @property def second(self): return self._cached_time.second @property def millisecond(self): return self._cached_time.microsecond / 1000 @property def time(self): return self._cached_time def __eq__(self, other): if isinstance(other, time): return other == self._cached_time return super(Time, self).__eq__(other) @compatible_repr def __repr__(self): return 'Time(%02d:%02d:%02d.%03d)' % ( self.hour, self.minute, self.second, self.millisecond ) class Point3D(_array_packed_type(3)): """The Point3D class represents a 3D point in cartesian space. To construct a Point3D, pass in the x, y and z parameters to the class constructor:: >>> my_point = Point(50,100,20) These attributes can be accessed either using named attributes, or via sequence indexes:: >>> my_point.x == my_point[0] == 50 True >>> my_point.y == my_point[1] == 100 True >>> my_point.z == my_point[2] == 20 True Point3D instances can be compared using ``==`` and ``!=``, either to another Point3D instance, or to any mutable sequence type with the correct number of items:: >>> my_point == [50, 100, 20] True >>> my_point != Point(5, 10, 2) True """ @property def x(self): return self[0] @property def y(self): return self[1] @property def z(self): return self[2] @compatible_repr def __repr__(self): return 'Point3D(%d, %d, %d)' % ( self.x, self.y, self.z, ) ./autopilot/introspection/dbus.py0000644000004100000410000010107414002063564017475 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """This module contains the code to retrieve state via DBus calls. Under normal circumstances, the only thing you should need to use from this module is the DBusIntrospectableObject class. """ import logging import sys from contextlib import contextmanager from autopilot.exceptions import StateNotFoundError from autopilot.introspection import _xpathselect as xpathselect from autopilot.introspection._object_registry import ( DBusIntrospectionObjectBase, ) from autopilot.introspection.types import create_value_instance from autopilot.introspection.utilities import ( translate_state_keys, sort_by_keys, ) from autopilot.utilities import sleep _logger = logging.getLogger(__name__) class DBusIntrospectionObject(DBusIntrospectionObjectBase): """A class that supports transparent data retrieval from the application under test. This class is the base class for all objects retrieved from the application under test. It handles transparently refreshing attribute values when needed, and contains many methods to select child objects in the introspection tree. This class must be used as a base class for any custom proxy classes. .. seealso:: Tutorial Section :ref:`custom_proxy_classes` Information on how to write custom proxy classes. """ def __init__(self, state_dict, path, backend): """Construct a new proxy instance. :param state_dict: A dictionary of state data for the proxy object. :param path: A bytestring describing the path to the object within the introspection tree. :param backend: The data source backend this proxy should use then retrieving additional state data. The state dictionary must contain an 'id' element, as this is used to uniquely identify this object. """ self.__state = {} self.__refresh_on_attribute = True self._set_properties(state_dict) self._path = path self._backend = backend self._query = xpathselect.Query.new_from_path_and_id( self._path, self.id ) def _execute_query(self, query): """Execute query object 'query' and return the result.""" return self._backend.execute_query_get_proxy_instances( query, getattr(self, '_id', None), ) def _set_properties(self, state_dict): """Creates and set attributes of *self* based on contents of *state_dict*. .. note:: Translates '-' to '_', so a key of 'icon-type' for example becomes 'icon_type'. """ # don't store id in state dictionary - make it a proper instance # attribute. If id is not present, raise a ValueError. try: self.id = int(state_dict['id'][1]) except KeyError: raise ValueError( "State dictionary does not contain required 'id' key." ) self.__state = {} for key, value in translate_state_keys(state_dict).items(): if key == 'id': continue try: self.__state[key] = create_value_instance(value, self, key) except ValueError as e: _logger.warning( "While constructing attribute '%s.%s': %s", self.__class__.__name__, key, str(e) ) def get_children_by_type(self, desired_type, **kwargs): """Get a list of children of the specified type. Keyword arguments can be used to restrict returned instances. For example:: get_children_by_type('Launcher', monitor=1) will return only Launcher instances that have an attribute 'monitor' that is equal to 1. The type can also be specified as a string, which is useful if there is no emulator class specified:: get_children_by_type('Launcher', monitor=1) Note however that if you pass a string, and there is an emulator class defined, autopilot will not use it. :param desired_type: Either a string naming the type you want, or a class of the type you want (the latter is used when defining custom emulators) .. seealso:: Tutorial Section :ref:`custom_proxy_classes` """ new_query = self._query.select_child( get_type_name(desired_type), kwargs ) return self._execute_query(new_query) def get_properties(self): """Returns a dictionary of all the properties on this class. This can be useful when you want to log all the properties exported from your application for a particular object. Every property in the returned dictionary can be accessed as attributes of the object as well. """ # Since we're grabbing __state directly there's no implied state # refresh, so do it manually: self.refresh_state() props = self.__state.copy() props['id'] = self.id return props def get_children(self): """Returns a list of all child objects. This returns a list of all children. To return only children of a specific type, use :meth:`get_children_by_type`. To get objects further down the introspection tree (i.e.- nodes that may not necessarily be immeadiate children), use :meth:`select_single` and :meth:`select_many`. """ # Thomi: 2014-03-20: There used to be a call to 'self.refresh_state()' # here. That's not needed, since the only thing we use is the proxy # path, which isn't affected by the current state. new_query = self._query.select_child(xpathselect.Query.WILDCARD) return self._execute_query(new_query) def _get_parent(self, base_object=None, level=1): """Returns the parent of this object. Note: *level* is in ascending order, i.e. its value as 1 will return the immediate parent of this object or (optionally) *base_object*, if provided. """ obj = base_object or self new_query = obj._query for i in range(level): new_query = new_query.select_parent() return obj._execute_query(new_query)[0] def _get_parent_nodes(self): parent_nodes = self.get_path().split('/') parent_nodes.pop() # return a list without any NoneType elements. Only needed for the # case when we try to get parent of a root object return [node for node in parent_nodes if node] def get_parent(self, type_name='', **kwargs): """Returns the parent of this object. One may also use this method to get a specific parent node from the introspection tree, with type equal to *type_name* or matching the keyword filters present in *kwargs*. Note: The priority order is closest parent. If no filters are provided and this object has no parent (i.e.- it is the root of the introspection tree). Then it returns itself. :param type_name: Either a string naming the type you want, or a class of the appropriate type (the latter case is for overridden emulator classes). :raises StateNotFoundError: if the requested object was not found. """ if not type_name and not kwargs: return self._get_parent() parent_nodes = self._get_parent_nodes() type_name_str = get_type_name(type_name) if type_name: # Raise if type_name is not a parent. if type_name_str not in parent_nodes: raise StateNotFoundError(type_name_str, **kwargs) for index, node in reversed(list(enumerate(parent_nodes))): if node == type_name_str: parent_level = len(parent_nodes) - index parent = self._get_parent(level=parent_level) if _validate_object_properties(parent, **kwargs): return parent else: # Keep a reference of the parent object to improve performance. parent = self for i in range(len(parent_nodes)): parent = self._get_parent(base_object=parent) if _validate_object_properties(parent, **kwargs): return parent raise StateNotFoundError(type_name_str, **kwargs) def _select(self, type_name_str, **kwargs): """Base method to execute search query on the DBus.""" new_query = self._query.select_descendant(type_name_str, kwargs) _logger.debug( "Selecting object(s) of %s with attributes: %r", 'any type' if type_name_str == '*' else 'type ' + type_name_str, kwargs ) return self._execute_query(new_query) def _select_single(self, type_name, **kwargs): """ Ensures a single search result is produced from the query and returns it. """ type_name_str = get_type_name(type_name) instances = self._select(type_name_str, **kwargs) if not instances: raise StateNotFoundError(type_name_str, **kwargs) if len(instances) > 1: raise ValueError("More than one item was returned for query") return instances[0] def select_single(self, type_name='*', **kwargs): """Get a single node from the introspection tree, with type equal to *type_name* and (optionally) matching the keyword filters present in *kwargs*. You must specify either *type_name*, keyword filters or both. This method searches recursively from the instance this method is called on. Calling :meth:`select_single` on the application (root) proxy object will search the entire tree. Calling :meth:`select_single` on an object in the tree will only search it's descendants. Example usage:: app.select_single('QPushButton', objectName='clickme') # returns a QPushButton whose 'objectName' property is 'clickme'. If nothing is returned from the query, this method raises StateNotFoundError. :param type_name: Either a string naming the type you want, or a class of the appropriate type (the latter case is for overridden emulator classes). :raises ValueError: if the query returns more than one item. *If you want more than one item, use select_many instead*. :raises ValueError: if neither *type_name* or keyword filters are provided. :raises StateNotFoundError: if the requested object was not found. .. seealso:: Tutorial Section :ref:`custom_proxy_classes` """ return self._select_single(type_name, **kwargs) def wait_select_single(self, type_name='*', ap_query_timeout=10, **kwargs): """Get a proxy object matching some search criteria, retrying if no object is found until a timeout is reached. This method is identical to the :meth:`select_single` method, except that this method will poll the application under test for 10 seconds in the event that the search criteria does not match anything. This method will return single proxy object from the introspection tree, with type equal to *type_name* and (optionally) matching the keyword filters present in *kwargs*. You must specify either *type_name*, keyword filters or both. This method searches recursively from the proxy object this method is called on. Calling :meth:`select_single` on the application (root) proxy object will search the entire tree. Calling :meth:`select_single` on an object in the tree will only search it's descendants. Example usage:: app.wait_select_single('QPushButton', objectName='clickme') # returns a QPushButton whose 'objectName' property is 'clickme'. # will poll the application until such an object exists, or will # raise StateNotFoundError after 10 seconds. If nothing is returned from the query, this method raises StateNotFoundError after *ap_query_timeout* seconds. :param type_name: Either a string naming the type you want, or a class of the appropriate type (the latter case is for overridden emulator classes). :param ap_query_timeout: Time in seconds to wait for search criteria to match. :raises ValueError: if the query returns more than one item. *If you want more than one item, use select_many instead*. :raises ValueError: if neither *type_name* or keyword filters are provided. :raises StateNotFoundError: if the requested object was not found. .. seealso:: Tutorial Section :ref:`custom_proxy_classes` """ if ap_query_timeout <= 0: return self._select_single(type_name, **kwargs) for i in range(ap_query_timeout): try: return self._select_single(type_name, **kwargs) except StateNotFoundError: sleep(1) raise StateNotFoundError(type_name, **kwargs) def _select_many(self, type_name, **kwargs): """Executes a query, with no restraints on the number of results.""" type_name_str = get_type_name(type_name) return self._select(type_name_str, **kwargs) def select_many(self, type_name='*', ap_result_sort_keys=None, **kwargs): """Get a list of nodes from the introspection tree, with type equal to *type_name* and (optionally) matching the keyword filters present in *kwargs*. You must specify either *type_name*, keyword filters or both. This method searches recursively from the instance this method is called on. Calling :meth:`select_many` on the application (root) proxy object will search the entire tree. Calling :meth:`select_many` on an object in the tree will only search it's descendants. Example Usage:: app.select_many('QPushButton', enabled=True) # returns a list of QPushButtons that are enabled. As mentioned above, this method searches the object tree recursively:: file_menu = app.select_one('QMenu', title='File') file_menu.select_many('QAction') # returns a list of QAction objects who appear below file_menu in # the object tree. .. warning:: The order in which objects are returned is not guaranteed. It is bad practise to write tests that depend on the order in which this method returns objects. (see :ref:`object_ordering` for more information). If you want to ensure a certain count of results retrieved from this method, use :meth:`wait_select_many` or if you only want to get one item, use :meth:`select_single` instead. :param type_name: Either a string naming the type you want, or a class of the appropriate type (the latter case is for overridden emulator classes). :param ap_result_sort_keys: list of object properties to sort the query result with (sort key priority starts with element 0 as highest priority and then descends down the list). :raises ValueError: if neither *type_name* or keyword filters are provided. .. seealso:: Tutorial Section :ref:`custom_proxy_classes` """ instances = self._select_many(type_name, **kwargs) return sort_by_keys(instances, ap_result_sort_keys) def wait_select_many( self, type_name='*', ap_query_timeout=10, ap_result_count=1, ap_result_sort_keys=None, **kwargs ): """Get a list of nodes from the introspection tree, with type equal to *type_name* and (optionally) matching the keyword filters present in *kwargs*. This method is identical to the :meth:`select_many` method, except that this method will poll the application under test for *ap_query_timeout* seconds in the event that the search result count is not greater than or equal to *ap_result_count*. You must specify either *type_name*, keyword filters or both. This method searches recursively from the instance this method is called on. Calling :meth:`wait_select_many` on the application (root) proxy object will search the entire tree. Calling :meth:`wait_select_many` on an object in the tree will only search it's descendants. Example Usage:: app.wait_select_many( 'QPushButton', ap_query_timeout=5, ap_result_count=2, enabled=True ) # returns at least 2 QPushButtons that are enabled, within # 5 seconds. .. warning:: The order in which objects are returned is not guaranteed. It is bad practise to write tests that depend on the order in which this method returns objects. (see :ref:`object_ordering` for more information). :param type_name: Either a string naming the type you want, or a class of the appropriate type (the latter case is for overridden emulator classes). :param ap_query_timeout: Time in seconds to wait for search criteria to match. :param ap_result_count: Minimum number of results to return. :param ap_result_sort_keys: list of object properties to sort the query result with (sort key priority starts with element 0 as highest priority and then descends down the list). :raises ValueError: if neither *type_name* or keyword filters are provided. Also raises, if search result count does not match the number specified by *ap_result_count* within *ap_query_timeout* seconds. .. seealso:: Tutorial Section :ref:`custom_proxy_classes` """ exception_message = 'Failed to find the requested number of elements.' if ap_query_timeout <= 0: instances = self._select_many(type_name, **kwargs) if len(instances) < ap_result_count: raise ValueError(exception_message) return sort_by_keys(instances, ap_result_sort_keys) for i in range(ap_query_timeout): instances = self._select_many(type_name, **kwargs) if len(instances) >= ap_result_count: return sort_by_keys(instances, ap_result_sort_keys) sleep(1) raise ValueError(exception_message) def refresh_state(self): """Refreshes the object's state. You should probably never have to call this directly. Autopilot automatically retrieves new state every time this object's attributes are read. :raises StateNotFound: if the object in the application under test has been destroyed. """ _, new_state = self._get_new_state() self._set_properties(new_state) def get_all_instances(self): """Get all instances of this class that exist within the Application state tree. For example, to get all the LauncherIcon instances:: icons = LauncherIcon.get_all_instances() .. warning:: Using this method is slow - it requires a complete scan of the introspection tree. You should only use this when you're not sure where the objects you are looking for are located. Depending on the application you are testing, you may get duplicate results using this method. :return: List (possibly empty) of class instances. """ cls_name = type(self).__name__ return self._execute_query( xpathselect.Query.whole_tree_search(cls_name) ) def get_root_instance(self): """Get the object at the root of this tree. This will return an object that represents the root of the introspection tree. """ query = xpathselect.Query.pseudo_tree_root() return self._execute_query(query)[0] def __getattr__(self, name): # avoid recursion if for some reason we have no state set (should never # happen). if name == '__state': raise AttributeError() if name in self.__state: if self.__refresh_on_attribute: self.refresh_state() return self.__state[name] # attribute not found. raise AttributeError( "Class '%s' has no attribute '%s'." % (self.__class__.__name__, name)) def _get_new_state(self): """Retrieve a new state dictionary for this class instance. You should probably never need to call this directly. .. note:: The state keys in the returned dictionary are not translated. """ try: return self._backend.execute_query_get_data(self._query)[0] except IndexError: raise StateNotFoundError(self.__class__.__name__, id=self.id) def wait_until_destroyed(self, timeout=10): """Block until this object is destroyed in the application. Block until the object this instance is a proxy for has been destroyed in the applicaiton under test. This is commonly used to wait until a UI component has been destroyed. :param timeout: The number of seconds to wait for the object to be destroyed. If not specified, defaults to 10 seconds. :raises RuntimeError: if the method timed out. """ for i in range(timeout): try: self._get_new_state() sleep(1) except StateNotFoundError: return else: raise RuntimeError( "Object was not destroyed after %d seconds" % timeout ) def is_moving(self, gap_interval=0.1): """Check if the element is moving. :param gap_interval: Time in seconds to wait before re-inquiring the object co-ordinates to be able to evaluate if, the element is moving. :return: True, if the element is moving, otherwise False. """ return _MockableDbusObject(self).is_moving(gap_interval) def wait_until_not_moving( self, retry_attempts_count=20, retry_interval=0.5, ): """Block until this object is not moving. Block until both x and y of the object stop changing. This is normally useful for cases, where there is a need to ensure an object is static before interacting with it. :param retry_attempts_count: number of attempts to check if the object is moving. :param retry_interval: time in fractional seconds to be slept, between each attempt to check if the object moving. :raises RuntimeError: if DBus node is still moving after number of retries specified in *retry_attempts_count*. """ # In case *retry_attempts_count* is something smaller than # 1, sanitize it. if retry_attempts_count < 1: retry_attempts_count = 1 for i in range(retry_attempts_count): if not self.is_moving(retry_interval): return raise RuntimeError( 'Object was still moving after {} second(s)'.format( retry_attempts_count * retry_interval ) ) def print_tree(self, output=None, maxdepth=None, _curdepth=0): """Print properties of the object and its children to a stream. When writing new tests, this can be called when it is too difficult to find the widget or property that you are interested in in "vis". .. warning:: Do not use this in production tests, this is expensive and not at all appropriate for actual testing. Only call this temporarily and replace with proper select_single/select_many calls. :param output: A file object or path name where the output will be written to. If not given, write to stdout. :param maxdepth: If given, limit the maximum recursion level to that number, i. e. only print children which have at most maxdepth-1 intermediate parents. """ if maxdepth is not None and _curdepth > maxdepth: return indent = " " * _curdepth if output is None: output = sys.stdout elif isinstance(output, str): output = open(output, 'w') # print path if _curdepth > 0: output.write("\n") output.write("%s== %s ==\n" % (indent, self._path.decode('utf-8'))) # Thomi 2014-03-20: For all levels other than the top level, we can # avoid an entire dbus round trip if we grab the underlying property # dictionary directly. We can do this since the print_tree function # that called us will have retrieved us via a call to get_children(), # which gets the latest state anyway. if _curdepth > 0: properties = self.__state.copy() else: properties = self.get_properties() # print properties try: for key in sorted(properties.keys()): output.write("%s%s: %r\n" % (indent, key, properties[key])) # print children if maxdepth is None or _curdepth < maxdepth: for c in self.get_children(): c.print_tree(output, maxdepth, _curdepth + 1) except StateNotFoundError as error: output.write("%sError: %s\n" % (indent, error)) def get_path(self): """Return the absolute path of the dbus node""" if isinstance(self._path, str): return self._path return self._path.decode('utf-8') @contextmanager def no_automatic_refreshing(self): """Context manager function to disable automatic DBus refreshing when retrieving attributes. Example usage: with instance.no_automatic_refreshing(): # access lots of attributes. This can be useful if you need to check lots of attributes in a tight loop, or if you want to atomicaly check several attributes at once. """ try: self.__refresh_on_attribute = False yield finally: self.__refresh_on_attribute = True @classmethod def validate_dbus_object(cls, path, _state): """Return whether this class is the appropriate proxy object class for a given dbus path and state. The default version matches the name of the dbus object and the class. Subclasses of CustomProxyObject can override it to define a different validation method. :param path: The dbus path of the object to check :param state: The dbus state dict of the object to check (ignored in default implementation) :returns: Whether this class is appropriate for the dbus object """ state_name = xpathselect.get_classname_from_path(path) if isinstance(state_name, str): state_name = state_name.encode('utf-8') class_name = cls.__name__.encode('utf-8') return state_name == class_name @classmethod def get_type_query_name(cls): """Return the Type node name to use within the search query. This allows for a Custom Proxy Object to be named differently to the underlying node type name. For instance if you have a QML type defined in the file RedRect.qml:: import QtQuick 2.0 Rectangle { color: red; } You can then define a Custom Proxy Object for this type like so:: class RedRect(DBusIntrospectionObject): @classmethod def get_type_query_name(cls): return 'QQuickRectangle' This is due to the qml engine storing 'RedRect' as a QQuickRectangle in the UI tree and the xpathquery query needs a node type to query for. By default the query will use the class name (in this case RedRect) but this will not match any node type in the tree. """ return cls.__name__ # TODO - can we add a deprecation warning around this somehow? CustomEmulatorBase = DBusIntrospectionObject def get_type_name(maybe_string_or_class): """Get a type name from something that might be a class or a string. This is a temporary funtion that will be removed once custom proxy classes can specify the query to be used to select themselves. """ if not isinstance(maybe_string_or_class, str): return _get_class_type_name(maybe_string_or_class) return maybe_string_or_class def _get_class_type_name(maybe_cpo_class): if hasattr(maybe_cpo_class, 'get_type_query_name'): return maybe_cpo_class.get_type_query_name() else: return maybe_cpo_class.__name__ def _validate_object_properties(item, **kwargs): """Returns bool representing if the properties specified in *kwargs* match the provided object *item*.""" props = item.get_properties() for key in kwargs.keys(): if key not in props or props[key] != kwargs[key]: return False return True def raises(exception_class, func, *args, **kwargs): """Evaluate if the callable *func* raises the expected exception. :param exception_class: Expected exception to be raised. :param func: The callable that is to be evaluated. :param args: Optional *args* to call the *func* with. :param kwargs: Optional *kwargs* to call the *func* with. :returns: bool, if the exception was raised. """ try: func(*args, **kwargs) except exception_class: return True else: return False def is_element(ap_query_func, *args, **kwargs): """Call the *ap_query_func* with the args and indicate if it raises StateNotFoundError. :param: ap_query_func: The dbus query call to be evaluated. :param: *args: The *ap_query_func* positional parameters. :param: **kwargs: The *ap_query_func* optional parameters. :returns: False if the *ap_query_func* raises StateNotFoundError, True otherwise. """ return not raises(StateNotFoundError, ap_query_func, *args, **kwargs) class _MockableDbusObject: """Mockable DBus object.""" def __init__(self, dbus_object): self._dbus_object = dbus_object self._mocked = False self._dbus_object_secondary = None sleep.disable_mock() @contextmanager def mocked(self, dbus_object_secondary): try: self.enable_mock(dbus_object_secondary) yield self finally: self.disable_mock() def enable_mock(self, dbus_object_secondary): self._dbus_object_secondary = dbus_object_secondary sleep.enable_mock() self._mocked = True def disable_mock(self): self._dbus_object_secondary = None sleep.disable_mock() self._mocked = False def _get_default_dbus_object(self): return self._dbus_object def _get_secondary_dbus_object(self): if not self._mocked: return self._get_default_dbus_object() else: return self._dbus_object_secondary def is_moving(self, gap_interval=0.1): """Check if the element is moving. :param gap_interval: Time in seconds to wait before re-inquiring the object co-ordinates to be able to evaluate if, the element has moved. :return: True, if the element is moving, otherwise False. """ x1, y1, h1, w1 = self._get_default_dbus_object().globalRect sleep(gap_interval) x2, y2, h2, w2 = self._get_secondary_dbus_object().globalRect return x1 != x2 or y1 != y2 ./autopilot/introspection/_xpathselect.py0000644000004100000410000003432314002063564021225 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Classes and functions that encode knowledge of the xpathselect query language. This module is internal, and should not be used directly. The main class is 'Query', which represents an xpathselect query. Query is a read-only object - once it has been constructed, it cannot be changed. This is partially to ease testing, but also to provide guarantees in the proxy object classes. To create a query, you must either have a reference to an existing query, or you must know the name of the root note. To create a query from an existing query:: >>> new_query = existing_query.select_child("NodeName") To create a query given the root node name:: >>> new_root_query = Query.root("AppName") Since the XPathSelect language is not perfect, and since we'd like to support a rich set of selection criteria, not all queries can be executed totally on the server side. Query instnaces are intelligent enough to know when they must invoke some client-side processing - in this case the 'needs_client_side_filtering' method will return True. Queries are executed in the autopilot.introspection.backends module. """ from pathlib import Path import re from autopilot.utilities import compatible_repr from autopilot.exceptions import InvalidXPathQuery class Query(object): """Encapsulate an XPathSelect query.""" class Operation(object): ROOT = b'/' CHILD = b'/' DESCENDANT = b'//' ALL = (ROOT, CHILD, DESCENDANT) PARENT = b'..' WILDCARD = b'*' def __init__(self, parent, operation, query, filters={}): """Create a new query object. You shouldn't need to call this directly. :param parent: The parent query object. Pass in None to make the root query object. :param operation: The operation object to perform on the result from the parent node. :param query: The query expression for this node. :param filters: A dictionary of filters to apply. :raises TypeError: If the 'query' parameter is not 'bytes'. :raises TypeError: If the operation parameter is not 'bytes'. :raises InvalidXPathQuery: If parent is specified, and the parent query needs client side filter processing. Only the last query in the query chain can have filters that need to be executed on the client-side. :raises InvalidXPathQuery: If operation is not one of the members of the Query.Operation class. :raises InvalidXPathQuery: If the query is set to Query.WILDCARD and 'filters' does not contain any server-side filters, and operation is set to Query.Operation.DESCENDANT. :raises InvalidXPathQuery: When 'filters' are specified while trying to select a parent node in the introspection tree. """ if not isinstance(query, bytes): raise TypeError( "'query' parameter must be bytes, not %s" % type(query).__name__ ) if not isinstance(operation, bytes): raise TypeError( "'operation' parameter must be bytes, not '%s'" % type(operation).__name__) if ( parent and parent.needs_client_side_filtering() ): raise InvalidXPathQuery( "Cannot create a new query from a parent that requires " "client-side filter processing." ) if query == Query.PARENT and filters: raise InvalidXPathQuery( "Cannot specify filters while selecting a parent" ) if query == Query.PARENT and operation != Query.Operation.CHILD: raise InvalidXPathQuery( "Operation must be CHILD while selecting a parent" ) if operation not in Query.Operation.ALL: raise InvalidXPathQuery( "Invalid operation '%s'." % operation.decode() ) if parent and parent.server_query_bytes() == b'/': raise InvalidXPathQuery( "Cannot select children from a pseudo-tree-root query." ) self._parent = parent self._operation = operation self._query = query self._server_filters = { k: v for k, v in filters.items() if _is_valid_server_side_filter_param(k, v) } self._client_filters = { k: v for k, v in filters.items() if k not in self._server_filters } if ( operation == Query.Operation.DESCENDANT and query == Query.WILDCARD and not self._server_filters ): raise InvalidXPathQuery( "Must provide at least one server-side filter when searching " "for descendants and using a wildcard node." ) @staticmethod def root(app_name): """Create a root query object. :param app_name: The name of the root node in the introspection tree. This is also typically the application name. :returns: A new Query instance, representing the root of the tree. """ app_name = _try_encode_type_name(app_name) return Query( None, Query.Operation.ROOT, app_name ) @staticmethod def new_from_path_and_id(path, id): """Create a new Query object from a path and id. :param path: The full path to the node you want to construct the query for. :param id: The object id of the node you want to construct the query for. :raises TypeError: If the path attribute is not 'bytes'. :raises ValueError: If the path does not start with b'/' """ if not isinstance(path, bytes): raise TypeError( "'path' attribute must be bytes, not '%s'" % type(path).__name__ ) nodes = list(filter(None, path.split(b'/'))) if not path.startswith(b'/') or not nodes: raise InvalidXPathQuery("Invalid path '%s'." % path.decode()) query = None for i, n in enumerate(nodes): if query is None: query = Query.root(n) else: if i == len(nodes) - 1: query = query.select_child(n, dict(id=id)) else: query = query.select_child(n) return query @staticmethod def pseudo_tree_root(): """Return a Query instance that will select the root of the tree. Unlike the 'root' method, this method does not need to know the name of the tree root. However, the query returned by this method cannot be used as the parent for any other query. In other words, calling any of the 'select_child', 'select_parent', 'select_descendant' method will raise a InvalidXPathQuery error. If at all possible, it's better to use the 'root' method instead of this one. The queries returned by this method are useful for getting the root proxy object, and then discarding the query. """ return Query(None, Query.Operation.CHILD, b'') @staticmethod def whole_tree_search(child_name, filters={}): """Return a query capable of searching the entire introspection tree. .. warning:: This method returns a query that can be extremely slow on larger applications. The execution time can easily extend beyond the dbus timeout period, which can result in tests that fail on some machines but not others. Test authors are strongly encouraged to use the 'Query.root' method and absolute queries instead. """ child_name = _try_encode_type_name(child_name) return Query(None, Query.Operation.DESCENDANT, child_name, filters) def needs_client_side_filtering(self): """Return true if this query requires some filtering on the client-side """ return self._client_filters or ( self._parent.needs_client_side_filtering() if self._parent else False ) def get_client_side_filters(self): """Return a dictionary of filters that must be processed on the client rather than the server. """ return self._client_filters def server_query_bytes(self): """Get a bytestring representing the entire query. This method returns a bytestring suitable for sending to the server. """ parent_query = self._parent.server_query_bytes() \ if self._parent is not None else b'' return parent_query + \ self._operation + \ self._query + \ self._get_server_filter_bytes() def _get_server_filter_bytes(self): if self._server_filters: keys = sorted(self._server_filters.keys()) return b'[' + \ b",".join( [ _get_filter_string_for_key_value_pair( k, self._server_filters[k] ) for k in keys if _is_valid_server_side_filter_param( k, self._server_filters[k] ) ] ) + \ b']' return b'' @compatible_repr def __repr__(self): return "Query(%r)" % self.server_query_bytes() def select_child(self, child_name, filters={}): """Return a query matching an immediate child. Keyword arguments may be used to restrict which nodes to match. :param child_name: The name of the child node to match. :returns: A Query instance that will match the child. """ child_name = _try_encode_type_name(child_name) return Query( self, Query.Operation.CHILD, child_name, filters ) def select_descendant(self, ancestor_name, filters={}): """Return a query matching an ancestor of the current node. :param ancestor_name: The name of the ancestor node to match. :returns: A Query instance that will match the ancestor. """ ancestor_name = _try_encode_type_name(ancestor_name) return Query( self, Query.Operation.DESCENDANT, ancestor_name, filters ) def select_parent(self): """Return a query matching the parent node of the current node. Calling this on the root node will return a query that looks like it ought to select the parent of the root (something like: b'/root/..'). This is however perfectly safe, as the server-side will just return the root node in this case. :returns: A Query instance that will match the parent node. """ return Query(self, Query.Operation.CHILD, Query.PARENT) def _try_encode_type_name(name): if isinstance(name, str): try: name = name.encode('ascii') except UnicodeEncodeError: raise InvalidXPathQuery( "Type name '%s', must be ASCII encodable" % (name) ) return name def _is_valid_server_side_filter_param(key, value): """Return True if the key and value parameters are valid for server-side processing. """ key_is_valid = re.match( r'^[a-zA-Z0-9_\-]+( [a-zA-Z0-9_\-])*$', key ) is not None if type(value) == int: return key_is_valid and (-2**31 <= value <= 2**31 - 1) elif type(value) == bool: return key_is_valid elif type(value) == bytes: return key_is_valid elif type(value) == str: try: value.encode('ascii') return key_is_valid except UnicodeEncodeError: pass return False def _get_filter_string_for_key_value_pair(key, value): """Return bytes representing the filter query for this key/value pair. The value must be suitable for server-side filtering. Raises ValueError if this is not the case. """ if isinstance(value, str): escaped_value = value.encode("unicode_escape")\ .decode('ASCII')\ .replace("'", "\\'") return '{}="{}"'.format(key, escaped_value).encode('utf-8') elif isinstance(value, bytes): escaped_value = value.decode('utf-8')\ .encode("unicode_escape")\ .decode('ASCII')\ .replace("'", "\\'") return '{}="{}"'.format(key, escaped_value).encode('utf-8') elif isinstance(value, int) or isinstance(value, bool): return "{}={}".format(key, repr(value)).encode('utf-8') else: raise ValueError( "Unsupported value type: {}".format(type(value).__name__) ) def _get_node(object_path, index): # TODO: Find places where paths are strings, and convert them to # bytestrings. Figure out what to do with the whole string vs. bytestring # mess. try: return Path(object_path).parts[index] except TypeError: if not isinstance(object_path, bytes): raise TypeError( 'Object path needs to be a string literal or a bytes literal' ) return object_path.split(b"/")[index] def get_classname_from_path(object_path): """Given an object path, return the class name component.""" return _get_node(object_path, -1) def get_path_root(object_path): """Return the name of the root node of specified path.""" return _get_node(object_path, 1) ./autopilot/content.py0000644000004100000410000000614614002063564015316 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Content objects and helpers for autopilot tests.""" import io import logging from testtools.content import ContentType, content_from_stream from autopilot.utilities import safe_text_content _logger = logging.getLogger(__name__) def follow_file(path, test_case, content_name=None): """Start monitoring a file. Use this convenience function to attach the contents of a file to a test. :param path: The path to the file on disk you want to monitor. :param test_case: An object that supports attaching details and cleanup actions (i.e.- has the ``addDetail`` and ``addCleanup`` methods). :param content_name: A name to give this content. If not specified, the file path will be used instead. """ try: file_obj = io.open(path, mode='rb') except IOError as e: _logger.error( "Could not add content object '%s' due to IO Error: %s", content_name, str(e) ) return safe_text_content('') else: file_obj.seek(0, io.SEEK_END) return follow_stream( file_obj, test_case, content_name or file_obj.name ) def follow_stream(stream, test_case, content_name): """Start monitoring the content from a stream. This function can be used to attach a portion of a stream to a test. :param stream: an open file-like object (that supports a read method that returns bytes). :param test_case: An object that supports attaching details and cleanup actions (i.e.- has the ``addDetail`` and ``addCleanup`` methods). :param content_name: A name to give this content. If not specified, the file path will be used instead. """ def make_content(): content_obj = content_from_stream( stream, ContentType('text', 'plain', {'charset': 'iso8859-1'}), buffer_now=True ) # Work around a bug in older testtools where an empty file would result # in None being decoded and exploding. # See: https://bugs.launchpad.net/autopilot/+bug/1517289 if list(content_obj.iter_text()) == []: _logger.warning('Followed stream is empty.') content_obj = safe_text_content('Unable to read file data.') test_case.addDetail(content_name, content_obj) test_case.addCleanup(make_content) ./autopilot/_fixtures.py0000644000004100000410000000667414002063564015662 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from fixtures import Fixture import logging from gi.repository import Gio logger = logging.getLogger(__name__) class FixtureWithDirectAddDetail(Fixture): """A test fixture that has a 'caseAddDetail' method that corresponds to the addDetail method of the test case in use. You must derive from this class in order to add detail objects to tests from within cleanup actions. """ def __init__(self, caseAddDetail=None): """Create the fixture. :param caseAddDetail: A closure over the testcase's addDetail method, or a similar substitution method. This may be omitted, in which case the 'caseAddDetail' method will be set to the fixtures normal 'addDetail' method. """ super().__init__() self.caseAddDetail = caseAddDetail or self.addDetail class OSKAlwaysEnabled(Fixture): """Enable the OSK to be shown regardless of if there is a keyboard (virtual or real) plugged in. This is a workaround for bug lp:1474444 """ osk_schema = 'com.canonical.keyboard.maliit' osk_show_key = 'stay-hidden' def setUp(self): super().setUp() try: _original_value = get_bool_gsettings_value( self.osk_schema, self.osk_show_key ) set_bool_gsettings_value( self.osk_schema, self.osk_show_key, False ) self.addCleanup( set_bool_gsettings_value, self.osk_schema, self.osk_show_key, _original_value ) except ValueError as e: logger.warning('Failed to set OSK gsetting: {}'.format(e)) def get_bool_gsettings_value(schema, key): """Return the boolean value for schema/key combo. :raises ValueError: If either ``schema`` or ``key`` are not valid. """ setting = _gsetting_get_setting(schema, key) return setting.get_boolean(key) def set_bool_gsettings_value(schema, key, value): """Set the boolean value ``value`` for schema/key combo. :raises ValueError: If either ``schema`` or ``key`` are not valid. """ setting = _gsetting_get_setting(schema, key) setting.set_boolean(key, value) def _gsetting_get_setting(schema, key): if schema not in Gio.Settings.list_schemas(): raise ValueError('schema {} is not installed.'.format(schema)) setting = Gio.Settings.new(schema) if key not in setting.keys(): raise ValueError( 'key \'{key}\' is not available for schema \'{schema}\''.format( key=key, schema=schema ) ) return setting ./autopilot/input/0000755000004100000410000000000014002063564014422 5ustar www-datawww-data./autopilot/input/_common.py0000644000004100000410000000526314002063564016431 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """Common, private utility code for input emulators.""" import logging _logger = logging.getLogger(__name__) def get_center_point(object_proxy): """Get the center point of an object. It searches for several different ways of determining exactly where the center is. The attributes used are (in order): * globalRect (x,y,w,h) * center_x, center_y * x, y, w, h :raises ValueError: if `object_proxy` has the globalRect attribute but it is not of the correct type. :raises ValueError: if `object_proxy` doesn't have the globalRect attribute, it has the x and y attributes instead, but they are not of the correct type. :raises ValueError: if `object_proxy` doesn't have any recognised position attributes. """ try: x, y, w, h = object_proxy.globalRect _logger.debug("Moving to object's globalRect coordinates.") return x+w/2, y+h/2 except AttributeError: pass except (TypeError, ValueError): raise ValueError( "Object '%r' has globalRect attribute, but it is not of the " "correct type" % object_proxy) try: x, y = object_proxy.center_x, object_proxy.center_y _logger.debug("Moving to object's center_x, center_y coordinates.") return x, y except AttributeError: pass try: x, y, w, h = ( object_proxy.x, object_proxy.y, object_proxy.w, object_proxy.h) _logger.debug( "Moving to object's center point calculated from x,y,w,h " "attributes.") return x+w/2, y+h/2 except AttributeError: raise ValueError( "Object '%r' does not have any recognised position attributes" % object_proxy) except (TypeError, ValueError): raise ValueError( "Object '%r' has x,y attribute, but they are not of the correct " "type" % object_proxy) ./autopilot/input/_osk.py0000644000004100000410000000726114002063564015735 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import logging from contextlib import contextmanager from ubuntu_keyboard.emulators.keyboard import Keyboard as KeyboardDriver from autopilot.input import Keyboard as KeyboardBase from autopilot.utilities import sleep _logger = logging.getLogger(__name__) class Keyboard(KeyboardBase): _keyboard = KeyboardDriver() @contextmanager def focused_type(self, input_target, pointer=None): """Ensures that the keyboard is up and ready for input as well as dismisses the keyboard afterward. """ with super(Keyboard, self).focused_type(input_target, pointer): try: self._keyboard.wait_for_keyboard_ready() yield self finally: self._keyboard.dismiss() def press(self, keys, delay=0.2): raise NotImplementedError( "OSK Backend does not support the press method" ) def release(self, keys, delay=0.2): raise NotImplementedError( "OSK Backend does not support the release method" ) def press_and_release(self, key, delay=0.2): """Press and release the key *key*. The 'key' argument must be a string of the single key you want pressed and released. For example:: press_and_release('A') presses then releases the 'A' key. :raises: *ValueError* if the provided key is not supported by the OSK Backend (or the current OSK langauge layout). :raises: *ValueError* if there is more than a single key supplied in the *key* argument. """ if len(self._sanitise_keys(key)) != 1: raise ValueError("Only a single key can be passed in.") try: self._keyboard.press_key(key) sleep(delay) except ValueError as e: e.args += ("OSK Backend is unable to type the key '%s" % key,) raise def type(self, string, delay=0.1): """Simulate a user typing a string of text. Only 'normal' keys can be typed with this method. There is no such thing as Alt or Ctrl on the Onscreen Keyboard. The OSK class back end will take care of ensuring that capitalized keys are in fact capitalized. :raises: *ValueError* if there is a key within the string that is not supported by the OSK Backend (or the current OSK langauge layout.) """ if not isinstance(string, str): raise TypeError("'string' argument must be a string.") _logger.debug("Typing text: %s", string) self._keyboard.type(string, delay) @classmethod def on_test_end(cls, test_instance): """Dismiss (swipe hide) the keyboard. """ _logger.debug("Dismissing the OSK with a swipe.") cls._keyboard.dismiss() def _sanitise_keys(self, keys): if keys == '+': return [keys] else: return keys.split('+') ./autopilot/input/__init__.py0000644000004100000410000006400514002063564016540 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """ Autopilot unified input system. =============================== This package provides input methods for various platforms. Autopilot aims to provide an appropriate implementation for the currently running system. For example, not all systems have an X11 stack running: on those systems, autopilot will instantiate input classes class that use something other than X11 to generate events (possibly UInput). Test authors should instantiate the appropriate class using the ``create`` method on each class. Calling ``create()`` with no arguments will get an instance of the specified class that suits the current platform. In this case, autopilot will do it's best to pick a suitable backend. Calling ``create`` with a backend name will result in that specific backend type being returned, or, if it cannot be created, an exception will be raised. For more information on creating backends, see :ref:`tut-picking-backends` There are three basic input types available: * :class:`Keyboard` - traditional keyboard devices. * :class:`Mouse` - traditional mouse devices (Currently only avaialble on the desktop). * :class:`Touch` - single point-of-contact touch device. The :class:`Pointer` class is a wrapper that unifies the API of the :class:`Mouse` and :class:`Touch` classes, which can be helpful if you want to write a test that can use either a mouse of a touch device. A common pattern is to use a Touch device when running on a mobile device, and a Mouse device when running on a desktop. .. seealso:: Module :mod:`autopilot.gestures` Multitouch and gesture support for touch devices. """ from collections import OrderedDict from contextlib import contextmanager import psutil from autopilot.input._common import get_center_point from autopilot.utilities import _pick_backend, CleanupRegistered import logging _logger = logging.getLogger(__name__) __all__ = ['get_center_point'] class Keyboard(CleanupRegistered): """A simple keyboard device class. The keyboard class is used to generate key events while in an autopilot test. This class should not be instantiated directly. To get an instance of the keyboard class, call :py:meth:`create` instead. """ @staticmethod def create(preferred_backend=''): """Get an instance of the :py:class:`Keyboard` class. For more infomration on picking specific backends, see :ref:`tut-picking-backends` For details regarding backend limitations please see: :ref:`Keyboard backend limitations` .. warning:: The **OSK** (On Screen Keyboard) backend option does not implement either :py:meth:`press` or :py:meth:`release` methods due to technical implementation details and will raise a NotImplementedError exception if used. :param preferred_backend: A string containing a hint as to which backend you would like. Possible backends are: * ``X11`` - Generate keyboard events using the X11 client libraries. * ``UInput`` - Use UInput kernel-level device driver. * ``OSK`` - Use the graphical On Screen Keyboard as a backend. :raises: RuntimeError if autopilot cannot instantate any of the possible backends. :raises: RuntimeError if the preferred_backend is specified and is not one of the possible backends for this device class. :raises: :class:`~autopilot.BackendException` if the preferred_backend is set, but that backend could not be instantiated. """ def get_x11_kb(): from autopilot.input._X11 import Keyboard return Keyboard() def get_uinput_kb(): from autopilot.input._uinput import Keyboard return Keyboard() def get_osk_kb(): try: maliit = [p for p in psutil.process_iter() if p.name() == 'maliit-server'] if maliit: from autopilot.input._osk import Keyboard return Keyboard() else: raise RuntimeError('maliit-server is not running') except ImportError as e: e.args += ("Unable to import the OSK backend",) raise backends = OrderedDict() backends['X11'] = get_x11_kb backends['OSK'] = get_osk_kb backends['UInput'] = get_uinput_kb return _pick_backend(backends, preferred_backend) @contextmanager def focused_type(self, input_target, pointer=None): """Type into an input widget. This context manager takes care of making sure a particular *input_target* UI control is selected before any text is entered. Some backends extend this method to perform cleanup actions at the end of the context manager block. For example, the OSK backend dismisses the keyboard. If the *pointer* argument is None (default) then either a Mouse or Touch pointer will be created based on the current platform. An example of using the context manager (with an OSK backend):: from autopilot.input import Keyboard text_area = self._launch_test_input_area() keyboard = Keyboard.create('OSK') with keyboard.focused_type(text_area) as kb: kb.type("Hello World.") self.assertThat(text_area.text, Equals("Hello World")) # Upon leaving the context managers scope the keyboard is dismissed # with a swipe """ if pointer is None: from autopilot.platform import model if model() == 'Desktop': pointer = Pointer(Mouse.create()) else: pointer = Pointer(Touch.create()) pointer.click_object(input_target) yield self def press(self, keys, delay=0.2): """Send key press events only. :param keys: Keys you want pressed. :param delay: The delay (in Seconds) after pressing the keys before returning control to the caller. :raises: NotImplementedError If called when using the OSK Backend. .. warning:: The **OSK** backend does not implement the press method and will raise a NotImplementedError if called. Example:: press('Alt+F2') presses the 'Alt' and 'F2' keys, but does not release them. """ raise NotImplementedError("You cannot use this class directly.") def release(self, keys, delay=0.2): """Send key release events only. :param keys: Keys you want released. :param delay: The delay (in Seconds) after releasing the keys before returning control to the caller. :raises: NotImplementedError If called when using the OSK Backend. .. warning:: The **OSK** backend does not implement the press method and will raise a NotImplementedError if called. Example:: release('Alt+F2') releases the 'Alt' and 'F2' keys. """ raise NotImplementedError("You cannot use this class directly.") def press_and_release(self, keys, delay=0.2): """Press and release all items in 'keys'. This is the same as calling 'press(keys);release(keys)'. :param keys: Keys you want pressed and released. :param delay: The delay (in Seconds) after pressing and releasing each key. Example:: press_and_release('Alt+F2') presses both the 'Alt' and 'F2' keys, and then releases both keys. """ raise NotImplementedError("You cannot use this class directly.") def type(self, string, delay=0.1): """Simulate a user typing a string of text. :param string: The string to text to type. :param delay: The delay (in Seconds) after pressing and releasing each key. Note that the default value here is shorter than for the press, release and press_and_release methods. .. note:: Only 'normal' keys can be typed with this method. Control characters (such as 'Alt' will be interpreted as an 'A', and 'l', and a 't'). """ raise NotImplementedError("You cannot use this class directly.") class Mouse(CleanupRegistered): """A simple mouse device class. The mouse class is used to generate mouse events while in an autopilot test. This class should not be instantiated directly however. To get an instance of the mouse class, call :py:meth:`create` instead. For example, to create a mouse object and click at (100,50):: mouse = Mouse.create() mouse.move(100, 50) mouse.click() """ @staticmethod def create(preferred_backend=''): """Get an instance of the :py:class:`Mouse` class. For more infomration on picking specific backends, see :ref:`tut-picking-backends` :param preferred_backend: A string containing a hint as to which backend you would like. Possible backends are: * ``X11`` - Generate mouse events using the X11 client libraries. :raises: RuntimeError if autopilot cannot instantate any of the possible backends. :raises: RuntimeError if the preferred_backend is specified and is not one of the possible backends for this device class. :raises: :class:`~autopilot.BackendException` if the preferred_backend is set, but that backend could not be instantiated. """ def get_x11_mouse(): from autopilot.input._X11 import Mouse return Mouse() def get_uinput_mouse(): # Return the Touch device for now as Mouse under a Mir desktop # is a challenge for now. from autopilot.input._uinput import Touch return Touch() backends = OrderedDict() backends['X11'] = get_x11_mouse backends['UInput'] = get_uinput_mouse return _pick_backend(backends, preferred_backend) @property def x(self): """Mouse position X coordinate.""" raise NotImplementedError("You cannot use this class directly.") @property def y(self): """Mouse position Y coordinate.""" raise NotImplementedError("You cannot use this class directly.") def press(self, button=1): """Press mouse button at current mouse location.""" raise NotImplementedError("You cannot use this class directly.") def release(self, button=1): """Releases mouse button at current mouse location.""" raise NotImplementedError("You cannot use this class directly.") def click(self, button=1, press_duration=0.10, time_between_events=0.1): """Click mouse at current location. :param time_between_events: takes floating point to represent the delay time between subsequent clicks. Default value 0.1 represents tenth of a second. """ raise NotImplementedError("You cannot use this class directly.") def click_object( self, object_proxy, button=1, press_duration=0.10, time_between_events=0.1): """Click the center point of a given object. It does this by looking for several attributes, in order. The first attribute found will be used. The attributes used are (in order): * globalRect (x,y,w,h) * center_x, center_y * x, y, w, h :param time_between_events: takes floating point to represent the delay time between subsequent clicks. Default value 0.1 represents tenth of a second. :raises: **ValueError** if none of these attributes are found, or if an attribute is of an incorrect type. """ self.move_to_object(object_proxy) self.click(button, press_duration, time_between_events) def move(self, x, y, animate=True, rate=10, time_between_events=0.01): """Moves mouse to location (x,y). Callers should avoid specifying the *rate* or *time_between_events* parameters unless they need a specific rate of movement. """ raise NotImplementedError("You cannot use this class directly.") def move_to_object(self, object_proxy): """Attempts to move the mouse to 'object_proxy's centre point. It does this by looking for several attributes, in order. The first attribute found will be used. The attributes used are (in order): * globalRect (x,y,w,h) * center_x, center_y * x, y, w, h :raises: **ValueError** if none of these attributes are found, or if an attribute is of an incorrect type. """ raise NotImplementedError("You cannot use this class directly.") def position(self): """ Returns the current position of the mouse pointer. :return: (x,y) tuple """ raise NotImplementedError("You cannot use this class directly.") def drag(self, x1, y1, x2, y2, rate=10, time_between_events=0.01): """Perform a press, move and release. This is to keep a common API between Mouse and Finger as long as possible. The pointer will be dragged from the starting point to the ending point with multiple moves. The number of moves, and thus the time that it will take to complete the drag can be altered with the `rate` parameter. :param x1: The point on the x axis where the drag will start from. :param y1: The point on the y axis where the drag will starts from. :param x2: The point on the x axis where the drag will end at. :param y2: The point on the y axis where the drag will end at. :param rate: The number of pixels the mouse will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. """ raise NotImplementedError("You cannot use this class directly.") class Touch(object): """A simple touch driver class. This class can be used for any touch events that require a single active touch at once. If you want to do complex gestures (including multi-touch gestures), look at the :py:mod:`autopilot.gestures` module. """ @staticmethod def create(preferred_backend=''): """Get an instance of the :py:class:`Touch` class. :param preferred_backend: A string containing a hint as to which backend you would like. If left blank, autopilot will pick a suitable backend for you. Specifying a backend will guarantee that either that backend is returned, or an exception is raised. possible backends are: * ``UInput`` - Use UInput kernel-level device driver. :raises: RuntimeError if autopilot cannot instantate any of the possible backends. :raises: RuntimeError if the preferred_backend is specified and is not one of the possible backends for this device class. :raises: :class:`~autopilot.BackendException` if the preferred_backend is set, but that backend could not be instantiated. """ def get_uinput_touch(): from autopilot.input._uinput import Touch return Touch() backends = OrderedDict() backends['UInput'] = get_uinput_touch return _pick_backend(backends, preferred_backend) @property def pressed(self): """Return True if this touch is currently in use (i.e.- pressed on the 'screen'). """ raise NotImplementedError("You cannot use this class directly.") def tap(self, x, y, press_duration=0.1, time_between_events=0.1): """Click (or 'tap') at given x,y coordinates. :param time_between_events: takes floating point to represent the delay time between subsequent taps. Default value 0.1 represents tenth of a second. """ raise NotImplementedError("You cannot use this class directly.") def tap_object(self, object, press_duration=0.1, time_between_events=0.1): """Tap the center point of a given object. It does this by looking for several attributes, in order. The first attribute found will be used. The attributes used are (in order): * globalRect (x,y,w,h) * center_x, center_y * x, y, w, h :param time_between_events: takes floating point to represent the delay time between subsequent taps. Default value 0.1 represents tenth of a second. :raises: **ValueError** if none of these attributes are found, or if an attribute is of an incorrect type. """ raise NotImplementedError("You cannot use this class directly.") def press(self, x, y): """Press and hold at the given x,y coordinates.""" raise NotImplementedError("You cannot use this class directly.") def move(self, x, y): """Move the pointer coords to (x,y). .. note:: The touch 'finger' must be pressed for a call to this method to be successful. (see :py:meth:`press` for further details on touch presses.) :raises: **RuntimeError** if called and the touch 'finger' isn't pressed. """ raise NotImplementedError("You cannot use this class directly.") def release(self): """Release a previously pressed finger""" raise NotImplementedError("You cannot use this class directly.") def drag(self, x1, y1, x2, y2, rate=10, time_between_events=0.01): """Perform a drag gesture. The finger will be dragged from the starting point to the ending point with multiple moves. The number of moves, and thus the time that it will take to complete the drag can be altered with the `rate` parameter. :param x1: The point on the x axis where the drag will start from. :param y1: The point on the y axis where the drag will starts from. :param x2: The point on the x axis where the drag will end at. :param y2: The point on the y axis where the drag will end at. :param rate: The number of pixels the finger will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more finger slots are available. """ raise NotImplementedError("You cannot use this class directly.") class Pointer(object): """A wrapper class that represents a pointing device which can either be a mouse or a touch, and provides a unified API. This class is useful if you want to run tests with either a mouse or a touch device, and want to write your tests to use a single API. Create this wrapper by passing it either a mouse or a touch device, like so:: pointer_device = Pointer(Mouse.create()) or, like so:: pointer_device = Pointer(Touch.create()) .. warning:: Some operations only make sense for certain devices. This class attempts to minimise the differences between the Mouse and Touch APIs, but there are still some operations that will cause exceptions to be raised. These are documented in the specific methods below. """ def __init__(self, device): if not isinstance(device, Mouse) and not isinstance(device, Touch): raise TypeError( "`device` must be either a Touch or a Mouse instance.") self._device = device @property def x(self): """Pointer X coordinate. If the wrapped device is a :class:`Touch` device, this will return the last known X coordinate, which may not be a sensible value. """ return self._device.x @property def y(self): """Pointer Y coordinate. If the wrapped device is a :class:`Touch` device, this will return the last known Y coordinate, which may not be a sensible value. """ return self._device.y def press(self, button=1): """Press the pointer at it's current location. If the wrapped device is a mouse, you may pass a button specification. If it is a touch device, passing anything other than 1 will raise a ValueError exception. """ if isinstance(self._device, Mouse): self._device.press(button) else: if button != 1: raise ValueError( "Touch devices do not have button %d" % (button)) self._device.press(self.x, self.y) def release(self, button=1): """Releases the pointer at it's current location. If the wrapped device is a mouse, you may pass a button specification. If it is a touch device, passing anything other than 1 will raise a ValueError exception. """ if isinstance(self._device, Mouse): self._device.release(button) else: if button != 1: raise ValueError( "Touch devices do not have button %d" % (button)) self._device.release() def click(self, button=1, press_duration=0.10, time_between_events=0.1): """Press and release at the current pointer location. If the wrapped device is a mouse, the button specification is used. If it is a touch device, passing anything other than 1 will raise a ValueError exception. :param time_between_events: takes floating point to represent the delay time between subsequent clicks/taps. Default value 0.1 represents tenth of a second. """ if isinstance(self._device, Mouse): self._device.click(button, press_duration, time_between_events) else: if button != 1: raise ValueError( "Touch devices do not have button %d" % (button)) self._device.tap( self.x, self.y, press_duration=press_duration, time_between_events=time_between_events ) def move(self, x, y): """Moves the pointer to the specified coordinates. If the wrapped device is a mouse, the mouse will animate to the specified coordinates. If the wrapped device is a touch device, this method will determine where the next :meth:`press`, :meth:`release` or :meth:`click` will occur. """ self._device.move(x, y) def click_object( self, object_proxy, button=1, press_duration=0.10, time_between_events=0.1): """ Attempts to move the pointer to 'object_proxy's centre point and click a button. See :py:meth:`~autopilot.input.get_center_point` for details on how the center point is calculated. If the wrapped device is a mouse, the button specification is used. If it is a touch device, passing anything other than 1 will raise a ValueError exception. :param time_between_events: takes floating point to represent the delay time between subsequent clicks/taps. Default value 0.1 represents tenth of a second. """ self.move_to_object(object_proxy) self.click(button, press_duration, time_between_events) def move_to_object(self, object_proxy): """Attempts to move the pointer to 'object_proxy's centre point. See :py:meth:`~autopilot.input.get_center_point` for details on how the center point is calculated. """ x, y = get_center_point(object_proxy) self.move(x, y) def position(self): """ Returns the current position of the pointer. :return: (x,y) tuple """ if isinstance(self._device, Mouse): return self._device.position() else: return (self.x, self.y) def drag(self, x1, y1, x2, y2, rate=10, time_between_events=0.01): """Perform a press, move and release. This is to keep a common API between Mouse and Finger as long as possible. The pointer will be dragged from the starting point to the ending point with multiple moves. The number of moves, and thus the time that it will take to complete the drag can be altered with the `rate` parameter. :param x1: The point on the x axis where the drag will start from. :param y1: The point on the y axis where the drag will starts from. :param x2: The point on the x axis where the drag will end at. :param y2: The point on the y axis where the drag will end at. :param rate: The number of pixels the mouse will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. """ self._device.drag( x1, y1, x2, y2, rate=rate, time_between_events=time_between_events) ./autopilot/input/_X11.py0000644000004100000410000003352614002063564015515 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """A collection of emulators for X11 - namely keyboards and mice. In the future we may also need other devices. """ import logging from autopilot.input import get_center_point from autopilot.display import is_point_on_any_screen, move_mouse_to_screen from autopilot.utilities import ( EventDelay, Silence, sleep, StagnantStateDetector, ) from autopilot.input import ( Keyboard as KeyboardBase, Mouse as MouseBase, ) from Xlib import X, XK from Xlib.display import Display from Xlib.ext.xtest import fake_input _PRESSED_KEYS = [] _PRESSED_MOUSE_BUTTONS = [] _DISPLAY = None _logger = logging.getLogger(__name__) def get_display(): """Return the Xlib display object. It is created (silently) if it doesn't exist. """ global _DISPLAY if _DISPLAY is None: with Silence(): _DISPLAY = Display() return _DISPLAY def reset_display(): global _DISPLAY _DISPLAY = None class Keyboard(KeyboardBase): """Wrapper around xlib to make faking keyboard input possible.""" _special_X_keysyms = { ' ': "space", '\t': "Tab", '\n': "Return", # for some reason this needs to be cr, not lf '\r': "Return", '\e': "Escape", '\b': "BackSpace", '!': "exclam", '#': "numbersign", '%': "percent", '$': "dollar", '&': "ampersand", '"': "quotedbl", '\'': "apostrophe", '(': "parenleft", ')': "parenright", '*': "asterisk", '=': "equal", '+': "plus", ',': "comma", '-': "minus", '.': "period", '/': "slash", ':': "colon", ';': "semicolon", '<': "less", '>': "greater", '?': "question", '@': "at", '[': "bracketleft", ']': "bracketright", '\\': "backslash", '^': "asciicircum", '_': "underscore", '`': "grave", '{': "braceleft", '|': "bar", '}': "braceright", '~': "asciitilde" } _keysym_translations = { 'Control': 'Control_L', 'Ctrl': 'Control_L', 'Alt': 'Alt_L', 'AltR': 'Alt_R', 'Super': 'Super_L', 'Shift': 'Shift_L', 'Enter': 'Return', 'Space': ' ', 'Backspace': 'BackSpace', } def __init__(self): super(Keyboard, self).__init__() self.shifted_keys = [k[1] for k in get_display()._keymap_codes if k] def press(self, keys, delay=0.2): """Send key press events only. :param string keys: Keys you want pressed. Example:: press('Alt+F2') presses the 'Alt' and 'F2' keys. """ if not isinstance(keys, str): raise TypeError("'keys' argument must be a string.") _logger.debug("Pressing keys %r with delay %f", keys, delay) for key in self.__translate_keys(keys): self.__perform_on_key(key, X.KeyPress) sleep(delay) def release(self, keys, delay=0.2): """Send key release events only. :param string keys: Keys you want released. Example:: release('Alt+F2') releases the 'Alt' and 'F2' keys. """ if not isinstance(keys, str): raise TypeError("'keys' argument must be a string.") _logger.debug("Releasing keys %r with delay %f", keys, delay) # release keys in the reverse order they were pressed in. keys = self.__translate_keys(keys) keys.reverse() for key in keys: self.__perform_on_key(key, X.KeyRelease) sleep(delay) def press_and_release(self, keys, delay=0.2): """Press and release all items in 'keys'. This is the same as calling 'press(keys);release(keys)'. :param string keys: Keys you want pressed and released. Example:: press_and_release('Alt+F2') presses both the 'Alt' and 'F2' keys, and then releases both keys. """ self.press(keys, delay) self.release(keys, delay) def type(self, string, delay=0.1): """Simulate a user typing a string of text. .. note:: Only 'normal' keys can be typed with this method. Control characters (such as 'Alt' will be interpreted as an 'A', and 'l', and a 't'). """ if not isinstance(string, str): raise TypeError("'keys' argument must be a string.") _logger.debug("Typing text %r", string) for key in string: # Don't call press or release here, as they translate keys to # keysyms. self.__perform_on_key(key, X.KeyPress) sleep(delay) self.__perform_on_key(key, X.KeyRelease) sleep(delay) @classmethod def on_test_end(cls, test_instance): """Generate KeyRelease events for any un-released keys. .. important:: Ensure you call this at the end of any test to release any keys that were pressed and not released. """ global _PRESSED_KEYS for keycode in _PRESSED_KEYS: _logger.warning( "Releasing key %r as part of cleanup call.", keycode) fake_input(get_display(), X.KeyRelease, keycode) _PRESSED_KEYS = [] def __perform_on_key(self, key, event): if not isinstance(key, str): raise TypeError("Key parameter must be a string") keycode = 0 shift_mask = 0 keycode, shift_mask = self.__char_to_keycode(key) if shift_mask != 0: fake_input(get_display(), event, 50) if event == X.KeyPress: _logger.debug("Sending press event for key: %s", key) _PRESSED_KEYS.append(keycode) elif event == X.KeyRelease: _logger.debug("Sending release event for key: %s", key) if keycode in _PRESSED_KEYS: _PRESSED_KEYS.remove(keycode) else: _logger.warning( "Generating release event for keycode %d that was not " "pressed.", keycode) fake_input(get_display(), event, keycode) get_display().sync() def __get_keysym(self, key): keysym = XK.string_to_keysym(key) if keysym == 0: # Unfortunately, although this works to get the correct keysym # i.e. keysym for '#' is returned as "numbersign" # the subsequent display.keysym_to_keycode("numbersign") is 0. keysym = XK.string_to_keysym(self._special_X_keysyms[key]) return keysym def __is_shifted(self, key): return len(key) == 1 and ord(key) in self.shifted_keys and key != '<' def __char_to_keycode(self, key): keysym = self.__get_keysym(key) keycode = get_display().keysym_to_keycode(keysym) if keycode == 0: _logger.warning("Sorry, can't map '%s'", key) if (self.__is_shifted(key)): shift_mask = X.ShiftMask else: shift_mask = 0 return keycode, shift_mask def __translate_keys(self, key_string): if len(key_string) > 1: return [self._keysym_translations.get(k, k) for k in key_string.split('+')] else: # workaround that lets us press_and_release '+' by itself. return [self._keysym_translations.get(key_string, key_string)] class Mouse(MouseBase): """Wrapper around xlib to make moving the mouse easier.""" def __init__(self): super(Mouse, self).__init__() # Try to access the screen to see if X11 mouse is supported get_display() self.event_delayer = EventDelay() @property def x(self): """Mouse position X coordinate.""" return self.position()[0] @property def y(self): """Mouse position Y coordinate.""" return self.position()[1] def press(self, button=1): """Press mouse button at current mouse location.""" _logger.debug("Pressing mouse button %d", button) _PRESSED_MOUSE_BUTTONS.append(button) fake_input(get_display(), X.ButtonPress, button) get_display().sync() def release(self, button=1): """Releases mouse button at current mouse location.""" _logger.debug("Releasing mouse button %d", button) if button in _PRESSED_MOUSE_BUTTONS: _PRESSED_MOUSE_BUTTONS.remove(button) else: _logger.warning( "Generating button release event or button %d that was not " "pressed.", button) fake_input(get_display(), X.ButtonRelease, button) get_display().sync() def click(self, button=1, press_duration=0.10, time_between_events=0.1): """Click mouse at current location.""" self.event_delayer.delay(time_between_events) self.press(button) sleep(press_duration) self.release(button) def move(self, x, y, animate=True, rate=10, time_between_events=0.01): """Moves mouse to location (x, y). Callers should avoid specifying the *rate* or *time_between_events* parameters unless they need a specific rate of movement. """ def perform_move(x, y, sync): fake_input( get_display(), X.MotionNotify, sync, X.CurrentTime, X.NONE, x=int(x), y=int(y)) get_display().sync() sleep(time_between_events) dest_x, dest_y = int(x), int(y) _logger.debug( "Moving mouse to position %d,%d %s animation.", dest_x, dest_y, "with" if animate else "without") if not animate: perform_move(dest_x, dest_y, False) return coordinate_valid = is_point_on_any_screen((dest_x, dest_y)) if x < -1000 or y < -1000: raise ValueError( "Invalid mouse coordinates: %d, %d" % (dest_x, dest_y)) loop_detector = StagnantStateDetector(threshold=1000) curr_x, curr_y = self.position() while curr_x != dest_x or curr_y != dest_y: dx = abs(dest_x - curr_x) dy = abs(dest_y - curr_y) intx = float(dx) / max(dx, dy) inty = float(dy) / max(dx, dy) step_x = min(rate * intx, dx) step_y = min(rate * inty, dy) if dest_x < curr_x: step_x *= -1 if dest_y < curr_y: step_y *= -1 perform_move(step_x, step_y, True) if coordinate_valid: curr_x, curr_y = self.position() else: curr_x += step_x curr_y += step_y try: loop_detector.check_state(curr_x, curr_y) except StagnantStateDetector.StagnantState as e: e.args = ("Mouse cursor is stuck.", ) raise x, y = self.position() _logger.debug('The mouse is now at position %d,%d.', x, y) def move_to_object(self, object_proxy): """Attempts to move the mouse to 'object_proxy's centre point. See :py:meth:`~autopilot.input.get_center_point` for details on how the center point is calculated. """ x, y = get_center_point(object_proxy) self.move(x, y) def position(self): """ Returns the current position of the mouse pointer. :return: (x,y) tuple """ coord = get_display().screen().root.query_pointer()._data x, y = coord["root_x"], coord["root_y"] return x, y def drag(self, x1, y1, x2, y2, rate=10, time_between_events=0.01): """Perform a press, move and release. This is to keep a common API between Mouse and Finger as long as possible. The pointer will be dragged from the starting point to the ending point with multiple moves. The number of moves, and thus the time that it will take to complete the drag can be altered with the `rate` parameter. :param x1: The point on the x axis where the drag will start from. :param y1: The point on the y axis where the drag will starts from. :param x2: The point on the x axis where the drag will end at. :param y2: The point on the y axis where the drag will end at. :param rate: The number of pixels the mouse will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. """ self.move(x1, y1) self.press() self.move(x2, y2, rate=rate, time_between_events=time_between_events) self.release() @classmethod def on_test_end(cls, test_instance): """Put mouse in a known safe state.""" global _PRESSED_MOUSE_BUTTONS for btn in _PRESSED_MOUSE_BUTTONS: _logger.debug("Releasing mouse button %d as part of cleanup", btn) fake_input(get_display(), X.ButtonRelease, btn) _PRESSED_MOUSE_BUTTONS = [] move_mouse_to_screen(0) ./autopilot/input/_uinput.py0000644000004100000410000005552514002063564016473 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012, 2013, 2014, 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """UInput device drivers.""" import logging from evdev import UInput, ecodes as e from autopilot.input import Keyboard as KeyboardBase from autopilot.input import Touch as TouchBase from autopilot.input import get_center_point from autopilot.platform import model from autopilot.utilities import deprecated, EventDelay, sleep _logger = logging.getLogger(__name__) def _get_devnode_path(): """Return the uinput device node""" return '/dev/uinput' class _UInputKeyboardDevice(object): """Wrapper for the UInput Keyboard to execute its primitives.""" def __init__(self, device_class=UInput): super(_UInputKeyboardDevice, self).__init__() self._device = device_class(devnode=_get_devnode_path()) self._pressed_keys_ecodes = [] def press(self, key): """Press one key button. It ignores case, so, for example, 'a' and 'A' are mapped to the same key. """ ecode = self._get_ecode_for_key(key) _logger.debug('Pressing %s (%r).', key, ecode) self._emit_press_event(ecode) self._pressed_keys_ecodes.append(ecode) def _get_ecode_for_key(self, key): key_name = key if key.startswith('KEY_') else 'KEY_' + key key_name = key_name.upper() ecode = e.ecodes.get(key_name, None) if ecode is None: raise ValueError('Unknown key name: %s.' % key) return ecode def _emit_press_event(self, ecode): press_value = 1 self._emit(ecode, press_value) def _emit(self, ecode, value): self._device.write(e.EV_KEY, ecode, value) self._device.syn() def release(self, key): """Release one key button. It ignores case, so, for example, 'a' and 'A' are mapped to the same key. :raises ValueError: if ``key`` is not pressed. """ ecode = self._get_ecode_for_key(key) if ecode in self._pressed_keys_ecodes: _logger.debug('Releasing %s (%r).', key, ecode) self._emit_release_event(ecode) self._pressed_keys_ecodes.remove(ecode) else: raise ValueError('Key %r not pressed.' % key) def _emit_release_event(self, ecode): release_value = 0 self._emit(ecode, release_value) def release_pressed_keys(self): """Release all the keys that are currently pressed.""" for ecode in self._pressed_keys_ecodes: self._emit_release_event(ecode) self._pressed_keys_ecodes = [] class Keyboard(KeyboardBase): _device = None def __init__(self, device_class=_UInputKeyboardDevice): super(Keyboard, self).__init__() if Keyboard._device is None: Keyboard._device = device_class() def _sanitise_keys(self, keys): if keys == '+': return [keys] else: return keys.split('+') def press(self, keys, delay=0.1): """Send key press events only. The 'keys' argument must be a string of keys you want pressed. For example: press('Alt+F2') presses the 'Alt' and 'F2' keys. :raises TypeError: if ``keys`` is not a string. """ if not isinstance(keys, str): raise TypeError("'keys' argument must be a string.") for key in self._sanitise_keys(keys): for key_button in self._get_key_buttons(key): self._device.press(key_button) sleep(delay) def release(self, keys, delay=0.1): """Send key release events only. The 'keys' argument must be a string of keys you want released. For example: release('Alt+F2') releases the 'Alt' and 'F2' keys. Keys are released in the reverse order in which they are specified. :raises TypeError: if ``keys`` is not a string. :raises ValueError: if one of the keys to be released is not pressed. """ if not isinstance(keys, str): raise TypeError("'keys' argument must be a string.") for key in reversed(self._sanitise_keys(keys)): for key_button in reversed(self._get_key_buttons(key)): self._device.release(key_button) sleep(delay) def press_and_release(self, keys, delay=0.1): """Press and release all items in 'keys'. This is the same as calling 'press(keys);release(keys)'. The 'keys' argument must be a string of keys you want pressed and released.. For example: press_and_release('Alt+F2') presses both the 'Alt' and 'F2' keys, and then releases both keys. :raises TypeError: if ``keys`` is not a string. """ _logger.debug("Pressing and Releasing: %s", keys) self.press(keys, delay) self.release(keys, delay) def type(self, string, delay=0.1): """Simulate a user typing a string of text. Only 'normal' keys can be typed with this method. Control characters (such as 'Alt' will be interpreted as an 'A', and 'l', and a 't'). :raises TypeError: if ``keys`` is not a string. """ if not isinstance(string, str): raise TypeError("'keys' argument must be a string.") _logger.debug("Typing text %r", string) for key in string: self.press(key, delay) self.release(key, delay) @classmethod def on_test_end(cls, test_instance): """Generate KeyRelease events for any un-released keys. Make sure you call this at the end of any test to release any keys that were pressed and not released. """ if cls._device is not None: cls._device.release_pressed_keys() def _get_key_buttons(self, key): """Return a list of the key buttons required to press. Multiple buttons will be returned when the key specified requires more than one keypress to generate (for example, upper-case letters). """ key_buttons = [] if key.isupper() or key in _SHIFTED_KEYS: key_buttons.append('KEY_LEFTSHIFT') key_name = _UINPUT_CODE_TRANSLATIONS.get(key.upper(), key) key_buttons.append(key_name) return key_buttons @deprecated('the Touch class to instantiate a device object') def create_touch_device(res_x=None, res_y=None): """Create and return a UInput touch device. If res_x and res_y are not specified, they will be queried from the system. """ return UInput(events=_get_touch_events(res_x, res_y), name='autopilot-finger', version=0x2, devnode=_get_devnode_path()) # Multiouch notes: # ---------------- # We're simulating a class of device that can track multiple touches, and keep # them separate. This is how most modern track devices work anyway. The device # is created with a capability to track a certain number of distinct touches at # once. This is the ABS_MT_SLOT capability. Since our target device can track 9 # separate touches, we'll do the same. # Each finger contact starts by registering a slot number (0-8) with a tracking # Id. The Id should be unique for this touch - this can be an # auto-inctrementing integer. The very first packets to tell the kernel that # we have a touch happening should look like this: # ABS_MT_SLOT 0 # ABS_MT_TRACKING_ID 45 # ABS_MT_POSITION_X x[0] # ABS_MT_POSITION_Y y[0] # This associates Tracking id 45 (could be any number) with slot 0. Slot 0 can # now not be use by any other touch until it is released. # If we want to move this contact's coordinates, we do this: # ABS_MT_SLOT 0 # ABS_MT_POSITION_X 123 # ABS_MT_POSITION_Y 234 # Technically, the 'SLOT 0' part isn't needed, since we're already in slot 0, # but it doesn't hurt to have it there. # To lift the contact, we simply specify a tracking Id of -1: # ABS_MT_SLOT 0 # ABS_MT_TRACKING_ID -1 # The initial association between slot and tracking Id is made when the # 'finger' first makes contact with the device (well, not technically true, # but close enough). Multiple touches can be active simultaniously, as long # as they all have unique slots, and tracking Ids. The simplest way to think # about this is that the SLOT refers to a finger number, and the TRACKING_ID # identifies a unique touch for the duration of it's existance. def _get_touch_events(res_x=None, res_y=None): if res_x is None or res_y is None: res_x, res_y = _get_system_resolution() touch_tool = _get_touch_tool() events = { e.EV_ABS: [ (e.ABS_X, (0, res_x, 0, 0)), (e.ABS_Y, (0, res_y, 0, 0)), (e.ABS_PRESSURE, (0, 65535, 0, 0)), (e.ABS_MT_POSITION_X, (0, res_x, 0, 0)), (e.ABS_MT_POSITION_Y, (0, res_y, 0, 0)), (e.ABS_MT_TOUCH_MAJOR, (0, 30, 0, 0)), (e.ABS_MT_TRACKING_ID, (0, 65535, 0, 0)), (e.ABS_MT_PRESSURE, (0, 255, 0, 0)), (e.ABS_MT_SLOT, (0, 9, 0, 0)), ], e.EV_KEY: [ touch_tool, ] } return events def _get_system_resolution(): from autopilot.display import Display display = Display.create() # TODO: This calculation needs to become part of the display module: l = r = t = b = 0 for screen in range(display.get_num_screens()): geometry = display.get_screen_geometry(screen) if geometry[0] < l: l = geometry[0] if geometry[1] < t: t = geometry[1] if geometry[0] + geometry[2] > r: r = geometry[0] + geometry[2] if geometry[1] + geometry[3] > b: b = geometry[1] + geometry[3] res_x = r - l res_y = b - t return res_x, res_y def _get_touch_tool(): return e.BTN_TOUCH class _UInputTouchDevice(object): """Wrapper for the UInput Touch to execute its primitives.""" _device = None _touch_fingers_in_use = [] _last_tracking_id = 0 def __init__(self, res_x=None, res_y=None, device_class=UInput): """Class constructor. If res_x and res_y are not specified, they will be queried from the system. """ super(_UInputTouchDevice, self).__init__() if _UInputTouchDevice._device is None: _UInputTouchDevice._device = device_class( events=_get_touch_events(res_x, res_y), name='autopilot-finger', version=0x2, devnode=_get_devnode_path()) self._touch_finger_slot = None @property def pressed(self): return self._touch_finger_slot is not None def finger_down(self, x, y): """Internal: moves finger "finger" down on the touchscreen. :param x: The finger will be moved to this x coordinate. :param y: The finger will be moved to this y coordinate. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more touch slots are available. """ if self.pressed: raise RuntimeError("Cannot press finger: it's already pressed.") self._touch_finger_slot = self._get_free_touch_finger_slot() self._device.write(e.EV_ABS, e.ABS_MT_SLOT, self._touch_finger_slot) self._device.write( e.EV_ABS, e.ABS_MT_TRACKING_ID, self._get_next_tracking_id()) press_value = 1 self._device.write(e.EV_KEY, _get_touch_tool(), press_value) self._device.write(e.EV_ABS, e.ABS_MT_POSITION_X, int(x)) self._device.write(e.EV_ABS, e.ABS_MT_POSITION_Y, int(y)) self._device.write(e.EV_ABS, e.ABS_MT_PRESSURE, 400) self._device.syn() def _get_free_touch_finger_slot(self): """Return the id of a free touch finger. :raises RuntimeError: if no more touch slots are available. """ max_number_of_fingers = 9 for i in range(max_number_of_fingers): if i not in _UInputTouchDevice._touch_fingers_in_use: _UInputTouchDevice._touch_fingers_in_use.append(i) return i raise RuntimeError('All available fingers have been used already.') def _get_next_tracking_id(self): _UInputTouchDevice._last_tracking_id += 1 return _UInputTouchDevice._last_tracking_id def finger_move(self, x, y): """Internal: moves finger "finger" on the touchscreen to pos (x,y) NOTE: The finger has to be down for this to have any effect. :raises RuntimeError: if the finger is not pressed. """ if not self.pressed: raise RuntimeError('Attempting to move without finger being down.') _logger.debug("Moving pointing 'finger' to position %d,%d.", x, y) self._device.write(e.EV_ABS, e.ABS_MT_SLOT, self._touch_finger_slot) self._device.write(e.EV_ABS, e.ABS_MT_POSITION_X, int(x)) self._device.write(e.EV_ABS, e.ABS_MT_POSITION_Y, int(y)) self._device.syn() _logger.debug("The pointing 'finger' is now at position %d,%d.", x, y) def finger_up(self): """Internal: moves finger "finger" up from the touchscreen :raises RuntimeError: if the finger is not pressed. """ if not self.pressed: raise RuntimeError("Cannot release finger: it's not pressed.") self._device.write(e.EV_ABS, e.ABS_MT_SLOT, self._touch_finger_slot) lift_tracking_id = -1 self._device.write(e.EV_ABS, e.ABS_MT_TRACKING_ID, lift_tracking_id) release_value = 0 self._device.write(e.EV_KEY, _get_touch_tool(), release_value) self._device.syn() self._release_touch_finger() def _release_touch_finger(self): """Release the touch finger. :raises RuntimeError: if the finger was not claimed before or was already released. """ if (self._touch_finger_slot not in _UInputTouchDevice._touch_fingers_in_use): raise RuntimeError( "Finger %d was never claimed, or has already been released." % self._touch_finger_slot) _UInputTouchDevice._touch_fingers_in_use.remove( self._touch_finger_slot) self._touch_finger_slot = None class Touch(TouchBase): """Low level interface to generate single finger touch events.""" def __init__(self, device_class=_UInputTouchDevice): super(Touch, self).__init__() self._device = device_class() self.event_delayer = EventDelay() self._x = 0 self._y = 0 @property def x(self): """Finger position X coordinate.""" return self._x @property def y(self): """Finger position Y coordinate.""" return self._y @property def pressed(self): return self._device.pressed def tap(self, x, y, press_duration=0.1, time_between_events=0.1): """Click (or 'tap') at given x and y coordinates. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more finger slots are available. """ _logger.debug("Tapping at: %d,%d", x, y) self.event_delayer.delay(time_between_events) self._finger_down(x, y) sleep(press_duration) self._device.finger_up() def _finger_down(self, x, y): self._device.finger_down(x, y) self._x = x self._y = y def tap_object(self, object_, press_duration=0.1, time_between_events=0.1): """Click (or 'tap') a given object. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more finger slots are available. :raises ValueError: if `object_` doesn't have any recognised position attributes or if they are not of the correct type. """ _logger.debug("Tapping object: %r", object) x, y = get_center_point(object_) self.tap( x, y, press_duration=press_duration, time_between_events=time_between_events ) def press(self, x, y): """Press and hold a given object or at the given coordinates. Call release() when the object has been pressed long enough. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more finger slots are available. """ _logger.debug("Pressing at: %d,%d", x, y) self._finger_down(x, y) def release(self): """Release a previously pressed finger. :raises RuntimeError: if the touch is not pressed. """ _logger.debug("Releasing") self._device.finger_up() def move(self, x, y, animate=True, rate=10, time_between_events=0.01): """Moves the pointing "finger" to pos(x,y). NOTE: The finger has to be down for this to have any effect. :param x: The point on the x axis where the move will end at. :param y: The point on the y axis where the move will end at. :param animate: Indicates if the move should be immediate or it should be animated moving the finger slowly accross the screen as a real user would do. By default, when the finger is down the finger is animated. When the finger is up, the parameter is ignored and the move is always immediate. :type animate: boolean. :param rate: The number of pixels the finger will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. :raises RuntimeError: if the finger is not pressed. """ if self.pressed: if animate: self._move_with_animation(x, y, rate, time_between_events) else: self._device.finger_move(x, y) self._x = x self._y = y def _move_with_animation(self, x, y, rate, time_between_events): current_x, current_y = self.x, self.y while current_x != x or current_y != y: dx = abs(x - current_x) dy = abs(y - current_y) intx = float(dx) / max(dx, dy) inty = float(dy) / max(dx, dy) step_x = min(rate * intx, dx) step_y = min(rate * inty, dy) if x < current_x: step_x *= -1 if y < current_y: step_y *= -1 current_x += step_x current_y += step_y self._device.finger_move(current_x, current_y) sleep(time_between_events) def drag(self, x1, y1, x2, y2, rate=10, time_between_events=0.01): """Perform a drag gesture. The finger will be dragged from the starting point to the ending point with multiple moves. The number of moves, and thus the time that it will take to complete the drag can be altered with the `rate` parameter. :param x1: The point on the x axis where the drag will start from. :param y1: The point on the y axis where the drag will starts from. :param x2: The point on the x axis where the drag will end at. :param y2: The point on the y axis where the drag will end at. :param rate: The number of pixels the finger will be moved per iteration. Default is 10 pixels. A higher rate will make the drag faster, and lower rate will make it slower. :param time_between_events: The number of seconds that the drag will wait between iterations. :raises RuntimeError: if the finger is already pressed. :raises RuntimeError: if no more finger slots are available. """ _logger.debug("Dragging from %d,%d to %d,%d", x1, y1, x2, y2) self._finger_down(x1, y1) self.move( x2, y2, animate=True, rate=rate, time_between_events=time_between_events) self._device.finger_up() # veebers: there should be a better way to handle this. _SHIFTED_KEYS = "~!@#$%^&*()_+{}|:\"?><" # The double-ups are due to the 'shifted' keys. _UINPUT_CODE_TRANSLATIONS = { '/': 'SLASH', '?': 'SLASH', '.': 'DOT', ',': 'COMMA', '>': 'DOT', '<': 'COMMA', '\'': 'APOSTROPHE', '"': 'APOSTROPHE', ';': 'SEMICOLON', ':': 'SEMICOLON', '\\': 'BACKSLASH', '|': 'BACKSLASH', ']': 'RIGHTBRACE', '[': 'LEFTBRACE', '}': 'RIGHTBRACE', '{': 'LEFTBRACE', '=': 'EQUAL', '+': 'EQUAL', '-': 'MINUS', '_': 'MINUS', ')': '0', '(': '9', '*': '8', '&': '7', '^': '6', '%': '5', '$': '4', '#': '3', '@': '2', '!': '1', '~': 'GRAVE', '`': 'GRAVE', ' ': 'SPACE', '\t': 'TAB', '\n': 'ENTER', '\b': 'BACKSPACE', 'CTRL': 'LEFTCTRL', 'ALT': 'LEFTALT', 'SHIFT': 'LEFTSHIFT', } class UInputHardwareKeysDevice: _device = None def __init__(self, device_class=UInput): if not UInputHardwareKeysDevice._device: UInputHardwareKeysDevice._device = device_class( devnode=_get_devnode_path(), ) # This workaround is not needed on desktop. if model() != 'Desktop': self._wait_for_device_to_ready() def press_and_release_power_button(self): self._device.write(e.EV_KEY, e.KEY_POWER, 1) self._device.write(e.EV_KEY, e.KEY_POWER, 0) self._device.syn() def _wait_for_device_to_ready( self, retry_attempts_count=10, retry_interval=0.1, ): """Wait for UInput device to initialize. This is a workaround for a bug in evdev where the input device is not instantly created. :param retry_attempts_count: number of attempts to check if device is ready. :param retry_interval: time in fractional seconds to be slept, between each attempt to check if device is ready. :raises RuntimeError: if device is not initialized after number of retries specified in *retry_attempts_count*. """ for i in range(retry_attempts_count): device = self._device._find_device() if device: self._device.device = device return else: sleep(retry_interval) raise RuntimeError('Failed to find UInput device.') ./autopilot/clipboard.py0000644000004100000410000000233514002063564015577 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """A collection of functions relating to the X11clipboards.""" import autopilot._glib def get_clipboard_contents(): """Get the contents of the clipboard. This function returns the text copied to the 'CLIPBOARD' clipboard. Text can be added to this clipbaord using Ctrl+C. """ Gtk = autopilot._glib._import_gtk() Gdk = autopilot._glib._import_gdk() cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) return cb.wait_for_text() ./autopilot/emulators.py0000644000004100000410000000303114002063564015645 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # """ .. otto:: **Deprecated Namespace!** This module contains modules that were in the ``autopilot.emulators`` package in autopilot version 1.2 and earlier, but have now been moved to the ``autopilot`` package. This module exists to ease the transition to autopilot 1.3, but is not guaranteed to exist in the future. .. seealso:: Modulule :mod:`autopilot.display` Get display information. Module :mod:`autopilot.input` Create input events to interact with the application under test. """ import autopilot.display as display # flake8: noqa import autopilot.clipboard as clipboard # flake8: noqa import autopilot.dbus_handler as dbus_handler # flake8: noqa import autopilot.input as input # flake8: noqa ./bin/0000755000004100000410000000000014002063564012013 5ustar www-datawww-data./bin/autopilot3-sandbox-run0000755000004100000410000001161514002063564016306 0ustar www-datawww-data#!/bin/sh # # Runner to execute autopilot locally # # This scripts run autopilot in a "fake" X server, and optionally a # window manager with either headless with xvfb or nested with Xephyr # # Copyright (C) 2013-2015 Canonical # # Authors: Jean-Baptiste Lallement # # This program is free software; you can redistribute it and/or modify it # under # the terms of the GNU General Public License as published by the Free # Software # Foundation; version 3. # # 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 General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA set -eu # General Settings RC=0 # Main return code BINDIR=$(dirname $(readlink -f $0)) XEPHYR=0 XEPHYR_CMD="$(which Xephyr||true)" XEPHYR_OPT="-ac -br -noreset" XVFB_CMD="$(which Xvfb||true)" XVFB_OPT="" AP_OPT="" SERVERNUM=5 SCREEN="1024x768x24" USEWM=0 WINDOWMANAGER="ratpoison" WINDOWMANAGER_CMD="$(which $WINDOWMANAGER||true)" DBUS_SESSION_BUS_PID="" X_PID="" usage() { # Display usage and exit with error cat</dev/null 2>&1 || true done exit $RC } trap on_exit EXIT INT QUIT ABRT PIPE TERM SHORTOPTS="hda:s:Xw:" LONGOPTS="help,debug,autopilot:,screen:,xephyr,windowmanager:" TEMP=$(getopt -o $SHORTOPTS --long $LONGOPTS -- "$@") eval set -- "$TEMP" exec 2>&1 while true ; do case "$1" in -h|--help) usage;; -d|--debug) set -x shift;; -a|--autopilot) AP_OPT=$2 shift 2;; -s|-screen) SCREEN=$2 shift 2;; -w|--windowmanager) USEWM=1 WINDOWMANAGER=$2 WINDOWMANAGER_CMD="$(which $WINDOWMANAGER||true)" [ ! -x "$WINDOWMANAGER_CMD" ] && \ echo "E: $WINDOWMANAGER Executable not found." &&\ RC=1 && exit 1 shift 2;; -X|--xephyr) XEPHYR=1 [ ! -x "$XEPHYR_CMD" ] && \ echo "E: Xephyr executable not found. Please install Xephyr" &&\ RC=1 && exit 1 shift;; --) shift; break;; *) usage;; esac done [ $# -eq 0 ] && usage if [ $XEPHYR -eq 0 -a ! -x "$XVFB_CMD" ]; then echo "E: Xvfb executable not found. Please install xvfb" RC=1 exit fi TESTLIST="$@" echo "I: Running tests $TESTLIST" SERVERNUM=$(find_free_servernum) XCMD="$XVFB_CMD :$SERVERNUM $XVFB_OPT -screen 0 $SCREEN" [ $XEPHYR -eq 1 ] && XCMD="$XEPHYR_CMD :$SERVERNUM $XEPHYR_OPT -screen $SCREEN" echo "I: Starting X Server: $XCMD" $XCMD >/dev/null 2>&1 & X_PID=$! export DISPLAY=:${SERVERNUM}.0 export XAUTHORITY=/dev/null wait_for_x $SERVERNUM if [ "$USEWM" -eq 1 ]; then echo "I: Starting window manager: $WINDOWMANAGER_CMD" dbus-launch --exit-with-session $WINDOWMANAGER_CMD & fi echo "I: Starting autopilot" dbus-launch --exit-with-session python3 -m autopilot.run run $AP_OPT $TESTLIST || RC=$? echo "I: autopilot tests done" ./README0000644000004100000410000001003614002063567012126 0ustar www-datawww-dataWelcome to the Autopilot source code! ##################################### Autopilot is a tool for writing functional tests for GUI applications. Autopilot is free software, licensed under GNU General Public License (GPLv3+). Links ===== - Project Home (Source Code, Version Control, Bug Tracking, etc): https://launchpad.net/autopilot - Documentation (Tutorial, FAQ, API Reference, etc): https://developer.ubuntu.com/api/autopilot/python/1.6.1/ - IRC channel is #ubuntu-autopilot on irc.freenode.net Build Instructions ================== Autopilot is not buildable within a python virtualenv, as it requires several packages that are not available on ``pypi``. Instead, either use autopilot from the source tree, or build debian packages instead. Instructions for building debian packages are below: Assuming a current ubuntu installation, make sure you have the build tools that are required installed:: $ sudo apt-get install devscripts equivs bzr-builddeb Then install the build-dependencies for the autopilot packages:: $ sudo mk-build-deps -i Then build the debian packages:: $ bzr bd bzr-builddeb will build binary packages into the parent directory, or into '../build-area/' if you do not own the correct gpg key to sign the package. The resulting ``.deb`` files can be installed as normal with ``sudo dpkg -i package.deb``. The documentation can be built separately from the debian packages:: $ python3 setup.py build_sphinx The docs are built into 'build/sphinx/html' and can be opened in the default browser with:: $ xdg-open build/sphinx/html/index.html Running and Listing tests ========================= Normally running autopilot tests is as easy as:: $ autopilot3 run There are some complexities when attempting to run the autopilot tests from within a development branch (this related to how autopilot modules are loaded and then used to attempt to collect the required tests). For that reason when running autopilot's tests while hacking on it it is advised to run autopilot in this manner:: $ python3 -m autopilot.run run autopilot.tests.unit Listing is similar:: $ python3 -m autopilot.run list autopilot.tests.unit For a more complete explanation for running or listing tests please see the full documentation found here: https://developer.ubuntu.com/api/autopilot/python/1.6.1/guides-running_ap/ If you are in the root of the autopilot source tree this will run/list the tests from within that local module. Otherwise autopilot will look in the system python path. Release Autopilot ================= 1. Open a new request on bileto: https://bileto.ubuntu.com/ with the lp:autopilot -> lp:autopilot/1.5 merge proposal 2. Add the relevant details (i.e. bug fix details in the landing description and a link to the testplan: https://wiki.ubuntu.com/Process/Merges/TestPlan/autopilot) 3. Build the silo and run the tests 4. Once happy with all tests approve and publish the result Release Manual Tests ==================== Not all our tests are automated at the moment. Specifically, the vis tool is lacking some automated tests due to deficiancies in other packages. Until we remedy this situation, the following things need to be manually tested upon an autopilot release: - Run the following tests by running both: ``autopilot vis`` and ``autopilot3 vis``. - Run 'window-mocker -testability' and the vis tool. - Make sure you can select window-mocker from the connection list. - Make sure the top-level tree node is 'window-mocker' - Run the vis tool with the '-testability' flag enabled. Run a second vis tool, and make sure that the second vis tool can introspect the first. - Make sure that the component overlay feature highlights the selected item in the tree view, as long as it has a globalRect. - Make sure that searching works: - Searching narrows down tree view to just the nodes that match the search criteria. - Searching turns off the current component overlay (if any). - Resetting the search restores the tree view to the full tree. - Resetting the search turns off the current component overlay (if any). ./icons/0000755000004100000410000000000014002063564012356 5ustar www-datawww-data./icons/autopilot.svg0000644000004100000410000024624514002063564015134 0ustar www-datawww-data image/svg+xml ./icons/autopilot-toggle-overlay.svg0000644000004100000410000000366414002063564020066 0ustar www-datawww-data image/svg+xml ./docs/0000755000004100000410000000000014002063564012173 5ustar www-datawww-data./docs/tutorial/0000755000004100000410000000000014002063564014036 5ustar www-datawww-data./docs/tutorial/getting_started.rst0000644000004100000410000004432214002063564017764 0ustar www-datawww-dataWriting Your First Test ####################### This document contains everything you need to know to write your first autopilot test. It covers writing several simple tests for a sample Qt5/Qml application. However, it's important to note that nothing in this tutorial is specific to Qt5/Qml, and will work equally well with any other kind of application. Files and Directories ===================== Your autopilot test suite will grow to several files, possibly spread across several directories. We recommend that you follow this simple directory layout:: autopilot/ autopilot// autopilot//tests/ The ``autopilot`` folder can be anywhere within your project's source tree. It will likely contain a `setup.py `_ file. The ``autopilot//`` folder is the base package for your autopilot tests. This folder, and all child folders, are python packages, and so must contain an `__init__.py file `_. If you ever find yourself writing custom proxy classes (This is an advanced topic, and is covered here: :ref:`custom_proxy_classes`), they should be imported from this top-level package. Each test file should be named ``test_.py``, where ** is the logical component you are testing in that file. Test files must be written in the ``autopilot//tests/`` folder. A Minimal Test Case +++++++++++++++++++ Autopilot tests follow a similar pattern to other python test libraries: you must declare a class that derives from :class:`~autopilot.testcase.AutopilotTestCase`. A minimal test case looks like this:: from autopilot.testcase import AutopilotTestCase class MyTests(AutopilotTestCase): def test_something(self): """An example test case that will always pass.""" self.assertTrue(True) .. otto:: **Make your tests expressive!** It's important to make sure that your tests express your *intent* as clearly as possible. We recommend choosing long, descriptive names for test functions and classes (even breaking :pep:`8`, if you need to), and give your tests a detailed docstring explaining exactly what you are trying to test. For more detailed advice on this point, see :ref:`write-expressive-tests` The Setup Phase =============== Before each test is run, the ``setUp`` method is called. Test authors may override this method to run any setup that needs to happen before the test is run. However, care must be taken when using the ``setUp`` method: it tends to hide code from the test case, which can make your tests less readable. It is our recommendation, therefore, that you use this feature sparingly. A more suitable alternative is often to put the setup code in a separate function or method and call it from the test function. Should you wish to put code in a setup method, it looks like this: .. code-block:: python from autopilot.testcase import AutopilotTestCase class MyTests(AutopilotTestCase): def setUp(self): super(MyTests, self).setUp() # This code gets run before every test! def test_something(self): """An example test case that will always pass.""" self.assertTrue(True) .. note:: Any action you take in the setup phase must be undone if it alters the system state. See :ref:`cleaning-up` for more details. Starting the Application ++++++++++++++++++++++++ At the start of your test, you need to tell autopilot to launch your application. To do this, call :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application`. The minimum required argument to this method is the application name or path. If you pass in the application name, autopilot will look in the current working directory, and then will search the :envvar:`PATH` environment variable. Otherwise, autopilot looks for the executable at the path specified. Positional arguments to this method are passed to the executable being launched. Autopilot will try and guess what type of application you are launching, and therefore what kind of introspection libraries it should load. Sometimes autopilot will need some assistance however. For example, at the time of writing, autopilot cannot automatically detect the introspection type for python / Qt4 applications. In that case, a :class:`RuntimeError` will be raised. To provide autopilot with a hint as to which introspection type to load, you can provide the ``app_type`` keyword argument. For example:: class MyTests(AutopilotTestCase): def test_python_qt4_application(self): self.app = self.launch_test_application( 'my-pyqt4-app', app_type='qt' ) See the documentation for :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application` for more details. The return value from :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application` is a proxy object representing the root of the introspection tree of the application you just launched. .. otto:: **What is a Proxy Object?** Whenever you launch an application, autopilot gives you a "proxy object". These are instances of the :class:`~autopilot.introspection.ProxyBase` class, with all the data from your application mirrored in the proxy object instances. For example, if you have a proxy object for a push button class (say, ``QPushButton``, for example), the proxy object will have attribute to match every attribute in the class within your application. Autopilot automatically keeps the data in these instances up to date, so you can use them in your test assertions. User interfaces are made up of a tree of widgets, and autopilot represents these widgets as a tree of proxy objects. Proxy objects have a number of methods on them for selecting child objects in the introspection tree, so test authors can easily inspect the parts of the UI tree they care about. A Simple Test ============= To demonstrate the material covered so far, this selection will outline a simple application, and a single test for it. Instead of testing a third-party application, we will write the simplest possible application in Python and Qt4. The application, named 'testapp.py', is listed below:: #!/usr/bin/env python from PyQt4 import QtGui from sys import argv def main(): app = QtGui.QApplication(argv) win = QtGui.QMainWindow() win.show() win.setWindowTitle("Hello World") app.exec_() if __name__ == '__main__': main() As you can see, this is a trivial application, but it serves our purpose. For the upcoming tests to run this file must be executable:: $ chmod u+x testapp.py We will write a single autopilot test that asserts that the title of the main window is equal to the string "Hello World". Our test file is named "test_window.py", and contains the following code:: from autopilot.testcase import AutopilotTestCase from os.path import abspath, dirname, join from testtools.matchers import Equals class MainWindowTitleTests(AutopilotTestCase): def launch_application(self): """Work out the full path to the application and launch it. This is necessary since our test application will not be in $PATH. :returns: The application proxy object. """ full_path = abspath(join(dirname(__file__), '..', '..', 'testapp.py')) return self.launch_test_application(full_path, app_type='qt') def test_main_window_title_string(self): """The main window title must be 'Hello World'.""" app_root = self.launch_application() main_window = app_root.select_single('QMainWindow') self.assertThat(main_window.windowTitle, Equals("Hello World")) Note that we have made the test method as readable as possible by hiding the complexities of finding the full path to the application we want to test. Of course, if you can guarantee that the application is in :envvar:`PATH`, then this step becomes a lot simpler. The entire directory structure looks like this:: ./example/__init__.py ./example/tests/__init__.py ./example/tests/test_window.py ./testapp.py The ``__init__.py`` files are empty, and are needed to make these directories importable by python. Running Autopilot +++++++++++++++++ From the root of this directory structure, we can ask autopilot to list all the tests it can find:: $ autopilot3 list example Loading tests from: /home/thomi/code/canonical/autopilot/example_test example.tests.test_window.MainWindowTitleTests.test_main_window_title_string 1 total tests. Note that on the first line, autopilot will tell you where it has loaded the test definitions from. Autopilot will look in the current directory for a python package that matches the package name specified on the command line. If it does not find any suitable packages, it will look in the standard python module search path instead. To run our test, we use the autopilot 'run' command:: $ autopilot3 run example Loading tests from: /home/thomi/code/canonical/autopilot/example_test Tests running... Ran 1 test in 2.342s OK You will notice that the test application launches, and then dissapears shortly afterwards. Since this test doesn't manipulate the application in any way, this is a rather boring test to look at. If you ever want more output from the run command, you may specify the '-v' flag:: $ autopilot3 run -v example Loading tests from: /home/thomi/code/canonical/autopilot/example_test Tests running... 13:41:11.614 INFO globals:49 - ************************************************************ 13:41:11.614 INFO globals:50 - Starting test example.tests.test_window.MainWindowTitleTests.test_main_window_title_string 13:41:11.693 INFO __init__:136 - Launching process: ['/home/thomi/code/canonical/autopilot/example_test/testapp.py', '-testability'] 13:41:11.699 INFO __init__:169 - Looking for autopilot interface for PID 12013 (and children) 13:41:11.727 WARNING __init__:185 - Caught exception while searching for autopilot interface: 'DBusException("Could not get PID of name 'org.freedesktop.DBus': no such name",)' 13:41:12.773 WARNING __init__:185 - Caught exception while searching for autopilot interface: 'DBusException("Could not get PID of name 'org.freedesktop.DBus': no such name",)' 13:41:12.848 WARNING __init__:185 - Caught exception while searching for autopilot interface: 'RuntimeError("Could not find Autopilot interface on DBus backend ''",)' 13:41:12.852 WARNING __init__:185 - Caught exception while searching for autopilot interface: 'RuntimeError("Could not find Autopilot interface on DBus backend ''",)' 13:41:12.863 WARNING dbus:464 - Generating introspection instance for type 'Root' based on generic class. 13:41:12.864 DEBUG dbus:338 - Selecting objects of type QMainWindow with attributes: {} 13:41:12.871 WARNING dbus:464 - Generating introspection instance for type 'QMainWindow' based on generic class. 13:41:12.886 INFO testcase:380 - waiting for process to exit. 13:41:13.983 INFO testresult:35 - OK: example.tests.test_window.MainWindowTitleTests.test_main_window_title_string Ran 1 test in 2.370s OK You may also specify '-v' twice for even more output (this is rarely useful for test authors however). Both the 'list' and 'run' commands take a test id as an argument. You may be as generic, or as specific as you like. In the examples above, we will list and run all tests in the 'example' package (i.e.- all tests), but we could specify a more specific run criteria if we only wanted to run some of the tests. For example, to only run the single test we've written, we can execute:: $ autopilot3 run example.tests.test_window.MainWindowTitleTests.test_main_window_title_string .. _tut_test_with_interaction: A Test with Interaction ======================= Now lets take a look at some simple tests with some user interaction. First, update the test application with some input and output controls:: #!/usr/bin/env python # File: testapp.py from PyQt4 import QtGui from sys import argv class AutopilotHelloWorld(QtGui.QWidget): def __init__(self): super(AutopilotHelloWorld, self).__init__() self.hello = QtGui.QPushButton("Hello") self.hello.clicked.connect(self.say_hello) self.goodbye = QtGui.QPushButton("Goodbye") self.goodbye.clicked.connect(self.say_goodbye) self.response = QtGui.QLabel("Response: None") grid = QtGui.QGridLayout() grid.addWidget(self.hello, 0, 0) grid.addWidget(self.goodbye, 0, 1) grid.addWidget(self.response, 1, 0, 1, 2) self.setLayout(grid) self.show() self.setWindowTitle("Hello World") def say_hello(self): self.response.setText('Response: Hello') def say_goodbye(self): self.response.setText('Response: Goodbye') def main(): app = QtGui.QApplication(argv) ahw = AutopilotHelloWorld() app.exec_() if __name__ == '__main__': main() We've reorganized the application code into a class to make the event handling easier. Then we added two input controls, the ``hello`` and ``goodbye`` buttons and an output control, the ``response`` label. The operation of the application is still very trivial, but now we can test that it actually does something in response to user input. Clicking either of the two buttons will cause the response text to change. Clicking the ``Hello`` button should result in ``Response: Hello`` while clicking the ``Goodbye`` button should result in ``Response: Goodbye``. Since we're adding a new category of tests, button response tests, we should organize them into a new class. Our tests module now looks like:: from autopilot.testcase import AutopilotTestCase from os.path import abspath, dirname, join from testtools.matchers import Equals from autopilot.matchers import Eventually class HelloWorldTestBase(AutopilotTestCase): def launch_application(self): """Work out the full path to the application and launch it. This is necessary since our test application will not be in $PATH. :returns: The application proxy object. """ full_path = abspath(join(dirname(__file__), '..', '..', 'testapp.py')) return self.launch_test_application(full_path, app_type='qt') class MainWindowTitleTests(HelloWorldTestBase): def test_main_window_title_string(self): """The main window title must be 'Hello World'.""" app_root = self.launch_application() main_window = app_root.select_single('AutopilotHelloWorld') self.assertThat(main_window.windowTitle, Equals("Hello World")) class ButtonResponseTests(HelloWorldTestBase): def test_hello_response(self): """The response text must be 'Response: Hello' after a Hello click.""" app_root = self.launch_application() response = app_root.select_single('QLabel') hello = app_root.select_single('QPushButton', text='Hello') self.mouse.click_object(hello) self.assertThat(response.text, Eventually(Equals('Response: Hello'))) def test_goodbye_response(self): """The response text must be 'Response: Goodbye' after a Goodbye click.""" app_root = self.launch_application() response = app_root.select_single('QLabel') goodbye = app_root.select_single('QPushButton', text='Goodbye') self.mouse.click_object(goodbye) self.assertThat(response.text, Eventually(Equals('Response: Goodbye'))) In addition to the new class, ``ButtonResponseTests``, you'll notice a few other changes. First, two new import lines were added to support the new tests. Next, the existing ``MainWindowTitleTests`` class was refactored to subclass from a base class, ``HelloWorldTestBase``. The base class contains the ``launch_application`` method which is used for all test cases. Finally, the object type of the main window changed from ``QMainWindow`` to ``AutopilotHelloWorld``. The change in object type is a result of our test application being refactored into a class called ``AutopilotHelloWorld``. .. otto:: **Be careful when identifing user interface controls** Notice that our simple refactoring of the test application forced a change to the test for the main window. When developing application code, put a little extra thought into how the user interface controls will be identified in the tests. Identify objects with attributes that are likely to remain constant as the application code is developed. The ``ButtonResponseTests`` class adds two new tests, one for each input control. Each test identifies the user interface controls that need to be used, performs a single, specific action, and then verifies the outcome. In ``test_hello_response``, we first identify the ``QLabel`` control which contains the output we need to check. We then identify the ``Hello`` button. As the application has two ``QPushButton`` controls, we must further refine the ``select_single`` call by specifing an additional property. In this case, we use the button text. Next, an input action is triggered by instructing the ``mouse`` to click the ``Hello`` button. Finally, the test asserts that the response label text matches the expected string. The second test repeats the same process with the ``Goodbye`` button. The Eventually Matcher ====================== Notice that in the ButtonResponseTests tests above, the autopilot method :class:`~autopilot.matchers.Eventually` is used in the assertion. This allows the assertion to be retried continuously until it either becomes true, or times out (the default timout is 10 seconds). This is necessary because the application and the autopilot tests run in different processes. Autopilot could test the assert before the application has completed its action. Using :class:`~autopilot.matchers.Eventually` allows the application to complete its action without having to explicitly add delays to the tests. .. otto:: **Use Eventually when asserting any user interface condition** You may find that when running tests, the application is often ready with the outcome by the time autopilot is able to test the assertion without using :class:`~autopilot.matchers.Eventually`. However, this may not always be true when running your test suite on different hardware. .. TODO: Continue to discuss the issues with running tests & application in separate processes, and how the Eventually matcher helps us overcome these problems. Cover the various ways the matcher can be used. ./docs/tutorial/what_is_autopilot.rst0000644000004100000410000001117714002063564020335 0ustar www-datawww-dataWhat is Autopilot, and what can it do? ###################################### Autopilot is a tool for writing functional tests. Functional tests are tests that: * Run out-of-process. I.e.- the tests run in a separate process to the application under test. * Simulate user interaction. Autopilot provides methods to generate keyboard, mouse, and touch events. These events are delivered to the application under test in exactly the same way as normal input events. The application under test therefore cannot distinguish between a "real" user and an autopilot test case. * Validate design decisions. The primary function of a functional test is to determine whether of not an application has met the design criteria. Functional tests evaluate high-level design correctness. Where is Autopilot used? ######################## Autopilot was designed to test the `Unity 3D `_ shell. However, since then it has been used to test a number of other applications, including: * Core Ubuntu GUI applications. * Mobile phone applications for the Ubuntu Phone & Ubuntu Tablet. How does Autopilot fit with other test frameworks? ################################################## .. image:: /images/test_pyramid.* Autopilot exists at the apex of the "testing pyramid". It is designed to test high-level functionality, and complement a solid base of unit and integration tests. *Using autopilot is not a substitute for testing your application with unit and integration tests!*. Autopilot is a very capable tool for testing high-level feature functionality. It is not an appropriate tool for testing low-level implementation details. Autopilot is built on top of several other python test frameworks, including: * `Python Testtools `_ - :class:`~autopilot.testcase.AutopilotTestCase` derives from the testtools :class:`~testtools.TestCase` class, which allows test author to use all the extended features found in testtools. Specifically, Autopilot includes the :class:`~autopilot.matchers.Eventually` matcher class, which allows test authors to make assertions about the application under test without having to worry about the timing between the tests and the application under test. * `Python Test Scenarios `_ - :class:`~autopilot.testcase.AutopilotTestCase` contains the necessary plumbing in order to allow test authors to use test scenarios out of the box. This is extremely useful when you want to test several different modes of operation. * `Python Test Fixtures `_ - Several parts of autopilot are built as fixtures. While this is rarely exposed to the test author, it can be useful to know that this functionality is always present whenever autopilot is installed. What do Autopilot Tests Contain? ################################ A typical autopilot test has three distinct stages: .. image:: /images/test_stages.* **The Setup Stage** There are several concerns that must be addressed in the setup Phase. The most important step is to launch the application to be tested. Most autopilot test suites launch the application under test anew for each test. This ensures that the test starts with the application under test in a known, clean state. Autopilot can launch normal applications, launch applications via upstart, or launch apps contained within a click package. Tests may also wish to take other actions in the setup stage, including: * Setting environment variables to certain values. * Starting external applications that are required for the test to run. * Creating files or folders (or any kind of external data) on disk. The purpose of the setup stage is to make sure that everything that is required for the test to run is in place. **The Interaction Stage** Once the setup has been completed, it's time to start interacting with your application. This typically involves generating input events. For example, if you are testing a text editor you might have a test whose specification is similar to the following:: Type some text into the document area, open the 'Edit' menu and click the 'Search and Replace' menu item. During this stage you will most likely need to read the applications internal state. For example, your test will need to know where the 'Edit' menu is on the screen. Thankfully, autopilot takes care of the details, allowing you to write expressive tests. **The Assertion Stage** The final stage is where you determine if your test should pass or fail. Most tests will contain more than one assertion (:ref:`why? `). Autopilot contains several custom assertions that make testing high-level concepts easier. ./docs/tutorial/advanced_autopilot.rst0000644000004100000410000010571214002063564020443 0ustar www-datawww-dataAdvanced Autopilot Features ########################### This document covers advanced features in autopilot. .. _cleaning-up: Cleaning Up =========== It is vitally important that every test you run leaves the system in exactly the same state as it found it. This means that: * Any files written to disk need to be removed. * Any environment variables set during the test run need to be un-set. * Any applications opened during the test run need to be closed again. * Any :class:`~autopilot.input.Keyboard` keys pressed during the test need to be released again. All of the methods on :class:`~autopilot.testcase.AutopilotTestCase` that alter the system state will automatically revert those changes at the end of the test. Similarly, the various input devices will release any buttons or keys that were pressed during the test. However, for all other changes, it is the responsibility of the test author to clean up those changes. For example, a test might require that a file with certain content be written to disk at the start of the test. The test case might look something like this:: class MyTests(AutopilotTestCase): def make_data_file(self): open('/tmp/datafile', 'w').write("Some data...") def test_application_opens_data_file(self): """Our application must be able to open a data file from disk.""" self.make_data_file() # rest of the test code goes here However this will leave the :file:`/tmp/datafile` on disk after the test has finished. To combat this, use the :meth:`addCleanup` method. The arguments to :meth:`addCleanup` are a callable, and then zero or more positional or keyword arguments. The Callable will be called with the positional and keyword arguments after the test has ended. Cleanup actions are called in the reverse order in which they are added, and are called regardless of whether the test passed, failed, or raised an uncaught exception. To fix the above test, we might write something similar to:: import os class MyTests(AutopilotTestCase): def make_data_file(self): open('/tmp/datafile', 'w').write("Some data...") self.addCleanup(os.remove, '/tmp/datafile') def test_application_opens_data_file(self): """Our application must be able to open a data file from disk.""" self.make_data_file() # rest of the test code goes here Note that by having the code to generate the ``/tmp/datafile`` file on disk in a separate method, the test itself can ignore the fact that these resources need to be cleaned up. This makes the tests cleaner and easier to read. Test Scenarios ============== Occasionally test authors will find themselves writing multiple tests that differ in one or two subtle ways. For example, imagine a hypothetical test case that tests a dictionary application. The author wants to test that certain words return no results. Without using test scenarios, there are two basic approaches to this problem. The first is to create many test cases, one for each specific scenario (*don't do this*):: class DictionaryResultsTests(AutopilotTestCase): def test_empty_string_returns_no_results(self): self.dictionary_app.enter_search_term("") self.assertThat(len(self.dictionary_app.results), Equals(0)) def test_whitespace_string_returns_no_results(self): self.dictionary_app.enter_search_term(" \t ") self.assertThat(len(self.dictionary_app.results), Equals(0)) def test_punctuation_string_returns_no_results(self): self.dictionary_app.enter_search_term(".-?<>{}[]") self.assertThat(len(self.dictionary_app.results), Equals(0)) def test_garbage_string_returns_no_results(self): self.dictionary_app.enter_search_term("ljdzgfhdsgjfhdgjh") self.assertThat(len(self.dictionary_app.results), Equals(0)) The main problem here is that there's a lot of typing in order to change exactly one thing (and this hypothetical test is deliberately short, to ease clarity. Imagine a 100 line test case!). Another approach is to make the entire thing one large test (*don't do this either*):: class DictionaryResultsTests(AutopilotTestCase): def test_bad_strings_returns_no_results(self): bad_strings = ("", " \t ", ".-?<>{}[]", "ljdzgfhdsgjfhdgjh", ) for input in bad_strings: self.dictionary_app.enter_search_term(input) self.assertThat(len(self.dictionary_app.results), Equals(0)) This approach makes it easier to add new input strings, but what happens when just one of the input strings stops working? It becomes very hard to find out which input string is broken, and the first string that breaks will prevent the rest of the test from running, since tests stop running when the first assertion fails. The solution is to use test scenarios. A scenario is a class attribute that specifies one or more scenarios to run on each of the tests. This is best demonstrated with an example:: class DictionaryResultsTests(AutopilotTestCase): scenarios = [ ('empty string', {'input': ""}), ('whitespace', {'input': " \t "}), ('punctuation', {'input': ".-?<>{}[]"}), ('garbage', {'input': "ljdzgfhdsgjfhdgjh"}), ] def test_bad_strings_return_no_results(self): self.dictionary_app.enter_search_term(self.input) self.assertThat(len(self.dictionary_app.results), Equals(0)) Autopilot will run the ``test_bad_strings_return_no_results`` once for each scenario. On each test, the values from the scenario dictionary will be mapped to attributes of the test case class. In this example, that means that the 'input' dictionary item will be mapped to ``self.input``. Using scenarios has several benefits over either of the other strategies outlined above: * Tests that use strategies will appear as separate tests in the test output. The test id will be the normal test id, followed by the strategy name in parenthesis. So in the example above, the list of test ids will be:: DictionaryResultsTests.test_bad_strings_return_no_results(empty string) DictionaryResultsTests.test_bad_strings_return_no_results(whitespace) DictionaryResultsTests.test_bad_strings_return_no_results(punctuation) DictionaryResultsTests.test_bad_strings_return_no_results(garbage) * Since scenarios are treated as separate tests, it's easier to debug which scenario has broken, and re-run just that one scenario. * Scenarios get applied before the ``setUp`` method, which means you can use scenario values in the ``setUp`` and ``tearDown`` methods. This makes them more flexible than either of the approaches listed above. .. TODO: document the use of the multiply_scenarios feature. Test Logging ============ Autopilot integrates the `python logging framework `_ into the :class:`~autopilot.testcase.AutopilotTestCase` class. Various autopilot components write log messages to the logging framework, and all these log messages are attached to each test result when the test completes. By default, these log messages are shown when a test fails, or if autopilot is run with the ``-v`` option. Test authors are encouraged to write to the python logging framework whenever doing so would make failing tests clearer. To do this, there are a few simple steps to follow: 1. Import the logging module:: import logging 2. Create a ``logger`` object. You can either do this at the file level scope, or within a test case class:: logger = logging.getLogger(__name__) 3. Log some messages. You may choose which level the messages should be logged at. For example:: logger.debug("This is debug information, not shown by default.") logger.info("This is some information") logger.warning("This is a warning") logger.error("This is an error") .. note:: To view log messages when using ``debug`` level of logging pass ``-vv`` when running autopilot. For more information on the various logging levels, see the `python documentation on Logger objects `_. All messages logged in this way will be picked up by the autopilot test runner. This is a valuable tool when debugging failing tests. Environment Patching ==================== Sometimes you need to change the value of an environment variable for the duration of a single test. It is important that the variable is changed back to it's original value when the test has ended, so future tests are run in a pristine environment. The :mod:`fixtures` module includes a :class:`fixtures.EnvironmentVariable` fixture which takes care of this for you. For example, to set the ``FOO`` environment variable to ``"Hello World"`` for the duration of a single test, the code would look something like this:: from fixtures import EnvironmentVariable from autopilot.testcase import AutopilotTestCase class MyTests(AutopilotTestCase): def test_that_needs_custom_environment(self): self.useFixture(EnvironmentVariable("FOO", "Hello World")) # Test code goes here. The :class:`fixtures.EnvironmentVariable` fixture will revert the value of the environment variable to it's initial value, or will delete it altogether if the environment variable did not exist when :class:`fixtures.EnvironmentVariable` was instantiated. This happens in the cleanup phase of the test execution. .. _custom_assertions: Custom Assertions ================= Autopilot provides additional custom assertion methods within the :class:`~autopilot.testcase.AutopilotTestCase` base class. These assertion methods can be used for validating the visible window stack and also properties on objects whose attributes do not have the ``wait_for`` method, such as :class:`~autopilot.process.Window` objects (See :ref:`wait_for` for more information about ``wait_for``). :py:mod:`autopilot.testcase.AutopilotTestCase.assertVisibleWindowStack` This assertion allows the test to check the start of the visible window stack by passing an iterable item of :class:`~autopilot.process.Window` instances. Minimised windows will be ignored:: from autopilot.process import ProcessManager from autopilot.testcase import AutopilotTestCase class WindowTests(AutopilotTestCase): def test_window_stack(self): self.launch_some_test_apps() pm = ProcessManager.create() test_app_windows = [] for window in pm.get_open_windows(): if self.is_test_app(window.name): test_app_windows.append(window) self.assertVisibleWindowStack(test_app_windows) .. note:: The process manager is only available on environments that use bamf, i.e. desktop running Unity 7. There is currently no process manager for any other platform. .. _custom_assertions_assertProperty: :py:mod:`autopilot.testcase.AutopilotTestCase.assertProperty` This assertion allows the test to check properties of an object that does not have a **wait_for** method (i.e.- objects that do not come from the autopilot DBus interface). For example the :py:mod:`~autopilot.process.Window` object:: from autopilot.process import ProcessManager from autopilot.testcase import AutopilotTestCase class WindowTests(AutopilotTestCase): def test_window_stack(self): self.launch_some_test_apps() pm = ProcessManager.create() for window in pm.get_open_windows(): if self.is_test_app(window.name): self.assertProperty(window, is_maximized=True) .. note:: :py:mod:`~autopilot.testcase.AutopilotTestCase.assertProperties` is a synonym for this method. .. note:: The process manager is only available on environments that use bamf, i.e. desktop running Unity 7. There is currently no process manager for any other platform. :py:mod:`autopilot.testcase.AutopilotTestCase.assertProperties` See :ref:`autopilot.testcase.AutopilotTestCase.assertProperty `. .. note:: :py:mod:`~autopilot.testcase.AutopilotTestCase.assertProperty` is a synonym for this method. .. _platform_selection: Platform Selection ================== .. Document the methods we have to get information about the platform we're running on, and how we can skip tests based on this information. Autopilot provides functionality that allows the test author to determine which platform a test is running on so that they may either change behaviour within the test or skipping the test all together. For examples and API documentaion please see :py:mod:`autopilot.platform`. .. _gestures_multitouch: Gestures and Multi-touch ======================== Autopilot provides API support for both :ref:`single-touch ` and :ref:`multi-touch ` gestures which can be used to simulate user input required to drive an application or system under test. These APIs should be used in conjunction with :ref:`platform_selection` to detect platform capabilities and ensure the correct input API is being used. .. _single_touch: Single-Touch ++++++++++++ :class:`autopilot.input.Touch` provides single-touch input gestures, which includes: * :meth:`~autopilot.input.Touch.tap` which can be used to tap a specified [x,y] point on the screen * :meth:`~autopilot.input.Touch.drag` which will drag between 2 [x,y] points and can be customised by altering the speed of the action * :meth:`~autopilot.input.Touch.press`, :meth:`~autopilot.input.Touch.release` and :meth:`~autopilot.input.Touch.move` operations which can be combined to create custom gestures * :meth:`~autopilot.input.Touch.tap_object` can be used to tap the center point of a given introspection object, where the screen co-ordinates are taken from one of several properties of the object Autopilot additionally provides the class :class:`autopilot.input.Pointer` as a means to provide a single unified API that can be used with both :class:`~autopilot.input.Mouse` input and :class:`~autopilot.input.Touch` input . See the :class:`documentation ` for this class for further details of this, as not all operations can be performed on both of these input types. This example demonstrates swiping from the center of the screen to the left edge, which could for example be used in `Ubuntu Touch `_ to swipe a new scope into view. 1. First calculate the center point of the screen (see: :ref:`display_information`): :: >>> from autopilot.display import Display >>> display = Display.create() >>> center_x = display.get_screen_width() // 2 >>> center_y = display.get_screen_height() // 2 2. Then perform the swipe operation from the center of the screen to the left edge, using :meth:`autopilot.input.Pointer.drag`: :: >>> from autopilot.input import Touch, Pointer >>> pointer = Pointer(Touch.create()) >>> pointer.drag(center_x, center_y, 0, center_y) .. _multi_touch: Multi-Touch +++++++++++ :class:`autopilot.gestures` provides support for multi-touch input which includes: * :meth:`autopilot.gestures.pinch` provides a 2-finger pinch gesture centered around an [x,y] point on the screen This example demonstrates how to use the pinch gesture, which for example could be used on `Ubuntu Touch `_ web-browser, or gallery application to zoom in or out of currently displayed content. 1. To zoom in, pinch vertically outwards from the center point by 100 pixels: :: >>> from autopilot import gestures >>> gestures.pinch([center_x, center_y], [0, 0], [0, 100]) 2. To zoom back out, pinch vertically 100 pixels back towards the center point: :: >>> gestures.pinch([center_x, center_y], [0, 100], [0, 0]) .. note:: The multi-touch :meth:`~autopilot.gestures.pinch` method is intended for use on a touch enabled device. However, if run on a desktop environment it will behave as if the mouse select button is pressed whilst moving the mouse pointer. For example to select some text in a document. .. _tut-picking-backends: Advanced Backend Picking ======================== Several features in autopilot are provided by more than one backend. For example, the :mod:`autopilot.input` module contains the :class:`~autopilot.input.Keyboard`, :class:`~autopilot.input.Mouse` and :class:`~autopilot.input.Touch` classes, each of which can use more than one implementation depending on the platform the tests are being run on. For example, when running autopilot on a traditional ubuntu desktop platform, :class:`~autopilot.input.Keyboard` input events are probably created using the X11 client libraries. On a phone platform, X11 is not present, so autopilot will instead choose to generate events using the kernel UInput device driver instead. Other autopilot systems that make use of multiple backends include the :mod:`autopilot.display` and :mod:`autopilot.process` modules. Every class in these modules follows the same construction pattern: Default Creation ++++++++++++++++ By default, calling the ``create()`` method with no arguments will return an instance of the class that is appropriate to the current platform. For example:: >>> from autopilot.input import Keyboard >>> kbd = Keyboard.create() The code snippet above will create an instance of the Keyboard class that uses X11 on Desktop systems, and UInput on other systems. On the rare occaison when test authors need to construct these objects themselves, we expect that the default creation pattern to be used. .. _adv_picking_backend: Picking a Backend +++++++++++++++++ Test authors may sometimes want to pick a specific backend. The possible backends are documented in the API documentation for each class. For example, the documentation for the :meth:`autopilot.input.Keyboard.create` method says there are three backends available: the ``X11`` backend, the ``UInput`` backend, and the ``OSK`` backend. These backends can be specified in the create method. For example, to specify that you want a Keyboard that uses X11 to generate it's input events:: >>> from autopilot.input import Keyboard >>> kbd = Keyboard.create("X11") Similarly, to specify that a UInput keyboard should be created:: >>> from autopilot.input import Keyboard >>> kbd = Keyboard.create("UInput") Finally, for the Onscreen Keyboard:: >>> from autopilot.input import Keyboard >>> kbd = Keyboard.create("OSK") .. warning:: Care must be taken when specifying specific backends. There is no guarantee that the backend you ask for is going to be available across all platforms. For that reason, using the default creation method is encouraged. .. warning:: The **OSK** backend has some known implementation limitations, please see :meth:`autopilot.input.Keyboard.create` method documenation for further details. Possible Errors when Creating Backends ++++++++++++++++++++++++++++++++++++++ Lots of things can go wrong when creating backends with the ``create`` method. If autopilot is unable to create any backends for your current platform, a :exc:`RuntimeError` exception will be raised. It's ``message`` attribute will contain the error message from each backend that autopilot tried to create. If a preferred backend was specified, but that backend doesn't exist (probably the test author mis-spelled it), a :exc:`RuntimeError` will be raised:: >>> from autopilot.input import Keyboard >>> try: ... kbd = Keyboard.create("uinput") ... except RuntimeError as e: ... print("Unable to create keyboard: " + str(e)) ... Unable to create keyboard: Unknown backend 'uinput' In this example, ``uinput`` was mis-spelled (backend names are case sensitive). Specifying the correct backend name works as expected:: >>> from autopilot.input import Keyboard >>> kbd = Keyboard.create("UInput") Finally, if the test author specifies a preferred backend, but that backend could not be created, a :exc:`autopilot.BackendException` will be raised. This is an important distinction to understand: While calling ``create()`` with no arguments will try more than one backend, specifying a backend to create will only try and create that one backend type. The BackendException instance will contain the original exception raised by the backed in it's ``original_exception`` attribute. In this example, we try and create a UInput keyboard, which fails because we don't have the correct permissions (this is something that autopilot usually handles for you):: >>> from autopilot.input import Keyboard >>> from autopilot import BackendException >>> try: ... kbd = Keyboard.create("UInput") ... except BackendException as e: ... repr(e.original_exception) ... repr(e) ... 'UInputError(\'"/dev/uinput" cannot be opened for writing\',)' 'BackendException(\'Error while initialising backend. Original exception was: "/dev/uinput" cannot be opened for writing\',)' Keyboard Backends ================= A quick introduction to the Keyboard backends +++++++++++++++++++++++++++++++++++++++++++++ Each backend has a different method of operating behind the scenes to provide the Keyboard interface. Here is a quick overview of how each backend works. .. list-table:: :widths: 15, 85 :header-rows: 1 * - Backend - Description * - X11 - The X11 backend generates X11 events using a mock input device which it then syncs with X to actually action the input. * - Uinput - The UInput backend injects events directly in to the kernel using the UInput device driver to produce input. * - OSK - The Onscreen Keyboard backend uses the GUI pop-up keyboard to enter input. Using a pointer object it taps on the required keys to get the expected output. .. _keyboard_backend_limitations: Limitations of the different Keyboard backends ++++++++++++++++++++++++++++++++++++++++++++++ While every effort has been made so that the Keyboard devices act the same regardless of which backend or platform is in use, the simple fact is that there can be some technical limitations for some backends. Some of these limitations are hidden when using the "create" method and won't cause any concern (e.g. X11 backend on desktop, UInput on an Ubuntu Touch device.) while others will raise exceptions (that are fully documented in the API docs). Here is a list of known limitations: **X11** * Only available on desktop platforms - X11 isn't available on Ubuntu Touch devices **UInput** * Requires correct device access permissions - The user (or group) that are running the autopilot tests need read/write access to the UInput device (usually /dev/uinput). * Specific kernel support is required - The kernel on the system running the tests must be running a kernel that includes UInput support (as well as have the module loaded. **OSK** * Currently only available on Ubuntu Touch devices - At the time of writing this the OSK/Ubuntu Keyboard is only supported/available on the Ubuntu Touch devices. It is possible that it will be available on the desktop in the near future. * Unable to type 'special' keys e.g. Alt - This shouldn't be an issue as applications running on Ubuntu Touch devices will be using the expected patterns of use on these platforms. * The following methods have limitations or are not implemented: - :meth:`autopilot.input.Keyboard.press`: Raises NotImplementedError if called. - :meth:`autopilot.input.Keyboard.release`: Raises NotImplementedError if called. - :meth:`autopilot.input.Keyboard.press_and_release`: can can only handle single keys/characters. Raises either ValueError if passed more than a single character key or UnsupportedKey if passed a key that is not supported by the OSK backend (or the current language layout). .. _process_control: Process Control =============== The :mod:`autopilot.process` module provides the :class:`~autopilot.process.ProcessManager` class to provide a high-level interface for managing applications and windows during testing. Features of the :class:`~autopilot.process.ProcessManager` allow the user to start and stop applications easily and to query the current state of an application and its windows. It also provides automatic cleanup for apps that have been launched during testing. .. note:: :class:`~autopilot.process.ProcessManager` is not intended for introspecting an application's object tree, for this see :ref:`launching_applications`. Also it does not provide a method for interacting with an application's UI or specific features. Properties of an application and its windows can be accessed using the classes :class:`~autopilot.process.Application` and :class:`~autopilot.process.Window`, which also allows the window instance to be focused and closed. A list of known applications is defined in :meth:`~autopilot.process.ProcessManager.KNOWN_APPS` and these can easily be referenced by name. This list can also be updated using :meth:`~autopilot.process.ProcessManager.register_known_application` and :meth:`~autopilot.process.ProcessManager.unregister_known_application` for easier use during the test. To use the :class:`~autopilot.process.ProcessManager` the static :meth:`~autopilot.process.ProcessManager.create` method should be called, which returns an initialised object instance. A simple example to launch the gedit text editor and check it is in focus: :: from autopilot.process import ProcessManager from autopilot.testcase import AutopilotTestCase class ProcessManagerTestCase(AutopilotTestCase): def test_launch_app(self): pm = ProcessManager.create() app_window = pm.start_app_window('Text Editor') app_window.set_focus() self.assertTrue(app_window.is_focused) .. note:: :class:`~autopilot.process.ProcessManager` is only available on environments that use bamf, i.e. desktop running Unity 7. There is currently no process manager for any other platform. .. _display_information: Display Information =================== Autopilot provides the :mod:`autopilot.display` module to get information about the displays currently being used. This information can be used in tests to implement gestures or input events that are specific to the current test environment. For example a test could be run on a desktop environment with multiple screens, or on a variety of touch devices that have different screen sizes. The user must call the static :meth:`~autopilot.display.Display.create` method to get an instance of the :class:`~autopilot.display.Display` class. This example shows how to get the size of each available screen, which could be used to calculate coordinates for a swipe or input event (See the :mod:`autopilot.input` module for more details about generating input events).:: from autopilot.display import Display display = Display.create() for screen in range(0, display.get_num_screens()): width = display.get_screen_width(screen) height = display.get_screen_height(screen) print('screen {0}: {1}x{2}'.format(screen, width, height)) .. _custom_proxy_classes: Writing Custom Proxy Classes ============================ By default, autopilot will generate an object for every introspectable item in your application under test. These are generated on the fly, and derive from :class:`~autopilot.introspection.ProxyBase`. This gives you the usual methods of selecting other nodes in the object tree, as well the the means to inspect all the properties in that class. However, sometimes you want to customize the class used to create these objects. The most common reason to want to do this is to provide methods that make it easier to inspect or interact with these objects. Autopilot allows test authors to provide their own custom classes, through a couple of simple steps: 1. First, you must define your own base class, to be used by all custom proxy objects in your test suite. This base class can be empty, but must derive from :class:`~autopilot.introspection.ProxyBase`. An example class might look like this:: from autopilot.introspection import ProxyBase class CustomProxyObjectBase(ProxyBase): """A base class for all custom proxy objects within this test suite.""" For Ubuntu applications using Ubuntu UI Toolkit objects, you should derive your custom proxy object from UbuntuUIToolkitCustomProxyObjectBase. This base class is also derived from :class:`~autopilot.introspection.ProxyBase` and is used for all Ubuntu UI Toolkit custom proxy objects. So if you are introspecting objects from Ubuntu UI Toolkit then this is the base class to use. 2. Define the classes you want autopilot to use, instead of the default. The simplest method is to give the class the same name as the type you wish to override. For example, if you want to define your own custom class to be used every time autopilot generates an instance of a 'QLabel' object, the class definition would look like this:: class QLabel(CustomProxyObjectBase): # Add custom methods here... If you wish to implement more specific selection criteria, your class can override the validate_dbus_object method, which takes as arguments the dbus path and state. For example:: class SpecificQLabel(CustomProxyObjectBase): def validate_dbus_object(path, state): return (path.endswith('object_we_want') or state['some_property'] == 'desired_value') This method should return True if the object matches this custom proxy class, and False otherwise. If more than one custom proxy class matches an object, a :exc:`ValueError` will be raised at runtime. An example using Ubuntu UI Toolkit which would be used to swipe up a PageWithBottomEdge object to reveal it's bottom edge menu could look like this:: import ubuntuuitoolkit class PageWithBottomEdge(ubuntuuitoolkit.UbuntuUIToolkitCustomProxyObjectBase): """An emulator class that makes it easy to interact with the bottom edge swipe page""" def reveal_bottom_edge_page(self): """Swipe up from the bottom edge of the Page to reveal it's bottom edge menu.""" 3. Pass the custom proxy base class as an argument to the launch_test_application method on your test class. This base class should be the same base class that is used to write all of your custom proxy objects:: from autopilot.testcase import AutopilotTestCase class TestCase(AutopilotTestCase): def setUp(self): super().setUp() self.app = self.launch_test_application( '/path/to/the/application', emulator_base=CustomProxyObjectBase) For applications using objects from Ubuntu UI Toolkit, the emulator_base parameter should be:: emulator_base=ubuntuuitoolkit.UbuntuUIToolkitCustomProxyObjectBase 4. You can pass the custom proxy class to methods like :meth:`~autopilot.introspection.ProxyBase.select_single` instead of a string. So, for example, the following is a valid way of selecting the QLabel instances in an application:: # Get all QLabels in the applicaton: labels = self.app.select_many(QLabel) If you are introspecting an application that already has a custom proxy base class defined, then this class can simply be imported and passed to the appropriate application launcher method. See :ref:`launching applications ` for more details on launching an application for introspection. This will allow you to call all of the public methods of the application's proxy base class directly in your test. This example will run on desktop and uses the webbrowser application to navigate to a url using the base class go_to_url() method:: from autopilot.testcase import AutopilotTestCase from webbrowser_app.emulators import browser class ClickAppTestCase(AutopilotTestCase): def test_go_to_url(self): app = self.launch_test_application( 'webbrowser-app', emulator_base=browser.Webbrowser) # main_window is a property of the Webbrowser class app.main_window.go_to_url('http://www.ubuntu.com') .. _launching_applications: Launching Applications ====================== Applications can be launched inside of a testcase using the application launcher methods from the :class:`~autopilot.testcase.AutopilotTestCase` class. The exact method required will depend upon the type of application being launched: * :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application` is used to launch regular executables * :meth:`~autopilot.testcase.AutopilotTestCase.launch_upstart_application` is used to launch upstart-based applications * :meth:`~autopilot.testcase.AutopilotTestCase.launch_click_package` is used to launch applications inside a `click package `_ This example shows how to launch an installed click application from within a test case:: from autopilot.testcase import AutopilotTestCase class ClickAppTestCase(AutopilotTestCase): def test_something(self): app_proxy = self.launch_click_package('com.ubuntu.calculator') Outside of testcase classes, the :class:`~autopilot.application.NormalApplicationLauncher`, :class:`~autopilot.application.UpstartApplicationLauncher`, and :class:`~autopilot.application.ClickApplicationLauncher` fixtures can be used, e.g.:: from autopilot.application import NormalApplicationLauncher with NormalApplicationLauncher() as launcher: launcher.launch('gedit') or a similar example for an installed click package:: from autopilot.application import ClickApplicationLauncher with ClickApplicationLauncher() as launcher: app_proxy = launcher.launch('com.ubuntu.calculator') Within a fixture or a testcase, ``self.useFixture`` can be used:: launcher = self.useFixture(NormalApplicationLauncher()) launcher.launch('gedit', ['--new-window', '/path/to/file']) or for an installed click package:: launcher = self.useFixture(ClickApplicationLauncher()) app_proxy = launcher.launch('com.ubuntu.calculator') Additional options can also be specified to set a custom addDetail method, a custom proxy base, or a custom dbus bus with which to patch the environment:: launcher = self.useFixture(NormalApplicationLauncher( case_addDetail=self.addDetail, dbus_bus='some_other_bus', proxy_base=my_proxy_class, )) .. note:: You must pass the test case's 'addDetail' method to these application launch fixtures if you want application logs to be attached to the test result. This is due to the way fixtures are cleaned up, and is unavoidable. The main qml file of some click applications can also be launched directly from source. This can be done using the `qmlscene `_ application directly on the target application's main qml file. This example uses :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application` method from within a test case:: app_proxy = self.launch_test_application('qmlscene', 'application.qml', app_type='qt') However, using this method it will not be possible to return an application specific custom proxy object, see :ref:`custom_proxy_classes`. ./docs/tutorial/tutorial.rst0000644000004100000410000000031414002063564016431 0ustar www-datawww-dataAutopilot Tutorial ================== This tutorial will guide users new to Autopilot through creating a minimal autopilot test. .. toctree:: :maxdepth: 3 getting_started advanced_autopilot ./docs/conf.py0000644000004100000410000002162514002063564013500 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # This file is execfile()d with the current directory set to its containing # dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys # If extensions (or modules to document with autodoc) are in another # directory, add these directories to sys.path here. If the directory is # relative to the documentation root, use os.path.abspath to make it absolute, # like shown here. sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('.')) # -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.graphviz', 'sphinx.ext.pngmath', 'sphinx.ext.viewcode', 'otto', 'man', ] autodoc_member_order = 'bysource' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Autopilot' copyright = u'2012-2014, Canonical' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '1.5' # The full version, including alpha/beta/rc tags. try: from debian import changelog chl = changelog.Changelog(open('../debian/changelog')) release = str(chl.version).split('ubuntu')[0] except ImportError: # If we don't have python-debian installed, guess a coarse-grained version # string release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = False # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [''] # nitpicky = True # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'nature' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. html_logo = './images/otto-64.png' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. html_favicon = './images/favicon.ico' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { '**': ['localtoc.html', 'searchbox.html', 'relations.html'] } # Additional templates that should be rendered to pages, maps page names to # template names. html_additional_pages = { 'index': 'indexcontent.html', } # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Autopilotdoc' # -- Options for LaTeX output ------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ ('index', 'Autopilot.tex', u'Autopilot Documentation', u'Thomi Richards', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('man', 'autopilot3', u'Automated acceptance test tool', [u'Thomi Richards'], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ----------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Autopilot', u'Autopilot Documentation', u'Thomi Richards', 'Autopilot', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' ./docs/contents.rst0000644000004100000410000000051114002063564014557 0ustar www-datawww-data:orphan: Global Contents ############### .. toctree:: :maxdepth: 5 tutorial/what_is_autopilot tutorial/tutorial guides/good_tests guides/running_ap guides/installation api/index porting/porting guides/page_object faq/faq faq/contribute faq/troubleshooting appendix/appendix ./docs/guides/0000755000004100000410000000000014002063564013453 5ustar www-datawww-data./docs/guides/page_object.rst0000644000004100000410000002177614002063564016464 0ustar www-datawww-data.. _page_object_guide: Page Object Pattern #################### .. contents:: Introducing the Page Object Pattern ----------------------------------- Automated testing of an application through the Graphical User Interface (GUI) is inherently fragile. These tests require regular review and attention during the development cycle. This is known as Interface Sensitivity (`"even minor changes to the interface can cause tests to fail" `_). Utilizing the page-object pattern, alleviates some of the problems stemming from this fragility, allowing us to do automated user acceptance testing (UAT) in a sustainable manner. The Page Object Pattern comes from the `Selenium community `_ and is the best way to turn a flaky and unmaintainable user acceptance test into a stable and useful part of your release process. A page is what's visible on the screen at a single moment. A user story consists of a user jumping from page to page until they achieve their goal. Thus pages are modeled as objects following these guidelines: #. The public methods represent the services that the page offers. #. Try not to expose the internals of the page. #. Methods return other PageObjects. #. Assertions should exist only in tests #. Objects need not represent the entire page. #. Actions which produce multiple results should have a test for each result Lets take the page objects of the `Ubuntu Clock App `__ as an example, with some simplifications. This application is written in QML and Javascript using the `Ubuntu SDK `__. 1. The public methods represent the services that the page offers. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This application has a stopwatch page that lets users measure elapsed time. It offers services to start, stop and reset the watch, so we start by defining the stop watch page object as follows: .. code-block:: python class Stopwatch(object): def start(self): raise NotImplementedError() def stop(self): raise NotImplementedError() def reset(self): raise NotImplementedError() 2. Try not to expose the internals of the page. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The internals of the page are more likely to change than the services it offers. A stopwatch will keep the same three services we defined above even if the whole design changes. In this case, we reset the stop watch by clicking a button on the bottom-left of the window, but we hide that as an implementation detail behind the public methods. In Python, we can indicate that a method is for internal use only by adding a single leading underscore to its name. So, lets implement the reset\_stopwatch method: .. code-block:: python def reset(self): self._click_reset_button() def _click_reset_button(self): reset_button = self.wait_select_single( 'ImageButton', objectName='resetButton') self.pointing_device.click_object(reset_button) Now if the designers go crazy and decide that it's better to reset the stop watch in a different way, we will have to make the change only in one place to keep all the tests working. Remember that this type of tests has Interface Sensitivity, that's unavoidable; but we can reduce the impact of interface changes with proper encapsulation and turn these tests into a useful way to verify that a change in the GUI didn't introduce any regressions. .. _page_object_guide_guideline_3: 3. Methods return other PageObjects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ An UAT checks a user story. It will involve the journey of the user through the system, so he will move from one page to another. Lets take a look at how a journey to reset the stop watch will look like: .. code-block:: python stopwatch = clock_page.open_stopwatch() stopwatch.start() stopwatch.reset() In our sample application, the first page that the user will encounter is the Clock. One of the things the user can do from this page is to open the stopwatch page, so we model that as a service that the Clock page provides. Then return the new page object that will be visible to the user after completing that step. .. code-block:: python class Clock(object): ... def open_stopwatch(self): self._switch_to_tab('StopwatchTab') return self.wait_select_single(Stopwatch) Now the return value of open\_stopwatch will make available to the caller all the available services that the stopwatch exposes to the user. Thus it can be chained as a user journey from one page to the other. 4. Assertions should exist only in tests ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A well written UAT consists of a sequence of steps or user actions and ends with one single assertion that verifies that the user achieved its goal. The page objects are the helpers for the user actions part of the test, so it's better to leave the check for success out of them. With that in mind, a test for the reset of the stopwatch would look like this: .. code-block:: python def test_restart_button_must_restart_stopwatch_time(self): # Set up. stopwatch = self.clock_page.open_stopwatch() stopwatch.start() stopwatch.reset_stopwatch() # Check that the stopwatch has been reset. self.assertThat( stopwatch.get_time, Eventually(Equals('00:00.0'))) We have to add a new method to the stopwatch page object: get\_time. But it only returns the state of the GUI as the user sees it. We leave in the test method the assertion that checks it's the expected value. .. code-block:: python class Stopwatch(object): ... def get_time(self): return self.wait_select_single( 'Label', objectName='time').text 5. Need not represent an entire page ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The objects we are modeling here can just represent a part of the page. Then we build the entire page that the user is seeing by composition of page parts. This way we can reuse test code for parts of the GUI that are reused in the application or between different applications. As an example, take the \_switch\_to\_tab('StopwatchTab') method that we are using to open the stopwatch page. The Clock application is using the Header component provided by the Ubuntu SDK, as all the other Ubuntu applications are doing too. So, the Ubuntu SDK also provides helpers to make it easier the user acceptance testing of the applications, and you will find an object like this: .. code-block:: python class Header(object): def switch_to_tab(tab_object_name): """Open a tab. :parameter tab_object_name: The QML objectName property of the tab. :return: The newly opened tab. :raise ToolkitException: If there is no tab with that object name. """ ... This object just represents the header of the page, and inside the object we define the services that the header provides to the users. If you dig into the full implementation of the Clock test class you will find that in order to open the stopwatch page we end up calling Header methods. 6. Actions which produce multiple results should have a test for each result ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ According to the guideline :ref:`page_object_guide_guideline_3`, we are returning page objects every time that a user action opens the option for new actions to execute. Sometimes the same action has different results depending on the context or the values used for the action. For example, the Clock app has an Alarm page. In this page you can add new alarms, but if you try to add an alarm for sometime in the past, it will result in an error. So, we will have two different tests that will look something like this: .. code-block:: python def test_add_alarm_for_tomorrow_must_add_to_alarm_list(self): tomorrow = ... test_alarm_name = 'Test alarm for tomorrow' alarm_page = self.alarm_page.add_alarm( test_alarm_name, tomorrow) saved_alarms = alarm_page.get_saved_alarms() self.assertIn( (test_alarm_name, tomorrow), saved_alarms) def test_add_alarm_for_earlier_today_must_display_error(self): earlier_today = ... test_alarm_name = 'Test alarm for earlier_today' error_dialog = self.alarm_page.add_alarm_with_error( test_alarm_name, earlier_today) self.assertEqual( error_dialog.text, 'Please select a time in the future.') Take a look at the methods add\_alarm and add\_alarm\_with\_error. The first one returns the Alarm page again, where the user can continue his journey or finish the test checking the result. The second one returns the error dialog that's expected when you try to add an alarm with the wrong values. ./docs/guides/installation.rst0000644000004100000410000000323514002063564016711 0ustar www-datawww-data.. _installing_autopilot: Installing Autopilot #################### .. contents:: Autopilot is in continuous development, and the best way to get the latest version of autopilot is to run the latest Ubuntu development image. The autopilot developers traditionally support the Ubuntu release immediately prior to the development release via the autopilot PPA. Ubuntu ====== **I am running the latest development image!** In that case you can install autopilot directly from the repository and know you are getting the latest release. Check out the packages below. **I am running a stable version of Ubuntu!** You may install the version of autopilot in the archive directly, however it will not be up to date. Instead, you should add the latest autopilot ppa to your system (as of this writing, that is autopilot 1.5). To add the PPA to your system, run the following command:: sudo add-apt-repository ppa:autopilot/1.5 && sudo apt-get update Once the PPA has been added to your system, you should be able to install the autopilot packages below. **Which packages should I install?** Are you working on ubuntu touch applications? The ``autopilot-touch`` metapackage is for you:: sudo apt-get install autopilot-touch If you are sticking with gtk desktop applications, install the ``autopilot-desktop`` metapackage instead:: sudo apt-get install autopilot-desktop Feel free to install both metapackages to ensure you have support for all autopilot tests. Other Linux's ============= You may have to download the source code, and either run from source, or build the packages locally. Your best bet is to ask in the autopilot IRC channel ( :ref:`help_and_support`). ./docs/guides/running_ap.rst0000644000004100000410000001556014002063564016354 0ustar www-datawww-dataRunning Autopilot ================= Autopilot test suites can be run with any python test runner (for example, the built-in testtools runner). However, several autopilot features are only available if you use the autopilot runner. List Tests ---------- Autopilot can list all tests found within a particular module:: $ autopilot3 list where ** is the base name of the module you want to look at. The module must either be in the current working directory, or be importable by python. For example, to list the tests inside autopilot itself, you can run:: $ autopilot3 list autopilot autopilot.tests.test_ap_apps.GtkTests.test_can_launch_qt_app autopilot.tests.test_ap_apps.QtTests.test_can_launch_qt_app autopilot.tests.test_application_mixin.ApplicationSupportTests.test_can_create autopilot.tests.test_application_mixin.ApplicationSupportTests.test_launch_raises_ValueError_on_unknown_kwargs autopilot.tests.test_application_mixin.ApplicationSupportTests.test_launch_raises_ValueError_on_unknown_kwargs_with_known autopilot.tests.test_application_mixin.ApplicationSupportTests.test_launch_with_bad_types_raises_typeerror autopilot.tests.test_application_registration.ApplicationRegistrationTests.test_can_register_new_application autopilot.tests.test_application_registration.ApplicationRegistrationTests.test_can_unregister_application autopilot.tests.test_application_registration.ApplicationRegistrationTests.test_registering_app_twice_raises_KeyError autopilot.tests.test_application_registration.ApplicationRegistrationTests.test_unregistering_unknown_application_raises_KeyError ... 81 total tests. Some results have been omitted for clarity. The list command takes only one option: -ro, --run-order Display tests in the order in which they will be run, rather than alphabetical order (which is the default). Run Tests --------- Running autopilot tests is very similar to listing tests:: $ autopilot3 run However, the run command has many more options to customize the run behavior: -h, --help show this help message and exit -o OUTPUT, --output OUTPUT Write test result report to file. Defaults to stdout. If given a directory instead of a file will write to a file in that directory named: _.log -f FORMAT, --format FORMAT Specify desired output format. Default is "text". Other option is 'xml' to produce junit xml format. -r, --record Record failing tests. Required 'recordmydesktop' app to be installed. Videos are stored in /tmp/autopilot. -rd PATH, --record-directory PATH Directory to put recorded tests (only if -r) specified. -v, --verbose If set, autopilot will output test log data to stderr during a test run. Common use cases ++++++++++++++++ 1. **Run autopilot and save the test log**:: $ autopilot3 run -o . Autopilot will create a text log file named _.log with the contents of the test log. 2. **Run autopilot and record failing tests**:: $ autopilot3 run -r --rd . Videos are recorded as *ogg-vorbis* files, with an .ogv extension. They will be named with the test id that failed. All videos will be placed in the directory specified by the ``-rd`` option - in this case the currect directory. If this option is omitted, videos will be placed in ``/tmp/autopilot/``. 3. **Save the test log as jUnitXml format**:: $ autopilot3 run -o results.xml -f xml The file 'results.xml' will be created when all the tests have completed, and will be in the jUnitXml file format. This is useful when running the autopilot tests within a jenkins environment. .. _launching_application_to_introspect: Launching an Application to Introspect -------------------------------------- In order to be able to introspect an application, it must first be launched with introspection enabled. Autopilot provides the **launch** command to enable this: :: $ autopilot3 launch The ** parameter could be the full path to the application, or the name of an application located somewhere on ``$PATH``. ** is passed on to the application being launched. A simple Gtk example to launch gedit:: $ autopilot3 launch gedit A Qt example which passes on parameters to the application being launched:: $ autopilot3 launch qmlscene my_app.qml Autopilot launch attempts to detect if you are launching either a Gtk or Qt application so that it can enable the correct libraries. If it is unable to determine this you will need to specify the type of application it is by using the -i argument. This allows "Gtk" or "Qt" frameworks to be specified when launching the application. The default value ("Auto") will try to detect which interface to load automatically. A typical error in this situation will be "Error: Could not determine introspection type to use for application". In which case the -i option should be specified with the correct application framework type to fix the problem:: $ autopilot3 launch -i Qt address-book-app Once an application has launched with introspection enabled, it will be possible to launch autopilot vis and view the introspection tree, see: :ref:`visualise_introspection_tree`. .. _visualise_introspection_tree: Visualise Introspection Tree ---------------------------- A very common thing to want to do while writing autopilot tests is see the structure of the application being tested. To support this, autopilot includes a simple application to help visualize the introspection tree. To start it, make sure the application you wish to test is running (see: :ref:`launching_application_to_introspect`), and then run:: $ autopilot3 vis The result should be a window similar to below: .. image:: /images/ap_vis_front_page.png Selecting a connection from the drop-down box allows you to inspect different autopilot-supporting applications. If Unity is running, the Unity connection should always be present. If other applications have been started with the autopilot support enabled, they should appear in this list as well. Once a connection is selected, the introspection tree is rendered in the left-hand pane, and the details of each object appear in the right-hand pane. .. image:: /images/ap_vis_object.png Autopilot vis also has the ability to search the object tree for nodes that match a given name (such as "LauncherController", for example), and draw a transparent overlay over a widget if it contains position information. These tools, when combined can make finding certain parts of an application introspection tree much easier. ./docs/guides/good_tests.rst0000644000004100000410000006317314002063564016371 0ustar www-datawww-dataWriting Good Autopilot Tests ============================ This document is an introduction to writing good autopilot tests. This should be treated as additional material on top of all the things you'd normally do to write good code. Put another way: test code follows all the same rules as production code - it must follow the coding standards, and be of a professional quality. Several points in this document are written with respect to the unity autopilot test suite. This is incidental, and doesn't mean that these points do not apply to other test suites! .. _write-expressive-tests: Write Expressive Tests ++++++++++++++++++++++ Unit tests are often used as a reference for how your public API should be used. Functional (Autopilot) tests are no different: they can be used to figure out how your application should work from a functional standpoint. However, this only works if your tests are written in a clear, concise, and most importantly expressive style. There are many things you can do to make your tests easier to read: **Pick Good Test Case Class Names** Pick a name that encapsulates all the tests in the class, but is as specific as possible. If necessary, break your tests into several classes, so your class names can be more specific. This is important because when a test fails, the test id is the primary means of identifying the failure. The more descriptive the test id is, the easier it is to find the fault and fix the test. **Pick Good Test Case Method Names** Similar to picking good test case class names, picking good method names makes your test id more descriptive. We recommend writing very long test method names, for example: .. code-block:: python # bad example: def test_save_dialog(self): # test goes here # better example: def test_save_dialog_can_cancel(self): # test goes here # best example: def test_save_dialog_cancels_on_escape_key(self): # test goes here **Write Docstrings** You should write docstrings for your tests. Often the test method is enough to describe what the test does, but an English description is still useful when reading the test code. For example: .. code-block:: python def test_save_dialog_cancels_on_escape_key(self): """The Save dialog box must cancel when the escape key is pressed.""" We recommend following :pep:`257` when writing all docstrings. Test One Thing Only +++++++++++++++++++ Tests should test one thing, and one thing only. Since we're not writing unit tests, it's fine to have more than one assert statement in a test, but the test should test one feature only. How do you tell if you're testing more than one thing? There's two primary ways: 1. Can you describe the test in a single sentence without using words like 'and', 'also', etc? If not, you should consider splitting your tests into multiple smaller tests. 2. Tests usually follow a simple pattern: a. Set up the test environment. b. Perform some action. c. Test things with assert statements. If you feel you're repeating steps 'b' and 'c' you're likely testing more than one thing, and should consider splitting your tests up. **Good Example:** .. code-block:: python def test_alt_f4_close_dash(self): """Dash must close on alt+F4.""" self.dash.ensure_visible() self.keyboard.press_and_release("Alt+F4") self.assertThat(self.dash.visible, Eventually(Equals(False))) This test tests one thing only. Its three lines match perfectly with the typical three stages of a test (see above), and it only tests for things that it's supposed to. Remember that it's fine to assume that other parts of unity work as expected, as long as they're covered by an autopilot test somewhere else - that's why we don't need to verify that the dash really did open when we called ``self.dash.ensure_visible()``. Fail Well +++++++++ Make sure your tests test what they're supposed to. It's very easy to write a test that passes. It's much more difficult to write a test that only passes when the feature it's testing is working correctly, and fails otherwise. There are two main ways to achieve this: * Write the test first. This is easy to do if you're trying to fix a bug in Unity. In fact, having a test that's exploitable via an autopilot test will help you fix the bug as well. Once you think you have fixed the bug, make sure the autopilot test you wrote now passed. The general workflow will be: 0. Branch unity trunk. 1. Write autopilot test that reproduces the bug. 2. Commit. 3. Write code that fixes the bug. 4. Verify that the test now passes. 5. Commit. Push. Merge. 6. Celebrate! * If you're writing tests for a bug-fix that's already been written but is waiting on tests before it can be merged, the workflow is similar but slightly different: 0. Branch unity trunk. 1. Write autopilot test that reproduces the bug. 2. Commit. 3. Merge code that supposedly fixes the bug. 4. Verify that the test now passes. 5. Commit. Push. Superseed original merge proposal with your branch. 6. Celebrate! Think about design ++++++++++++++++++ Much in the same way you might choose a functional or objective-oriented paradigm for a piece of code, a testsuite can benefit from choosing a good design pattern. One such design pattern is the page object model. The page object model can reduce testcase complexity and allow the testcase to grow and easily adapt to changes within the underlying application. Check out :ref:`page_object_guide`. Test Length +++++++++++ Tests should be short - as short as possible while maintaining readability. Longer tests are harder to read, harder to understand, and harder to debug. Long tests are often symptomatic of several possible problems: * Your test requires complicated setup that should be encapsulated in a method or function. * Your test is actually several tests all jammed into one large test. **Bad Example:** .. code-block:: python def test_panel_title_switching_active_window(self): """Tests the title shown in the panel with a maximized application.""" # Locked Launchers on all monitors self.set_unity_option('num_launchers', 0) self.set_unity_option('launcher_hide_mode', 0) text_win = self.open_new_application_window("Text Editor", maximized=True) self.assertTrue(text_win.is_maximized) self.assertThat(self.panel.title, Equals(text_win.title)) sleep(.25) calc_win = self.open_new_application_window("Calculator") self.assertThat(self.panel.title, Equals(calc_win.application.name)) icon = self.launcher.model.get_icon_by_desktop_id(text_win.application.desktop_file) launcher = self.launcher.get_launcher_for_monitor(self.panel_monitor) launcher.click_launcher_icon(icon) self.assertTrue(text_win.is_focused) self.assertThat(self.panel.title, Equals(text_win.title)) This test can be simplified into the following: .. code-block:: python def test_panel_title_switching_active_window(self): """Tests the title shown in the panel with a maximized application.""" text_win = self.open_new_application_window("Text Editor", maximized=True) self.open_new_application_window("Calculator") icon = self.launcher.model.get_icon_by_desktop_id(text_win.application.desktop_file) launcher = self.launcher.get_launcher_for_monitor(self.panel_monitor) launcher.click_launcher_icon(icon) self.assertTrue(text_win.is_focused) self.assertThat(self.panel.title, Equals(text_win.title)) Here's what we changed: * Removed the ``set_unity_option`` lines, as they didn't affect the test results at all. * Removed assertions that were duplicated from other tests. For example, there's already an autopilot test that ensures that new applications have their title displayed on the panel. With a bit of refactoring, this test could be even smaller (the launcher proxy classes could have a method to click an icon given a desktop id), but this is now perfectly readable and understandable within a few seconds of reading. Good docstrings +++++++++++++++ Test docstrings are used to communicate to other developers what the test is supposed to be testing. Test Docstrings must: 1. Conform to `PEP8 `_ and `PEP257 `_ guidelines. 2. Avoid words like "should" in favor of stronger words like "must". 3. Contain a one-line summary of the test. Additionally, they should: 1. Include the launchpad bug number (if applicable). **Good Example:** .. code-block:: python def test_launcher_switcher_next_keeps_shortcuts(self): """Launcher switcher next action must keep shortcuts after they've been shown.""" Within the context of the test case, the docstring is able to explain exactly what the test does, without any ambiguity. In contrast, here's a poorer example: **Bad Example:** .. code-block:: python def test_switcher_all_mode_shows_all_apps(self): """Test switcher 'show_all' mode shows apps from all workspaces.""" The docstring explains what the desired outcome is, but without how we're testing it. This style of sentence assumes test success, which is not what we want! A better version of this code might look like this: .. code-block:: python def test_switcher_all_mode_shows_all_apps(self): """Switcher 'show all' mode must show apps from all workspaces.""" The difference between these two are subtle, but important. Test Readability ++++++++++++++++ The most important attribute for a test is that it is correct - it must test what's it's supposed to test. The second most important attribute is that it is readable. Tests should be able to be examined by themselves by someone other than the test author without any undue hardship. There are several things you can do to improve test readability: 1. Don't abuse the ``setUp()`` method. It's tempting to put code that's common to every test in a class into the ``setUp`` method, but it leads to tests that are not readable by themselves. For example, this test uses the ``setUp`` method to start the launcher switcher, and ``tearDown`` to cancel it: **Bad Example:** .. code-block:: python def test_launcher_switcher_next(self): """Moving to the next launcher item while switcher is activated must work.""" self.launcher_instance.switcher_next() self.assertThat(self.launcher.key_nav_selection, Eventually(GreaterThan(0))) This leads to a shorter test (which we've already said is a good thing), but the test itself is incomplete. Without scrolling up to the ``setUp`` and ``tearDown`` methods, it's hard to tell how the launcher switcher is started. The situation gets even worse when test classes derive from each other, since the code that starts the launcher switcher may not even be in the same class! A much better solution in this example is to initiate the switcher explicitly, and use ``addCleanup()`` to cancel it when the test ends, like this: **Good Example:** .. code-block:: python def test_launcher_switcher_next(self): """Moving to the next launcher item while switcher is activated must work.""" self.launcher_instance.switcher_start() self.addCleanup(self.launcher_instance.switcher_cancel) self.launcher_instance.switcher_next() self.assertThat(self.launcher.key_nav_selection, Eventually(GreaterThan(0))) The code is longer, but it's still very readable. It also follows the setup/action/test convention discussed above. Appropriate uses of the ``setUp()`` method include: * Initialising test class member variables. * Setting unity options that are required for the test. For example, many of the switcher autopilot tests set a unity option to prevent the switcher going into details mode after a timeout. This isn't part of the test, but makes the test easier to write. * Setting unity log levels. The unity log is captured after each test. Some tests may adjust the verbosity of different parts of the Unity logging tree. 2. Put common setup code into well-named methods. If the "setup" phase of a test is more than a few lines long, it makes sense to put this code into it's own method. Pay particular attention to the name of the method you use. You need to make sure that the method name is explicit enough to keep the test readable. Here's an example of a test that doesn't do this: **Bad Example:** .. code-block:: python def test_showdesktop_hides_apps(self): """Show Desktop keyboard shortcut must hide applications.""" self.start_app('Character Map', locale='C') self.start_app('Calculator', locale='C') self.start_app('Text Editor', locale='C') # show desktop, verify all windows are hidden: self.keybinding("window/show_desktop") self.addCleanup(self.keybinding, "window/show_desktop") open_wins = self.bamf.get_open_windows() for win in open_wins: self.assertTrue(win.is_hidden) In contrast, we can refactor the test to look a lot nicer: **Good Example:** .. code-block:: python def test_showdesktop_hides_apps(self): """Show Desktop keyboard shortcut must hide applications.""" self.launch_test_apps() # show desktop, verify all windows are hidden: self.keybinding("window/show_desktop") self.addCleanup(self.keybinding, "window/show_desktop") open_wins = self.bamf.get_open_windows() for win in open_wins: self.assertTrue(win.is_hidden) The test is now shorter, and the ``launch_test_apps`` method can be re-used elsewhere. Importantly - even though I've hidden the implementation of the ``launch_test_apps`` method, the test still makes sense. 3. Hide complicated assertions behind custom ``assertXXX`` methods or custom matchers. If you find that you frequently need to use a complicated assertion pattern, it may make sense to either: * Write a custom matcher. As long as you follow the protocol laid down by the ``testtools.matchers.Matcher`` class, you can use a hand-written Matcher just like you would use an ordinary one. Matchers should be written in the ``autopilot.matchers`` module if they're likely to be reusable outside of a single test, or as local classes if they're specific to one test. * Write custom assertion methods. For example: .. code-block:: python def test_multi_key_copyright(self): """Pressing the sequences 'Multi_key' + 'c' + 'o' must produce '©'.""" self.dash.reveal_application_lens() self.keyboard.press_and_release('Multi_key') self.keyboard.type("oc") self.assertSearchText("©") This test uses a custom method named ``assertSearchText`` that hides the complexity involved in getting the dash search text and comparing it to the given parameter. Prefer ``wait_for`` and ``Eventually`` to ``sleep`` ++++++++++++++++++++++++++++++++++++++++++++++++++++ Early autopilot tests relied on extensive use of the python ``sleep`` call to halt tests long enough for unity to change its state before the test continued. Previously, an autopilot test might have looked like this: **Bad Example:** .. code-block:: python def test_alt_f4_close_dash(self): """Dash must close on alt+F4.""" self.dash.ensure_visible() sleep(2) self.keyboard.press_and_release("Alt+F4") sleep(2) self.assertThat(self.dash.visible, Equals(False)) This test uses two ``sleep`` calls. The first makes sure the dash has had time to open before the test continues, and the second makes sure that the dash has had time to respond to our key presses before we start testing things. There are several issues with this approach: 1. On slow machines (like a jenkins instance running on a virtual machine), we may not be sleeping long enough. This can lead to tests failing on jenkins that pass on developers machines. 2. On fast machines, we may be sleeping too long. This won't cause the test to fail, but it does make running the test suite longer than it has to be. There are two solutions to this problem: In Tests -------- Tests should use the ``Eventually`` matcher. This can be imported as follows: .. code-block:: python from autopilot.matchers import Eventually The ``Eventually`` matcher works on all attributes in a proxy class that derives from ``UnityIntrospectableObject`` (at the time of writing that is almost all the autopilot unity proxy classes). The ``Eventually`` matcher takes a single argument, which is another testtools matcher instance. For example, the bad assertion from the example above could be rewritten like so: .. code-block:: python self.assertThat(self.dash.visible, Eventually(Equals(False))) Since we can use any testtools matcher, we can also write code like this: .. code-block:: python self.assertThat(self.launcher.key_nav_selection, Eventually(GreaterThan(prev_icon))) Note that you can pass any object that follows the testtools matcher protocol (so you can write your own matchers, if you like). .. _wait_for: In Proxy Classes ---------------- Proxy classes are not test cases, and do not have access to the ``self.assertThat`` method. However, we want proxy class methods to block until unity has had time to process the commands given. For example, the ``ensure_visible`` method on the Dash controller should block until the dash really is visible. To achieve this goal, all attributes on unity proxy classes have been patched with a ``wait_for`` method that takes a testtools matcher (just like ``Eventually`` - in fact, the ``Eventually`` matcher just calls wait_for under the hood). For example, previously the ``ensure_visible`` method on the Dash controller might have looked like this: **Bad Example:** .. code-block:: python def ensure_visible(self): """Ensures the dash is visible.""" if not self.visible: self.toggle_reveal() sleep(2) In this example we're assuming that two seconds is long enough for the dash to open. To use the ``wait_for`` feature, the code looks like this: **Good Example:** .. code-block:: python def ensure_visible(self): """Ensures the dash is visible.""" if not self.visible: self.toggle_reveal() self.visible.wait_for(True) Note that wait_for assumes you want to use the ``Equals`` matcher if you don't specify one. Here's another example where we're using it with a testtools matcher: .. code-block:: python key_nav_selection.wait_for(NotEquals(old_selection)) Scenarios +++++++++ Autopilot uses the ``python-testscenarios`` package to run a test multiple times in different scenarios. A good example of scenarios in use is the launcher keyboard navigation tests: each test is run once with the launcher hide mode set to 'always show launcher', and again with it set to 'autohide launcher'. This allows test authors to write their test once and have it execute in multiple environments. In order to use test scenarios, the test author must create a list of scenarios and assign them to the test case's ``scenarios`` *class* attribute. The autopilot ibus test case classes use scenarios in a very simple fashion: **Good Example:** .. code-block:: python class IBusTestsPinyin(IBusTests): """Tests for the Pinyin(Chinese) input engine.""" scenarios = [ ('basic', {'input': 'abc1', 'result': u'\u963f\u5e03\u4ece'}), ('photo', {'input': 'zhaopian ', 'result': u'\u7167\u7247'}), ('internet', {'input': 'hulianwang ', 'result': u'\u4e92\u8054\u7f51'}), ('disk', {'input': 'cipan ', 'result': u'\u78c1\u76d8'}), ('disk_management', {'input': 'cipan guanli ', 'result': u'\u78c1\u76d8\u7ba1\u7406'}), ] def test_simple_input_dash(self): self.dash.ensure_visible() self.addCleanup(self.dash.ensure_hidden) self.activate_ibus(self.dash.searchbar) self.keyboard.type(self.input) self.deactivate_ibus(self.dash.searchbar) self.assertThat(self.dash.search_string, Eventually(Equals(self.result))) This is a simplified version of the IBus tests. In this case, the ``test_simple_input_dash`` test will be called 5 times. Each time, the ``self.input`` and ``self.result`` attribute will be set to the values in the scenario list. The first part of the scenario tuple is the scenario name - this is appended to the test id, and can be whatever you want. .. Important:: It is important to notice that the test does not change its behavior depending on the scenario it is run under. Exactly the same steps are taken - the only difference in this case is what gets typed on the keyboard, and what result is expected. Scenarios are applied before the test's ``setUp`` or ``tearDown`` methods are called, so it's safe (and indeed encouraged) to set up the test environment based on these attributes. For example, you may wish to set certain unity options for the duration of the test based on a scenario parameter. Multiplying Scenarios --------------------- Scenarios are very helpful, but only represent a single-dimension of parameters. For example, consider the launcher keyboard navigation tests. We may want several different scenarios to come into play: 1. A scenario that controls whether the launcher is set to 'autohide' or 'always visible'. 2. A scenario that controls which monitor the test is run on (in case we have multiple monitors configured). We can generate two separate scenario lists to represent these two scenario axis, and then produce the dot-product of thw two lists like this: .. code-block:: python from autopilot.tests import multiply_scenarios class LauncherKeynavTests(AutopilotTestCase): hide_mode_scenarios = [ ('autohide', {'hide_mode': 1}), ('neverhide', {'hide_mode': 0}), ] monitor_scenarios = [ ('monitor_0', {'launcher_monitor': 0}), ('monitor_1', {'launcher_monitor': 1}), ] scenarios = multiply_scenarios(hide_mode_scenarios, monitor_scenarios) (please ignore the fact that we're assuming that we always have two monitors!) In the test classes ``setUp`` method, we can then set the appropriate unity option and make sure we're using the correct launcher: .. code-block:: python def setUp(self): self.set_unity_option('launcher_hide_mode', self.hide_mode) self.launcher_instance = self.launcher.get_launcher_for_monitor(self.launcher_monitor) Which allows us to write tests that work automatically in all the scenarios: .. code-block:: python def test_keynav_initiates(self): """Launcher must start keyboard navigation mode.""" self.launcher.keynav_start() self.assertThat(self.launcher.kaynav_mode, Eventually(Equals(True))) This works fine. So far we've not done anything to cause undue pain.... until we decide that we want to extend the scenarios with an additional axis: .. code-block:: python from autopilot.tests import multiply_scenarios class LauncherKeynavTests(AutopilotTestCase): hide_mode_scenarios = [ ('autohide', {'hide_mode': 1}), ('neverhide', {'hide_mode': 0}), ] monitor_scenarios = [ ('monitor_0', {'launcher_monitor': 0}), ('monitor_1', {'launcher_monitor': 1}), ] launcher_monitor_scenarios = [ ('launcher on all monitors', {'monitor_mode': 0}), ('launcher on primary monitor only', {'monitor_mode': 1}), ] scenarios = multiply_scenarios(hide_mode_scenarios, monitor_scenarios, launcher_monitor_scenarios) Now we have a problem: Some of the generated scenarios won't make any sense. For example, one such scenario will be ``(autohide, monitor_1, launcher on primary monitor only)``. If monitor 0 is the primary monitor, this will leave us running launcher tests on a monitor that doesn't contain a launcher! There are two ways to get around this problem, and they both lead to terrible tests: 1. Detect these situations and skip the test. This is bad for several reasons - first, skipped tests should be viewed with the same level of suspicion as commented out code. Test skips should only be used in exceptional circumstances. A test skip in the test results is just as serious as a test failure. 2. Detect the situation in the test, and run different code using an if statement. For example, we might decode to do this: .. code-block:: python def test_something(self): # ... setup code here ... if self.monitor_mode == 1 and self.launcher_monitor == 1: # test something else else: # test the original thing. As a general rule, tests shouldn't have assert statements inside an if statement unless there's a very good reason for doing so. Scenarios can be useful, but we must be careful not to abuse them. It is far better to spend more time typing and end up with clear, readable tests than it is to end up with fewer, less readable tests. Like all code, tests are read far more often than they're written. .. _object_ordering: Do Not Depend on Object Ordering ++++++++++++++++++++++++++++++++ Calls such as :meth:`~autopilot.introspection.ProxyBase.select_many` return several objects at once. These objects are explicitly unordered, and test authors must take care not to make assumptions about their order. **Bad Example:** .. code-block:: python buttons = self.select_many('Button') save_button = buttons[0] print_button = buttons[1] This code may work initially, but there's absolutely no guarantee that the order of objects won't change in the future. A better approach is to select the individual components you need: **Good Example:** .. code-block:: python save_button = self.select_single('Button', objectName='btnSave') print_button = self.select_single('Button', objectName='btnPrint') This code will continue to work in the future. ./docs/otto.py0000644000004100000410000000412114002063564013530 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2012-2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from docutils import nodes from sphinx.util.compat import Directive from sphinx.util.compat import make_admonition def setup(app): app.add_node(otto, html=(visit_todo_node, depart_todo_node), latex=(visit_todo_node, depart_todo_node), text=(visit_todo_node, depart_todo_node)) app.add_directive('otto', OttoSaysDirective) app.add_stylesheet('otto.css') class otto(nodes.Admonition, nodes.Element): pass def visit_todo_node(self, node): self.visit_admonition(node) def depart_todo_node(self, node): self.depart_admonition(node) class OttoSaysDirective(Directive): # this enables content in the directive has_content = True def run(self): ad = make_admonition(otto, self.name, ['Autopilot Says'], self.options, self.content, self.lineno, self.content_offset, self.block_text, self.state, self.state_machine) image_container = nodes.container() image_container.append(nodes.image(uri='/images/otto-64.png')) image_container['classes'] = ['otto-image-container'] outer_container = nodes.container() outer_container.extend([image_container] + ad) outer_container['classes'] = ['otto-says-container'] return [outer_container] ./docs/api/0000755000004100000410000000000014002063564012744 5ustar www-datawww-data./docs/api/autopilot.exceptions.rst0000644000004100000410000000022014002063564017670 0ustar www-datawww-data``autopilot.exceptions`` - Autopilot Exceptions +++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.exceptions :members:./docs/api/autopilot.matchers.rst0000644000004100000410000000025514002063564017325 0ustar www-datawww-data``autopilot.matchers`` - Custom matchers for test assertions ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.matchers :members: ./docs/api/autopilot.introspection.rst0000644000004100000410000000024314002063564020414 0ustar www-datawww-data``autopilot.introspection`` - Retrieve proxy objects +++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.introspection :members: ./docs/api/autopilot.gestures.rst0000644000004100000410000000024714002063564017361 0ustar www-datawww-data``autopilot.gestures`` - Gestural and multi-touch support +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.gestures :members: ./docs/api/autopilot.rst0000644000004100000410000000017114002063564015515 0ustar www-datawww-data``autopilot`` - Global stuff ++++++++++++++++++++++++++++ .. automodule:: autopilot :members: :undoc-members: ./docs/api/autopilot.display.rst0000644000004100000410000000032014002063564017155 0ustar www-datawww-data``autopilot.display`` - Get information about the current display(s) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.display :members: :undoc-members: ./docs/api/autopilot.process.rst0000644000004100000410000000022614002063564017173 0ustar www-datawww-data``autopilot.process`` - Process Control +++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.process :members: :undoc-members: ./docs/api/index.rst0000644000004100000410000000014614002063564014606 0ustar www-datawww-dataAutopilot API Documentation =========================== .. toctree:: :maxdepth: 1 :glob: * ./docs/api/autopilot.introspection.types.rst0000644000004100000410000000035214002063564021560 0ustar www-datawww-data``autopilot.introspection.types`` - Introspection Type details ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.introspection.types :members: PlainType, Rectangle, Point, Size, DateTime, Time ./docs/api/autopilot.emulators.rst0000644000004100000410000000026014002063564017526 0ustar www-datawww-data``autopilot.emulators`` - Backwards compatibility for autopilot v1.2 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.emulators ./docs/api/autopilot.application.rst0000644000004100000410000000025214002063564020017 0ustar www-datawww-data``autopilot.application`` - Autopilot Application Launchers +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.application :members: ./docs/api/autopilot.platform.rst0000644000004100000410000000027314002063564017343 0ustar www-datawww-data``autopilot.platform`` - Functions for platform detection +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.platform :members: :undoc-members: ./docs/api/autopilot.input.rst0000644000004100000410000000032214002063564016651 0ustar www-datawww-data``autopilot.input`` - Generate keyboard, mouse, and touch input events ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.input :members: :undoc-members: ./docs/api/autopilot.testcase.rst0000644000004100000410000000026514002063564017333 0ustar www-datawww-data``autopilot.testcase`` - Base class for all Autopilot Test Cases ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .. automodule:: autopilot.testcase :members: ./docs/faq/0000755000004100000410000000000014002063564012742 5ustar www-datawww-data./docs/faq/troubleshooting.rst0000644000004100000410000001306114002063564016724 0ustar www-datawww-data=============== Troubleshooting =============== .. contents:: .. _troubleshooting_general_techniques: ------------------ General Techniques ------------------ The single hardest thing to do while writing autopilot tests is to understand the state of the application's object tree. This is especially important for applications that change their object tree during the lifetime of the test. There are three techniques you can use to discover the state of the object tree: **Using Autopilot Vis** The :ref:`Autopilot vis tool ` is a useful tool for exploring the entire structure of an application, and allows you to search for a particular node in the object tree. If you want to find out what parts of the application to select to gain access to certain information, the vis tool is probably the best way to do that. **Using print_tree** The :meth:`~autopilot.introspection.ProxyBase.print_tree` method is available on every proxy class. This method will print every child of the proxy object recursively, either to ``stdout`` or a file on disk. This technique can be useful when: * The application cannot easily be put into the state required before launching autopilot vis, so the vis tool is no longer an option. * The application state that has to be captured only exists for a short amount of time. * The application only runs on platforms where the vis tool isn't available. The :meth:`~autopilot.introspection.ProxyBase.print_tree` method often produces a lot of output. There are two ways this information overload can be handled: #. Specify a file path to write to, so the console log doesn't get flooded. This log file can then be searched with tools such as ``grep``. #. Specify a ``maxdepth`` limit. This controls how many levels deep the recursive search will go. Of course, these techniques can be used in combination. **Using get_properties** The :meth:`~autopilot.introspection.ProxyBase.get_properties` method can be used on any proxy object, and will return a python dictionary containing all the properties of that proxy object. This is useful when you want to explore what information is provided by a single proxy object. The information returned by this method is exactly the same as is shown in the right-hand pane of ``autopilot vis``. ---------------------------------------- Common Questions regarding Failing Tests ---------------------------------------- .. _failing_tests: Q. Why is my test failing? It works some of the time. What causes "flakyness?" ============================================================================== Sometimes a tests fails because the application under tests has issues, but what happens when the failing test can't be reproduced manually? It means the test itself has an issue. Here is a troubleshooting guide you can use with some of the common problems that developers can overlook while writing tests. StateNotFoundError Exception ============================ .. _state_not_found: 1. Not waiting for an animation to finish before looking for an object. Did you add animations to your app recently? * problem:: self.main_view.select_single('Button', text='click_this') * solution:: page.animationRunning.wait_for(False) self.main_view.select_single('Button', text='click_this') 2. Not waiting for an object to become visible before trying to select it. Is your app slower than it used to be for some reason? Does its properties have null values? Do you see errors in stdout/stderr while using your app, if you run it from the commandline? Python code is executed in series which takes milliseconds, whereas the actions (clicking a button etc.) will take longer as well as the dbus query time. This is why wait_select_* is useful i.e. click a button and wait for that click to happen (including the dbus query times taken). * problem:: self.main_view.select_single('QPushButton', objectName='clickme') * solution:: self.main_view.wait_select_single('QPushButton', objectName='clickme') 3. Waiting for an item that is destroyed to be not visible, sometimes the objects is destroyed before it returns false: * problem:: self.assertThat(dialogButton.visible, Eventually(Equals(False))) * problem:: self._get_activity_indicator().running.wait_for(False) * solution:: dialogButton.wait_for_destroyed() * solution:: self._get_activity_indicator().running.wait_for_destroyed() 4. Trying to use select_many like a list. The order in which the objects are returned are non-deterministic. * problem:: def get_first_photo(self): """Returns first photo""" return event.select_many( 'OrganicItemInteraction', objectName='eventsViewPhoto' )[0] * solution:: def _get_named_photo_element(self, photo_name): """Return the ShapeItem container object for the named photo This object can be clicked to enable the photo to be selected. """ photo_element = self.grid_view().wait_select_single( 'QQuickImage', source=photo_name ) return photo_element.get_parent() def select_named_photo(self, photo_name): """Select the named photo from the picker view.""" photo_element = self._get_named_photo_element(photo_name) self.pointing_device.click_object(photo_element) ./docs/faq/contribute.rst0000644000004100000410000001011014002063564015643 0ustar www-datawww-dataContribute ########################## .. contents:: Autopilot: Contributing +++++++++++++++++++++++ Q. How can I contribute to autopilot? ===================================== * Documentation: We can always use more documentation. * if you don't know how to submit a merge proposal on launchpad, you can write a `bug `_ with new documentation and someone will submit a merge proposal for you. They will give you credit for your documentation in the merge proposal. * New Features: Check out our existing `Blueprints `_ or create some yourself... Then code! * Test and Fix: No project is perfect, log some `bugs `_ or `fix some bugs `_. Q. Where can I get help / support? ================================== The developers hang out in the #ubuntu-autopilot IRC channel on irc.freenode.net. Q. How do I download the code? ============================== Autopilot is using Launchpad and Bazaar for source code hosting. If you're new to Bazaar, or distributed version control in general, take a look at the `Bazaar mini-tutorial first. `_ Install bzr open a terminal and type:: $ sudo apt-get install bzr Download the code:: $ bzr branch lp:autopilot This will create an autopilot directory and place the latest code there. You can also view the autopilot code `on the web `_. Q. How do I submit the code for a merge proposal? ================================================= After making the desired changes to the code or documentation and making sure the tests still run type:: $ bzr commit Write a quick one line description of the bug that was fixed or the documentation that was written. Signup for a `launchpad account `_, if you don't have one. Then using your launchpad id type:: $ bzr push lp:~/autopilot/ Example:: $ bzr push lp:~chris.gagnon/autopilot/bug-fix-lp234567 All new features should have unit and/or functional test to make sure someone doesn't remove or break your new code with a future commit. .. _listing_source_tests: Q. How do I list or run the tests for the autopilot source code? ================================================================ Running autopilot from the source code root directory (the directory containing the autopilot/ bin/ docs/ debian/ etc. directories) will use the local copy and not the system installed version. An example from branching to running:: $ bzr branch lp:autopilot ~/src/autopilot/trunk $ cd ~/src/autopilot/trunk $ python3 -m autopilot.run list autopilot.tests Loading tests from: /home/example/src/autopilot/trunk autopilot.tests.functional.test_ap_apps.ApplicationLaunchTests.test_creating_app_for_non_running_app_fails autopilot.tests.functional.test_ap_apps.ApplicationLaunchTests.test_creating_app_proxy_for_running_app_not_on_dbus_fails # .. snip .. autopilot.tests.unit.test_version_utility_fns.VersionFnTests.test_package_version_returns_none_when_running_from_source 255 total tests. .. note:: The 'Loading tests from:' or 'Running tests from:' line will inform you where autopilot is loading the tests from. To run a specific suite or a single test in a suite, be more specific with the tests path. For example, running all unit tests:: $ python3 -m autopilot.run run autopilot.tests.unit For example, running just the 'InputStackKeyboardTypingTests' suite:: $ python3 -m autopilot.run run autopilot.tests.functional.test_input_stack.InputStackKeyboardTypingTests Or running a single test in the 'test_version_utility_fns' suite:: $ python3 -m autopilot.run run autopilot.tests.unit.test_version_utility_fns.VersionFnTests.test_package_version_returns_none_when_running_from_source Q. Which version of Python can Autopilot use? ============================================= Autopilot supports Python 3.4. ./docs/faq/faq.rst0000644000004100000410000003075614002063564014256 0ustar www-datawww-dataFrequently Asked Questions ########################## .. contents:: Autopilot: The Project ++++++++++++++++++++++ .. _help_and_support: Q. Where can I get help / support? ================================== The developers hang out in the #ubuntu-autopilot IRC channel on irc.freenode.net. Q. Which version of autopilot should I install? =============================================== Ideally you should adopt and utilize the latest version of autopilot. If your testcase requires you to utilize an older version of autopilot for reasons other than :ref:`porting`, please `file a bug `_ and let the development team know about your issue. Q. Should I write my tests in python2 or python3? ================================================= As Autopilot fully supports python3 (see :ref:`python3_support`), you should seek to use python3 for new tests. Before making a decision, you should also ensure any 3rd party modules your test may depend on also support python3. Q: Should I convert my existing tests to python3? ================================================= See above. In a word, yes. Converting python2 to python3 (see :ref:`python3_support`) is generally straightforward and converting a testcase is likely much easier than a full python application. You can also consider retaining python2 compatibility upon conversion. Q. Where can I report a bug? ============================ Autopilot is hosted on launchpad - bugs can be reported on the `launchpad bug page for autopilot `_ (this requires a launchpad account). Q. What type of applications can autopilot test? ================================================ Autopilot works with severall different types of applications, including: * The Unity desktop shell. * Gtk 2 & 3 applications. * Qt4, Qt5, and Qml applications. Autopilot is designed to work across all the form factors Ubuntu runs on, including the phone and tablet. Autopilot Tests +++++++++++++++ .. _faq-many-asserts: Q. Autopilot tests often include multiple assertions. Isn't this bad practise? ============================================================================== Maybe. But probably not. Unit tests should test a single unit of code, and ideally be written such that they can fail in exactly a single way. Therefore, unit tests should have a single assertion that determines whether the test passes or fails. However, autopilot tests are not unit tests, they are functional tests. Functional test suites tests features, not units of code, so there's several very good reasons to have more than assertion in a single test: * Some features require several assertions to prove that the feature is working correctly. For example, you may wish to verify that the 'Save' dialog box opens correctly, using the following code:: self.assertThat(save_win.title, Eventually(Equals("Save Document"))) self.assertThat(save_win.visible, Equals(True)) self.assertThat(save_win.has_focus, Equals(True)) * Some tests need to wait for the application to respond to user input before the test continues. The easiest way to do this is to use the :class:`~autopilot.matchers.Eventually` matcher in the middle of your interaction with the application. For example, if testing the `Firefox `_ browsers ability to print a certain web comic, we might produce a test that looks similar to this:: def test_firefox_can_print_xkcd(self): """Firefox must be able to print xkcd.com.""" # Put keyboard focus in URL bar: self.keyboard.press_and_release('Ctrl+l') self.keyboard.type('http://xkcd.com') self.keyboard.press_and_release('Enter') # wait for page to load: self.assertThat(self.app.loading, Eventually(Equals(False))) # open print dialog: self.keyboard.press_and_release('Ctrl+p') # wait for dialog to open: self.assertThat(self.app.print_dialog.open, Eventually(Equals(True))) self.keyboard.press_and_release('Enter') # ensure something was sent to our faked printer: self.assertThat(self.fake_printer.documents_printed, Equals(1)) In general, autopilot tests are more relaxed about the 'one assertion per test' rule. However, care should still be taken to produce tests that are as small and understandable as possible. Q. How do I write a test that uses either a Mouse or a Touch device interchangeably? ==================================================================================== The :class:`autopilot.input.Pointer` class is a simple wrapper that unifies some of the differences between the :class:`~autopilot.input.Touch` and :class:`~autopilot.input.Mouse` classes. To use it, pass in the device you want to use under the hood, like so:: pointer1 = Pointer(Touch.create()) pointer2 = Pointer(Mouse.create()) # pointer1 and pointer2 now have identical APIs Combined with test scenarios, this can be used to write tests that are run twice - once with a mouse device and once with a touch device:: from autopilot.input import Mouse, Touch, Pointer from autopilot.testcase import AutopilotTestCase class TestCase(AutopilotTestCase): scenarios = [ ('with mouse', dict(pointer=Pointer(Mouse.create()))), ('with touch', dict(pointer=Pointer(Touch.create()))), ] def test_something(self): """Click the pointer at 100,100.""" self.pointer.move(100,100) self.pointer.click() If you only want to use the mouse on certain platforms, use the :mod:`autopilot.platform` module to determine the current platform at runtime. Q. How do I use the Onscreen Keyboard (OSK) to input text in my test? ===================================================================== The OSK is an backend option for the :meth:`autopilot.input.Keyboard.create` method (see this :ref:`Advanced Autopilot` section for details regarding backend selection.) Unlike the other backends (X11, UInput) the OSK has a GUI presence and thus can be displayed on the screen. The :class:`autopilot.input.Keyboard` class provides a context manager that handles any cleanup required when dealing with the input backends. For example in the instance when the backend is the OSK, when leaving the scope of the context manager the OSK will be dismissed with a swipe:: from autopilot.input import Keyboard text_area = self._launch_test_input_area() keyboard = Keyboard.create('OSK') with keyboard.focused_type(text_area) as kb: kb.type("Hello World.") self.assertThat(text_area.text, Equals("Hello World")) # At this point now the OSK has been swiped away. self.assertThat() Autopilot Tests and Launching Applications ++++++++++++++++++++++++++++++++++++++++++ Q. How do I launch a Click application from within a test so I can introspect it? ================================================================================= Launching a Click application is similar to launching a traditional application and is as easy as using :meth:`~autopilot.testcase.AutopilotTestCase.launch_click_package`:: app_proxy = self.launch_click_package( "com.ubuntu.dropping-letters" ) Q. How do I access an already running application so that I can test/introspect it? =================================================================================== In instances where it's impossible to launch the application-under-test from within the testsuite use :meth:`~autopilot.introspection.get_proxy_object_for_existing_process` to get a proxy object for the running application. In all other cases the recommended way to launch and retrieve a proxy object for an application is by calling either :meth:`~autopilot.testcase.AutopilotTestCase.launch_test_application` or :meth:`~autopilot.testcase.AutopilotTestCase.launch_click_package` For example, to access a long running process that is running before your test starts:: application_pid = get_long_running_processes_pid() app_proxy = get_proxy_object_for_existing_process(pid=application_pid) Autopilot Qt & Gtk Support ++++++++++++++++++++++++++ Q. How do I launch my application so that I can explore it with the vis tool? ============================================================================= Autopilot can launch applications with Autopilot support enabled allowing you to explore and introspect the application using the :ref:`vis tool` For instance launching gedit is as easy as:: $ autopilot3 launch gedit *Autopilot launch* attempts to detect if you are launching either a Gtk or Qt application so that it can enable the correct libraries. If is is unable to determine this you will need to specify the type of application it is by using the **-i** argument. For example, in our previous example Autopilot was able to automatically determine that gedit is a Gtk application and thus no further arguments were required. If we want to use the vis tool to introspect something like the :ref:`testapp.py script ` from an earlier tutorial we will need to inform autopilot that it is a Qt application so that it can enable the correct support:: $ autopilot3 launch -i Qt testapp.py Now that it has been launched with Autopilot support we can introspect and explore out application using the :ref:`vis tool `. Q. What is the impact on memory of adding objectNames to QML items? =================================================================== The objectName is a QString property of QObject which defaults to an empty QString. QString is UTF-16 representation and because it uses some general purpose optimisations it usually allocates twice the space it needs to be able to grow fast. It also uses implicit sharing with copy-on-write and other similar tricks to increase performance again. These properties makes the used memory not straightforward to predict. For example, copying an object with an objectName, shares the memory between both as long as they are not changed. When measuring memory consumption, things like memory alignment come into play. Due to the fact that QML is interpreted by a JavaScript engine, we are working in levels where lots of abstraction layers are in between the code and the hardware and we have no chance to exactly measure consumption of a single objectName property. Therefore the taken approach is to measure lots of items and calculate the average consumption. .. table:: Measurement of memory consumption of 10000 Items ================== ====================== ==================== Without objectName With unique objectName With same objectName ================== ====================== ==================== 65292 kB 66628 kB 66480 kB ================== ====================== ==================== => With 10000 different objectNames 1336 kB of memory are consumed which is around 127 Bytes per Item. Indeed, this is more than only the string. Some of the memory is certainly lost due to memory alignment where certain areas are just not perfectly filled in but left empty. However, certainly not all of the overhead can be blamed on that. Additional memory is used by the QObject meta object information that is needed to do signal/slot connections. Also, QML does some optimisations: It does not connect signals/slots when not needed. So the fact that the object name is set could trigger some more connections. Even if more than the actual string size is used and QString uses a large representation, this is very little compared to the rest. A qmlscene with just the item is 27MB. One full screen image in the Nexus 10 tablet can easily consume around 30MB of memory. So objectNames are definitely not the first places where to search for optimisations. Writing the test code snippets, one interesting thing came up frequently: Just modifying the code around to set the objectName often influences the results more than the actual string. For example, having a javascript function that assigns the objectName definitely uses much more memory than the objectName itself. Unless it makes sense from a performance point of view (frequently changing bindings can be slow), objectNames should be added by directly binding the value to the property instead using helper code to assign it. Conclusion: If an objectName is needed for testing, this is definitely worth it. objectName's should obviously not be added when not needed. When adding them, the `general QML guidelines for performance should be followed. `_ ./docs/index.rst0000644000004100000410000000043114002063564014032 0ustar www-datawww-dataAutopilot Documentation Front Page ################################## .. toctree:: tutorial/what_is_autopilot tutorial/tutorial guides/good_tests guides/running_ap guides/installation api/index faq/faq porting/porting appendix/appendix man ./docs/man.rst0000644000004100000410000000741314002063564013505 0ustar www-datawww-dataAutopilot Man Page ################## SYNOPSIS -------- .. argparse_usage:: DESCRIPTION ----------- autopilot is a tool for writing functional test suites for graphical applications for Ubuntu. OPTIONS ------- .. argparse_options:: General Options -h, --help Get help from autopilot. This command can also be present after a sub-command (such as run or list) to get help on the specific com‐ mand. Further options are restricted to particular autopilot com‐ mands. -v, --version Display autopilot version and exit. --enable-profile Enable collection of profile data for autopilot itself. If enabled, profile data will be stored in 'autopilot_.profile' in the current working directory. list [options] suite [suite...] List the autopilot tests found in the given test suite. suite See `SPECIFYING SUITES`_ -ro, --run-order List tests in the order they will be run in, rather than alphabet‐ ically (which is the default). --suites Lists only available suites, not tests contained within the suite. run [options] suite [suite...] Run one or more test suites. suite See `SPECIFYING SUITES`_ -o FILE, --output FILE Specify where the test log should be written. Defaults to stdout. If a directory is specified the file will be created with a file‐ name of _.log -f FORMAT, --format FORMAT Specify the format for the log. Valid options are 'xml' and 'text' 'subunit' for JUnit XML, plain text, and subunit, respectively. -ff, --failfast Stop the test run on the first error or failure. -r, --record Record failed tests. Using this option requires the 'recordmydesk‐ top' application be installed. By default, videos are stored in /tmp/autopilot --record-options Comma separated list of options to pass to recordmydesktop -rd DIR, --record-directory DIR Directory where videos should be stored (overrides the default set by the -r option). -ro, --random-order Run the tests in random order -v, --verbose Causes autopilot to print the test log to stdout while the test is running. --debug-profile Select a profile for what additional debugging information should be attached to failed test results. --timeout-profile Alter the timeout values Autopilot uses. Selecting 'long' will make autopilot use longer timeouts for various polling loops. This can be useful if autopilot is running on very slow hardware launch [options] application Launch an application with introspection enabled. -v, --verbose Show autopilot log messages. Set twice to also log data useful for debugging autopilot itself. -i INTERFACE, --interface INTERFACE Specify which introspection interace to load. The default ('Auto') uses ldd to try and detect which interface to load. Options are Gtk and Qt. vis [options] Open the autopilot visualizer tool. -v, --verbose Show autopilot log messages. Set twice to also log data useful for debugging autopilot itself. -testability Start the vis tool in testability mode. Used for self-tests only. SPECIFYING SUITES ----------------- Suites are listed as a python dotted package name. Autopilot will do a recursive import in order to find all tests within a python package. ./docs/images/0000755000004100000410000000000014002063564013440 5ustar www-datawww-data./docs/images/favicon.ico0000644000004100000410000000257614002063564015573 0ustar www-datawww-datah( :w{?}A~@AEFGHFLEIFOPTX[\Z[]`^ddfifkmgklkpnrqx{w{UUUUU6%%6UUUUUUUU:  :UUUUU' 9"'UUU:!<@)TC:UU -(TT80 U6LQ$ASM 6%TH BT.%R>G31O5K6; % TNET,%6FP/2JRI6U #.TT=+ UU:47&TD:UUU'?*'UUUUU:  :UUUUUUUU6%%6UUUUU./docs/images/ap_vis_front_page.png0000644000004100000410000005345214002063564017644 0ustar www-datawww-dataPNG  IHDR~ubKGD pHYs  tIME-MtEXtCommentCreated with GIMPW IDATxw|]u9wݴi&t3Z(d)PTD" Dqb ldY(-ҽw$Ν|~ܛMPоG=ϙgYcB)hH m!""""""7kӰ{p x>N$0׃9pp~ElXXG屩VI^q.&pY9Xm~?Z 2/Û_OVAhB'JDDDDDPf :$.P~ExzlNa8>xy!1jF*"""""rXC /8I14Kk{h4F2,yC~a~r~-KDDDDDp$,1Whincnu9 @Un&w v]eXVss55TVV%OMX~qY2xh눐L)-\ Θkl[:q WMU-P6$?zq%mEvvϲp챳"Hv+˶m|>mmm< ؖscH&X8;?uH Ҽ{I,*Fg^~Q/~wc_ HJU@t]xr9qx-ghe.Zl: bIXx}^SDDDDDp9(ҵs;~f\7[\1O>O2<==&hp%`h߹'P^)Sșe{"uˏhj-^}5TM<>+$:B9v޻+cJhz9vI-殝.Q'Oa M_o[m57d20o7ڑjggrspG~rX*;v>`YTzC$Nd2I8&H9-%>鳬~m)2# lz?3;9sʹ :Lgy$:85+O,p 9˩!?~d)az7N*ɗW0pu]Ʀ~Gv}uLcXE^= N,");[Tv65řnɫM\}j)UgIœ7/=Q'əm  rǘf_Q7_ַRldz;\? J- $7Ȱ[ """""r-˲h'3n-ȝ;[*tzHsj>񅑄ؕ.ӳZ܆?$v =;k|~88Θƒ߿ZUs(""""4+yؘ`btCֶd{ #rz0`d^ >W8yEjh!v׮~kK{-#LYVvb024ƻBpV~.8~@w4D`!cGrצ®xɧj`.lJ4XV&R)S {(;3$|-px='P n45iɭV}#jyJoѾQ謱\|q=>ƤC {0[aݍinj곬9)!]3o<^j>3O1Rvlpvk<2ODMa]g |[wYN 镀=8tUe3䨈K jM?ʚ8=OamLeW=~|~&1}+ z&q9ҍ<r~Mρl:CeC9d4PtmoZ[jk?[k-Gg9I`ΉTmOvqA< Ŷm1.kz1eڍA#ρ:1clܕs3 1|w8j<污5i)°^lif^eonmlZj l:jʋoEYP9xy y3/gnxd=pd6G~$6t4 qX=~uP>dN316<$ړ]aT'Xؓ슻lYmB8>?׈hPȹ)cZ\pHduۿx[1 (%';iߟۂ<"""""&c ։'2ODjQ;üε%H ؐ=Y4{B>r gvTPbl߾۲ޓ5J"( ۝,͵X ̵y$H{x^MN1'@d2<^~x1xiHF#,"'h E{oLU5q" ǃC=I`Qk8~mM$CEM:o_g0Ѧ$"7+ ޿ I5t=maҷUDDDD]G+iz/G;SQ54&-68~^y9z,$͛"'7ߏ#Hiii?9]Bc4N h=qoW:cszNɔ$;`v1,*rl:T(K~u+h {,߶2yǵ:t!)gDDDDDwJx==A&}ԚuRYmx!>KUVchmﵬP(h;gH`96+CWYjDDDDDDgPDDDDDDEDDDDD l("""""r8CZ EDDDDDsTDDDDDDEDDDDD:"/PO]Ģw}{mz/d 4煮I}EgVxO~ApX@Բҿdt_ǪC2~nÈQuDDDDEim471r(C&vZ:P80`Vcfщm~Zr(/!"""rQRݵTx=e +IW"ZLj>πVg,;II>dhW.W8Q8h,/Dzllc{Sβz$LáIn5MTIJ$q]1ƥFq=HX!CqIquk\I,=uNhᳰoH&igtz'\mY8 *"""p?[l! R9\E9%}m!Cf0x,bYV`jVjuHhz,^KItkUΛ`j͛ytY{?{GbL""""pPWmTՌ뺩GnZ+Tu޻e@0HUVXNCC=EEo=t7lba`Nxuހ/'P> _09E}65bej-,7]~:ȒbШ_H<|o @b<FyP wP0 z,|pfb(<.atoi rm4.ypQ;auQ7y:<Ӿu Lsz TwC7c}Q8eW͆Aڲt;rMR?5έ'/_xO3'3 f:Cng?naʤmuq\,uMfʊW L闗rVpbeڰE obXe9sɉ7Pyم` .@uMf@9O'~;ω^SԬTDDDS4,|5"5خEHv)ٴe#TNKpOdvXΜq1$5ICl ,8<}nej:wݿ'o!b xy}cu|fGXNhp !mJV//w\a3N?+fI ePDDDD05@FfCċ?2XԖ/N쏟"oPc(p8g2~p C][^dž!!2YR2Aa=YSHp8g+5pe|8+ Nfd<ª/Wmb͡{&MaIy޻yq4tC+ v·f"""" o!0:7A9|I4m[ƓՋ6so(CBѷ{Lf(67{:ӿKN3^v r \pGeu~/F͡qlҵy}&Ϧ;vT_@8& 8)+/gۯce A8Ah0`,0meW3(†ukzO89a1=gF&&LfJU/]4tofnjڴr36 zGlu\}R@'?"Fxz5{+9W]G+}`bl}6niV'`y_ܫ}c/º&@)^ė>9R_myoXd 9]9_O`2__" x Gp_[/޷u)SrcS{yvU _1%А$G`Km _ϝTIЂ{yVOsE0sy?s㯱3b;(>qڈtS/Mw<ҝ1cWr)շs{1߾Omί|g?|?mGY]on>>ߓǗҐjfjﵶڍ{[1RkƐ_@NN6>E#c1(8Iڶ&qg]Sn?:̼;:J g̀Sy߄ñ:g8rUßx!V:p!>wե N?u.ƫ7.G(6/5q̢'N5补v^qiZp# F5~O3jԨtȲ2^Rg.ϸ'#`%*p׃`\ 8 ~0eUch}u)^_+q l΢kWDDDDpw[hKMW347A<%h~2v[9&ܕr1.OqBO/sy7|pdfTV^y&O،a7`Ռ8\ s{YȲ`T%lZ]^xn>[-[mSŘ8ssN姗B1KV[H)\/[FzTx(O^|90{#9o 9U1y0:ĢE;9fH:@0jLȵ`|5pKhЮ-o3L.Rwgר~f"l/zlnˮr}/r08xG$p|_3N:w,F";X:<6Ϯ^9n!8.n-\v޿{Xwz}ULd_WL7s\ܾfApY-2u@8H$^%k[M`Ԅ :-f IDATTp3uT]XVFqIe:v ʁ7wڵC.uwDVpHrnR:nh7M'A+}j;vβ3 pCK5Ac h8zv"t EPhY]S_ElMby=vfPDDDDoRy5&5p|}v<9 م%XNaͬ5cWlY~,_?}g%5׿cAz}O|wcYc<q3#RRt7=0nSN8~f88Fw=j6~2}M`F’_pe{U MN~8d2(*gИu5rVLՍ@œ8FJsVs  Cj[V#WY|SrRelC*[IX}OQ SlRtT>8N #%ie=b-$W}pߡ>,y~S}LRNݩ,F ]2Z m6}Hxp6']gYX\Xp6 X)OK*T04+Mf"""" cRHlbt8NR!|eEY;d9yr.muIϞFRWgx ^cm@o?mij_oƓܱ&^‰^EԤ5qMW%t,@$:>E2sjȲJ8̩@~XN/lxjJM =sÍ}' C8y[O.cYc8n\!+Ow% uk7V'7>nQ G^ձ|?s&owSϬ6wQgQv,Jl }wm7q|t4jʟ+? ƒK&SmeI/{~d 267o[W:/%-d1syGQ`&? *J)K1\s_nW;aO2uv!N==Xltw2+ׅ]_H}CY&iI֑gY]8ZxaW`(ؼ#=SO=`0`[6ea,yOrgz,nVn\$)Hٴ̬Hr( h\t 3flN5 [P> 1y4C zȴ,+-4)R9/ t-']x4a%jT{/+Gaǥjld2I"dB>r<$;H$H$]ʊ鏿/|Q`{=L""""" UU3W_y/<Lj#)+DFEϙr(סnwthmkaҗp]uv6SŘT\LՈq1p:kSӊ8#hxRgPdatΛY~`p,cy|nc^/~N= 6izs8乔@gUE1!zouϝ@DDDE:E&5S8ՕKSYXy}}~CY$d"*ʲ 3gΤ=B(ח孏VZZ:]}_DDDDE;P& LLzT(̌ lZV] <Uhu6fJ(IoӦFB.¡/UVcmj Sh V!鸗XMI4Z̖x[g5TUBlQcO$ymɫ&tԤ.=hew-zϲLW R&Kkk+5f.ajind"#zJ֯YE{Gǻ=۶ʦfHF """p(""""""+5+Ch".<7 CPgʪI$:)"ps("j8jDCNkK kweFGnnN¡H^zYzHD'CDH$SW֭[8yNA|td(̲,\ՉQ8CwAtwBE3BDDDD+ڌ\Ky5Nd&0xtv{\{2EDDDaU_}1WSC?Kl8./^hwϿ1c˗sg@<2XXWI05ay :o~z yJ/{fZ;^~ nRnq<O 6 jnVZy=rh.S*cqm=ck}aftDD䝕]Qɶz.v⊯QO_O?s~S`7o>ǪfY.]AĶ_bنZ 8g1r>4֓xb,t_+6Qa PŹ\ə^:?ɭ߸g7˜Ʒu?!x>}g\ãC1l6ER{nk ʾ*{g…z]0+)2!k{3;| -Uz/TwnS&16:ȝS6~*vn8Ά{bEs#*xի\bNHց 3T3|gX2YٯScW@_0T8s2}->|YVc|v^9rwkW;Dؾny+?Xrw$1aEP@;:Fww޷4 Px=lP1Aݙ&1]ʃ7sOfbyds„Tw(b>b~1f}j;_V¡GǦlf+]y:_8s+7=Wƒ32qƽ[?-#8.= W_)߹Ýw{o|,sQP>rn`pG3GPcdw 逳t99 ~&M9_zYzHD'SDhg58 8Ͱ""""""" """"""p("""""" """"""p("""""" """"""p("""""" """"""p("""""" """"""p("""""" """"""p("""""" """"""p(""""""{'I2u]CmضP8.hh4FKK#xB'fY~r}~,R8H&ĢQ44PXTD I9"M{Cdee8¡qRPXD( 9 deec6u W8Ԁ4"""""8ucH("CZ*1;%!J_wCQ8pi߶741:EEDDDкy5k Cy26λ|^-Qr]~1^Nϩ]ZW>}J]RQEDDDD-kpm0`,KG9TiCy2g|bZf-]'Dѐ1a(yO&AymVcOV!yI`)aQGAau//"T{cqi]2/YK̪泲q㸣F۸v?ef\N-_^CSH-%L rV!rss~rGi n 홥z(`䘡WP@0Eӎ/;Ǿ愼XMDPDDDD}L(pC^(J^]Mvkӛ,X:vch|^YŖ]E]GW?_Hjrl^2NZvl^h1zj5n+x ڕHccmM威WWenvΆ5iqW<̓=Ǻhy0DZIfU28òxm\;DQdPIJ@92ױh~x9c|; oWXlǔ2,9_ ̜aʥߐJEپ+!XP!sͽ(WKsuD3DŽISz^xA49DE:Y`>G:N'}~vAm5I->]])_>"\l7>lJo[7=QoKw]xBvyP8`9@ʅ-rFWZEtTu-o6-'睷?Se>杮7OޗelezFV0u vmg&O}ᅮ7pkyg=)j]YvqXL*KoTW)ڒ2'_9y*nX\FU ѷ;;=i>z{<3<3ؖƅ|=^d_fdW9d/n>Ȫ29}i&;bf:GDxP}geE nh?qufce>ʷ;gPn>/nɯ3QV17F284u`g̉Nڴ7/?1#CKyzNߑwUjC/'S]̉1ǎJloK7uE~q:vkyײj\Uٰ!mȖ~_rZݭ벩̅_Mɴ oH_ß,B k4~L[lKE;-~[ NZVya#U9o$7ۑ5'ey|v%s9?^y'MLgq?X㓿8 z{dռ82= {iH2{1GNNehϦU#c69~u[vZƾZ նl31>kZэ<[[艢;0.|}Ћi|?ޜ,XXe|[<(»ZFdm&g?K>͆ }p$}OW/o̒+kiKj=[7r|yp}Qz/5KiO2ơɜe%G\<}t?|}ǿϓv;[N뿋Z<->˗f2y&ʁ?,UmTE# { IeOcy-.;*9q1SOd,\ѩȶg-9_W2&dzgM;_XjmI9*Uaqg_<ۯJQ+RT_ڋ[^E3'nO#R ?_=7De&5: Of^\9,'sLjY]krp7Xr`LRՒޘ>GNl<9sBlB KQ_6Vk}lm}Uy~/#yukwwOˍ]Yezb"yԷsffMz:RM]ȥ}#EѓO}w̓YUf\FoI_[7ձDwez-sԟW?(1؂Miy}39z>٦ ,b׻}cV<37>3]ꩃ98*^KgUO(x|z8tLϑOI9lRUi_ݑ|u:!;ǺӞ۵!|.&ύeU>7A q2 MRaW.zppwۓ6|4gyZ8\ؖǺ̌͑C29$]d^3H}ރ#z6I[V n͛W+D=q502ۚ7OOgq-cdXUͨ)T+0(3yx.׆m| z7ecطMbJ[Hv{!{F:ZS-z<\:ӿqO^{~{Ix:[8>*DzkI*'Gs|kNCE|gZv O^\)F>o=}S1[-ښ5/O~튳{=~ёݿ̛owH 75W4ǥ2O+osȚ][nw><$դH=<7x=U˿7logSnS~\[W{;53kW`8`Y~X﷞|z-3fcTwgi\*RԷJ֜Zf:ߖ?pvb<sU"͓MGui8`y@&N,ù9Hh0R-^.[xZxCrO+}sBdq_j>Wx7yj*z\Sǘ=ih[\6qc~b%%83IQ5O\xMUT)գɵknNܯ9QK:ֿk筗iqN+@ C!v3ͷW+CYݓ'rZCq9iRGB޸>d[8R此%XX~g2[26m)˲|\?jLD!B!ľtC3d}[UWW?EMu%6arĄB!bXE("--8̬l9gS ,,KJB!Bk40t  S^m޼TUUb!9PB!B/SLa Ɩϱ-Y !B!~ vw cۼy#4#B!B**uu1TEC?@8F)4l<t]NBB!B6 &6JY;}I= 8[>_bunaƪ%qvZV]]#ee=())!=- F(ukײjjRe2h%])B!B`PJ$z+74zI7Ci  (u`US[?_Bi, ;lIB!t]n'|Y?IY?y:Wp$C!AE]F:eIT*Oq _p'⏱W&p˧R!B+ ZF^e7iZM.)cz(*D[8 HMwՕleĉuXRVfY&PFYY+VAY֏Z֮n*7n`(LˉbךL5 3H@i ɜ|m\wJ~% k 8ЧOg֧0 W;Uln1LnpzMA77S]%ŮSIؿ X]>B!B RfkQ=pjLs ?ZXݞgYFƏG  r ȇ~KPʵoM_[FJf*ֹzo=q#~9 !RΐvvwV/>ju~_TUrC\߀wϯaL"449rs7/Tܿ9yѥB!p%"ÏG-6LiuZۆ)*vaN?ӵkWN`S;h&N.VwX̙[θqc-t?XE+5Þk|6gC}+7?w3њ7*w7N"=\ pu)S?{F&U!ʏG4 T/xg?ZKRz^'_g&)"/݅i>{^j3\L3_O[=9ߝ2챓T^Q8t6v/M>HٔrXÞك1g_KYfLÀetVrTЗ#.sQ*ZFMmmfpR Plz-widad4瞥*da¡U|&ޙ%v$G!F3l&.7_K-v|އ3g>f?t#}ZGޑ޳):z2e_7]#+?zn:*_D p[v5?#J8<ɬ|K*Hv0&(=agf ];쏑VDo*63\B!ۯ4`4eXioKy۵ LjDati4'şiY\CZseu7d'\p0BޞWGTrq 0ԬO9bg te4zQ2s|&+*7xOXw锸J蓥' zmʶ5s!? 5ʘN1iD `ͯ0G;0ev&er3ᛏxwXYw|ZB!Q!0H߾~okGtd ߬BXzY71t[lЩo 9_VS]ӝ4F/ntXDiKly[euFH鉇ĕ>r)_4DN$*t ,,,e%%hٞjʂpp4uf$V2uUoctV |2Na8lŁIomtlc;,ǎOGq[AjB!qNm6> 1(OA4`%'qb7n"TOTnٝ`#M6۶nSڭv@5ضukʚ?6չʑEjTS5<ƍŒ7^Gpeүg*Ϯovi2#..^UfS$;=D?GϪ;|XTa&N[O8n6VUYmr7x:p5͎_3v؊ۡ4-n!I8#Úv0j5x^f<׍ 9hh3>jOB!?v8:hWp^hF@Y8MtYch- Q ZƗ^d;VlFuM 998N3M|T֑鰬>rjk-3}g?ŧuh윻sR:هFދYmn>:Myw34?Z´[„^>??Gogy蓀-² 4W%3Ƈ8gtGZo<jVl6_~;u Z4m-Z}5=NA+߯sK;Iu9hR-JDu,IUTz rL*67k%B!uR%7iL:MI2qD@T[]N2NUU5(eaY͛R`+W,׋k󶳲v)grhe}TNeOa~&BvtiCWjc7_pCa[]ǻm|v6V%]Isjup0߆pbvB [D~f*).TF9?YiyB!1)>Jza) syVmسh;guunY EEE84 @M6kORVGBal60d&ۆFTCX955"4 ͕@Sp6T~l(l4>,pIɗǕ!zfp%,FZ &+QyMEQp[k˃0rh&%a&\hUdj"lE{8IN<&V!B!~Deu8S:IvJnxX59FQ扄 :^RKJXѶǹ}mѼ82 :nύwZ~B!bh p1+lˆ0yi躎frv^܆N i[SeݮEcǑ>; !B!DPsN !B!wEJB!Bv:ϡB!B B!B!p B!bJ4B!B S*B!B¡B!B!Ų,*^oOu6it),B=B!?IC!~a-^ =#' 4 )մ ?}jT" QYQA4ٻB!?͛6R_WKqI7 Æa3O˄¡R MעG'V ƪ+HIKP^ !B4+b%Jl Rk:"Ph%BEhx$ P*sqR-Y$P!B¡;h-4 Jhl;jA*iJE'" !B!sPPN$4 aIN!;';RK׎(ZB o;%t'Tʊih;7$4i*G63Kki *B!s6_墤{)))r~OY@M׳qFf9PBʲ4E4+b?E;k i2VѼ *[oc!xa#Z'ߴG:ʖ7B!oGuJz²)g i[? 7Z{WӴCt(-ŋ$+k׃N,XJ++ ME 0M&}5X gwǖg).ZPCôG[_ 5+$y:-q˖#*݇NȲWHfv_/;<B!^nŲ؉kNCa{NsuAS)4se:u_M'sTXϪijR޻7HA4[@A*BEkc[(KEZ 8JU!BaX '5ZiMk>э V`BRAct]A]?3 Ӵt R)+~q ïgɊ h,)82X9{I:dRUl ]-9VRZHp8v܁= )}~}4+B!aD"\R d[Lf elIa~kڶ9٥phņQ``E! mg'S)ݏOg}ϪMt~Y]H'99pUKX{LϿ9ШjGx|sIPM:7 B!#d_h]kN[|$֤X=B?,'{pе(tXsw95Xtrۏs,,Rx^"i^t xXXm"k.aҵl1?3h4~2=nG0Isvcۗ⯭cL,e6_fB!ݪ9l1l>Ma?!hs4R\ĝC*W^/3wWXδi83ӧO4-5ʊU6ψ%T ݲQX\BzzˉiX"NӧdŒ)Eeq̓Р@i"ۊLia .Bڡp-nmxל$&6'kӘs=޽{GwÚhHyʊ\:ݴ UBVnI7ve, K(dy}i-6k`B"V)!B_aJcKUsShpgKH: 藻W}̗Ǚ,rft#}FS?gU3,tn(97CQm h;\k<'Iȇy 6T8.'6݈:EDZTtapRoFBViD[) HRF!Ba\릤e=[F̄)-5D[ 'ͱiVtme5gi'rNPeַLyԁK硏P=z I"X1}R!aB`$Cֲ6.ŕ迖҈53u3)4py0>辑 5y W{j.akt4B>B!6+M݋ 0 ̄pÎP͵Jш{RVs/v֏CMW=y ,MNlŞ}Y<|\NCF>Sv՚o'Vԓ!'Iڌf3®hZvc 0H9MyzmDBd|r P!Ba[ahJEk=BX8艮=6嗑ii,LT2#ˢYt\v{ T<8}|T{05ݜ{\4Mk3ES CDҡSYķTSϖa=2u֮_tO2ӴK X1k&F 2v6;<: hjnV 3ҬT!B!;oVjHMFoTױ2u~ S`Բ䣗y21"UYXVh3A6Z[ȡgۥ(Qe eTy2_/?3cf)BJ#4򱦥Jw-CF+%,Pf4tI)ts#9ֲ28ҲAѰ,XuF:ٸi+UP eG;F>?C!B йfH+m23ϿK)Ń9)h5MTJ5DKEk!"agá<LT26l?Ley ;5tů_(h(&ihmBSmi_*Cج/Y"6f'hvob⯰׭ftTXb3"ɓԼX(!BH8l3 fYњxh?A.H.IhULcqߧqbDZ$'FwpO<|jEgБR]؏8l](b"9qŊ!Rs<:il llڞP*׬b1>љx IDATyif#33KQQ!Z4A&&.y_Oz.G/6M4mDVKy,UM]eVT oj.bMde$ɇ1g^Dh !B._w9TUn'%%lD[5dBYXΎS-|VܢV6x l(iez<‹sY)ZtFTEuU%.sa'WY4bDֳ>|}Fu<ǒ50)9]>=iS&Yf[!RSyIOOo߾ˀ4B!wAAaWdȰᄣ5a4O2O25Mz*)ħ|߫W/jмh͛fgw (,.p8RͳcA_.ؓRzX0&RoaB029)+ k -ysɥѻw_tg^fըyh kw; 44_bY&=wk3>ěy"#RkZE*t0A?' ø GoSy-Pj[oW@VV&hzӑyB!$bGĚ+;g>O麎[v9{utލ7)TONfop@(/65v{8q?}l@$mՑS5*ljߙæB#YHB!{S8IJ^#(46o"Z/ާeu|dȨ0 cYat 2l8>(V^5HJN#&Q%XUF;8@-s4巸S@!bo BݗG o?qc HmԤps9\О E;)cn) )gѷ_vYh^vѩp( 8xC)*+زy~M0lIٹddfmJEnpj_dh?2ʙs!NM/kKM!>xtn_<:.)F[Gž~qt?\;[>.ƱsN?7*!%'ƲK{gө2/ZO?"v;]sxq* YtvXn&K/m8NB 5Ulۺ: `PR!M|R[`_˩'KŤsӹ߾dhSZ".IN!ڕ\D}HnoU}uD~:;=n> KBe>]Swx~6M0'pÝgSM|^ #PSI`+M)Aռxk*pr-"U3xbk|ӑCsɱGb-iO 4#8.bE-[dpYO?d9wyW.CmPRE/@4Ft(tRHn~V,cZ~jk!{ltٺ3^yn2qq?ֿY }h춅bcdaZ&f쾦c[cΤ 4{69ᦇpC$,wsVP|^a2?qP` ]|3g .A096̊wST쁱#=~?mv\ϫwk8ν| yglz4YDk /1LCe{Qϡm[7mjCǴt]Gy/.:2xQgO흇 8`}ganh&z2s{88[8 yɲ*B,zv5nn *?yn*6;eqgstTbͺyizeQ J7a3YWt\ŭg)$X- ʨT T<FFOƜz S_ =:7yAseI$ !aͥg~$nLu*z-./}y9v  zφ9Gl#:~{Aހa p/#Ip=8ol|.ۏBx{CNL@.bGp¡ ۺe+Jw\Turr,bZ8 {[Xu̚ϱars •ay1\p`k啩s]?:, SU|*WW`+ }'o\M]wrZxxY_LQh)o<?]?,_gk\qiR-/$}/|.\9))B-~/Y^L~-S<8ūxXlMoRUl βBݺ9w6lV9vrOS>6wwZ>ZAFހ]4PP;=((x)c>F_[l\Iw#HOl"jK&ەsn~-i'}MLϡ9 P8UmaX,6NgMo.t2%vV?Ⱥۡɔ'7?&FsX ;J:2_2o.\BEx0U񱉧.?ZQè6au[X/[FE~xL7nynXՑVL;)[!]Rc_Xdȥw._U+(?Y<fG+K9~m4^K-a? eUz1SbX@26|8ܫψ(:e"jľnWUUU1o|sN@r*OsցȪ72'>:eDIcY (`/((vɯ(s=h7l[H!,~1᳣M7n.\@CCib RS٫={qB!phtwT\M*Z݀R e5ayTէrRRRxb7whgCҥ nZ֯_w aZFGkI^L !T-`:V(8433ut R&-P+᱄er44ʲMXQYŅ'q#IO- g*mo._O>ðaaL3gyOzG'"=!5ݣsҙX`*. nQ; z4E:#ys8jLpEoϧ_7o 5v4+9 ' Gݛ"}L[d 5 =2ԦL_C aeE0Z 4M>|$4!Bq5WߴmBr~Q|4P{F>oG(o5,ѝd|;|Κp.$LY4\9?N gˢg&x%^{1cA=m|̯O%O):7ÑMx}&Bɸ K"}&1O6Wc̊o);EIҦTkijj"/K|E tPrrs/Dk-2-,B) KE>e=w{6_K^fX^=fzrt!B=UFuv3Egȉ7(]2 jBgq2׉\P_~!Nu))-#++P((E BDo +րsj&lIG'BP_OŶ-,_}*E BR`jzxSѼ(s"}DW`+YACAb r(ľ4qFBљoR9BOtb_ m,Rh("urAH!i(?o"GKLn^^w&:EZrB}i B:vsχO:M{/>V2Li(-ac,Bs~լYF9 x0LNI=āh*"*kB!ľ% Q]UŒŋMdxM"5=I(m[X_Ga ۾44E_L>'Z/ ƍc…L8Cs&b%kFjhU: hr&o<Ȅ>G~tۋޙ\$Mvs>יJ!/)4߄Q_i8P 4rߥVXM)*.ه~?}9 ۄ:Bsa?UUsRLnsտ࡫cVCOǦMcڴ'lzc|[yǪe-{JwB!1۶nC['N^>k~yN˳,mju.[gOcL.?\|IwQT]~fv齐RHHH]H]zSDTAE@ H"]!!!^vglB>"Ιs9#x|Nd <sLj#S IDATsԭ]=Z mqԹODٲZԂ(y [~A6g/8pCxrkW~pQYfr^>> /mb\_&oCժoDzE9"ZFDDPF@&Ĵ=kѮԺDv0ݫz5#Wu7Vr2Qѕ  =>>/ħñibuvH 2XVP3q-:0d;+[ٸ|HڒSMss 4,_Da*j9#WɻkF&YO^|:PӎzJ~Jr 8$ =αsAnbQfϽHhl.TR.0wdzKb`MK>a|/fg^'*By cՌ/Q%,rK>Z.8qvX;Xr-k a2"fJMg[3a)" hGYÇƳa2(&L&(&G(XIhc:cbo;QzȺUXtʸy'~ V x$;O=?aorUӧ6.DHJJ⧟~"11`Kbd\=ǫuBv\/Q]ΠWah6+/)7X/#kNʕݢ+"=}ۋtڋF(W}{EΉ;/͓S% >-*P*5+GVmoK 'o;|P)5[k8]1ըT%N=sWst܋ZIdXOg|6U' a+rR.XatKiM"G6.xOڌ =XO`-7-΄ EWdJ<Ɨ˦pXK#@(CC$7%/r2-M]$%% ~;xժܨ: 1,LdpT"v!,/sXu9K Nߘq܌H&Wdxo>)0Ϗ&psYY3jVV|KVSoq@r;. CW h}q&,r9y 2.N߯#!?K#"2{3|βIߑz<7e(r{ce7h/`MhZ=e; EB 4|d\X3׻q6',PomT_^g۵q?3U1G|LjU$X4JimBم'͛5{8.VzrxJ}׭L,%@U;J}UUA /c\}5RTɐfygJ%Q-W2k{-Ng{VE5U+¯@]BTm<4xTθw,>[í v?cfsW|]'IG`zأ{1£.pl"S 8|P},#4~6o0R*m #7Mfª$z*Ԁz-3WihҰM=ض}?gobM~U3/+Xf+'sѸ3xQi %(zBBf&;;ΤFЗ^" 6Ӻ#$4@ $WoaoBsY{&n a FVP S/|<0jDj9 ]jKH]:OrN?'K#Yrpϗӗ;m۶|ݷ-FAUUΜ9C֭GQ$@dXAU!|cZPvR~sVS<{&%jzʨw XjG~+4MqLCޢW g;*LuQl4۝#V%tx5 cM<(ӏ =c,b_¥j_~Ńς_zW*TF 7_V\ڹ")A¾\g4$Kܨ]P^y6%?fڏ:'ʕr :|_mbڐc &zTMC]9^X5J:, ɠZr[%a|-j|1?LTK5UTؽrm֯g1g~M-X^XG "^ܯkyz+ARVMp\v`jԨw Bۛ@M3]\~exSI*zMk3|i(ɿvMMw$ d ̤Zǎ!55ɄFər /\HEC漵yAQܘûF7vgEoӶECG*' i"x y6eꦶ[&rIqd>z=<Ϡ.xwq(JRnv2kwP2 ΙOl橨pmg,gN^.X`l95i IdT\5 B)&쫜 ޥBTZ29w܉ޛqpps.e ̸RMu'oc.+qq\rCh'G^|/U[Ȏ?e9:PW=)E5<ԡ[6GjJ V{{nJ…~ ϟ#acaZ@K+k\zF6y-̂\Wvdv&2*b̍M0ܸ3g2@JM7Rh52HMF;7ҳkjU87(n  Lw J5ۛSTcҭ-?]ǻq+" {7 FbnW֐7j듗{8#=۷6b0Щ Ɉdޞr +O[7cV|}}q B l?x_l%&9kP ;!)vPe?b;dywNc,l0?iA o֦{Mߝk؁ _Š2|4yvqM2(e KkEꗕXrkiAy ɧo^ε:{S!=Eho2s)%Mq|HM*''jj**TEZ6/*aٙ7p py\]][ LN--L|qqmXauFUAzHH-:z9M>#&odsKQxKSțb_ '󎲖e_ldKFxA}tB<{"WS Ƕkhб .RKUwfe|^U+`%,WlAʑkTTɁ Ͻkv7ƙ2uV0Ztٹ.]-Ϳ$Nfaٲy0Ft`~;@d(XbL&9t`U@QL8o Ʉ=G?P*jB]d08 5iq.Dqq,՛^`բn}jk Hz;ZJw}OZY Tym1-MM(|JeLZӰ2z1^ض@Ԁ|:{ {":a~iZ[EBT*ջah򊂋+\s@IP-sk'@4L&aV%08sSuh,88:QZ }|1sq8p@~i:vNw?27R!7_Cn:珲s2@˷zcG)9Ral> \Z3M,Nˮ )sAEg] M x`/EltHG]Ո{"I>Bz0qh4پ};=FP*VԊyv|F;jg[5s罥83_gX Jfܷ̟\#{[ҶXWиqWxk\dESÜ8Ss2KLvL+V/ah䕛>6 cYvbȩY Zx=d?s. c:d8j{NI8v>trAԄszL.?o)`#f,n&+tp_٘y,\w;nƓj7-dZ>Dr.HPyuTs&d.$\BcH=0'O *=^z@!:f-'o`2QF?;P~ItO+`(Ox6N<Dg] &]dͫ OAE)1L+,o!@DD̘1( BMhSv\y?nĠIG4MVwL2CjE0%S8ڙ!M!!' wBi8a2?`^ d+2aT%Me= VG/\[rs&MXtkf Z|ј;؂! zo3Kd.AQa_LQSVqlM`wռN|Ld^]Ԥ$zI<}'-a/],NC]xAƯ5CK׫@|8m)oί+cqc$gH3/sY0r&tL/+k2g4*y\D#lf`F9(U|+Mz;wƱXF0'ϊκ@ M (ᛅm ̉$ \GI2z?ΐ!C;wv~(k5*lh;J۳6NT}ټ9a\j){eԡĺ6OafR*dɫmMΐU8a U,ZZ : |,m|qKHXY\YCIүPJPj2Ofҽ<^CKj/*{Zn(PG&n;Aœ3OTAHG$^uU|B[Тj-z=)eS>i*OCZz.ˡx> ٜr\xdW !8A6P3:(kM &<#2CCs(qsMΝ;GDD d_%HAN-Jg_v .uC*7ˁ6D*~wد1fdobZzɹr8kiOn,s9 )Sx%فRn?4D>C*?`Shމ.]:Ҹ;Z@ɼI::8S35Gsj:MV qf ~Q,w^axFb P~ΕCun2{Qt?c@ sBQܴ%^4@؛@_4x,ُ@'NÈ#a^=̟YPU0y&;aO9reeq\O`XR%n+@kJh42(&Pgf#f7]`m[%&Yq1Hh]Z|e7.ۃQzLǘT6wɍsIJQL?shQ1@w]_U(TzYv.M[v:ƱFh|^"PD B[;jiР={g6so}QHU]LGyOm^0TH2gd+%3?&׹&D9\9R}:ƘKD^f:;O!KPFF_zFn9+;]heJ7@'Z'iA/|z̭L ; dWjj{OЀ&:2`ROs⦎czӲ#g)o}}oy#1Fn`1n|>qߖXBYh}{ J*@ PoYͣ@MRu4LhX9T,^͇(Kjn,IuK {nѵ Bqq*mq4D{^ÔhIdm ,%:K>lNIEB]K*(OO\KډEUPq6VM TO*/6c nH""m"nᮅEbɺDwjP`+eC9}hgS?ܼHDSY!Y+X06(!IjMXĢ VaAكz#Soh @ dggcoo*J~VD D[iQ*mъ^''LK!5ф$ɨ$KyPU%PDD]"n#ۃZ3$Udnb"<_aqjwΥ2kn z 0O>u,|KM8Is2/s1qq)-S${OB*ԥSh/L=+|, Lw[fVsPJBYE|TTշvWTbXǓ+fQyC 0Yly=5s(ġ@ x<0`6KEKFsL~:Ny?2Ne\0P BI w9IM&[SgG.s߸ It*mͿ=P;`JRv,16a|X1J Js#(Ubؐ,@2IN?,L"Q50\%*UB@#,ŇHvu̙tTkS#A% E qc5?3e|wk^۴6MfnBۉ!zfY;.kveٙ7\&}5Xl}?nH|ZhZaC0c%Uj{8%mL&`M0eiEl˜3'f݁d!ޙRȽΥ`^.eȸ7,Gږ($RXXֱs'4,'ÝvK[ ܼC @R<_@-6kRH:8c4ZT (Ӳ"YmAwYH+ID8~;HaϷ}G'U!qNn8a(p4xT̰wY!a*// 5¡ T@m 9a[j5.2h] ulR=NKX) )م(g;'1h* x՝u>SaqyJ*d$w4 VG/\[rs&MXt+$dvOϚ9\t˜9b6>u挘AyEu<ИC6]XDE}ֻअL)o~Db>|8[pt&bZRsxkHs3ϼ;Hy罃>p6ϗӚf}4vk^^B(K <./w(A)wywLI|u]K.DLDqu.\]K#ɵk:`o,ߞAZq1fr0Z =ܺyϏi*!YH\\)~Mʥ ixrR$4J% Kت,Y>daF۷3x`jԨQ/lWZ>g+Y:c1ick4)blVo; ShkޜՓ`u8u[FvfβϺ7@1v&O |v51 43w%l8p\j){eԡĺv8V߆?.-Oatz)8[+\7 {Y' *쎾l"=Fѷh]pP"4$2)T}P'Eҍ4>Y- ? DV̺ mLEo DΧTONBJ‚ӋBE,oNI~6.go3cfw~)[0^ڱvOj(KTHʺHPr,kX J1Ա D ܯk9z[oҔ=GR䇊$iJؘCU&d(-Dlqן \tb,;L"f889RT1 m9Æch[YQ-NTގT2U6';K~FQdB;QYslt(-PJn[?gUZѼ Ȋ>!@DD̘1( pPS;0|¾o~JD#.ry֎+T{H q=mnj9vdeQFtdڏJʍclcGv.CdsiRf#N]6U4 y^0Q6%|wZ`8r"x 49| Fjk[AÒJhf7P{,'w,lў\T+lƇϷSyOܕ.mjhȹ;FnӁ\Mw=GZ*xQ@VNz~ɪz?-",ko?LSՔ̅I .׸lڶiGPy+Z%) ǯBƅw齻#!>\%_*n&(UUE-@UKؘC.|I"~pMgy#\TUb+.;)ŝn.% (y/`54lF <;n^. Rtsx-JZ N8Q HU5bCRjʪl) yCwwwz=ǏgȐ!̝;Q؉FyjGy=:?I@汕:ٹdczn3X{5j[J!(*Ѕqjc"i* xeN4'w}3i6z"ϗ3w= gd kb6fS|fjJi V$I(lVUࡷ E1g+Djois{RkJt;Xpޏq鴯d^4XZeŔXbqׄH$H[ʍcٜCy-N*zxHHN*osRy9[<,n޼ɹs爈`РANR#n& M|cF~^Әگ"UILY@{S-@`7؟D ! H$okKmL;[;'`N6/V*sekw |>2Rr*bycrr8Lt!ܘl E"ŒXd4^9ӉRe6@a8?rGZ4Ba$;u ʭж^!|{u>8F&Il5|5^;M'#ׄEI_ɦ$IȲln/Y-Ya RB kyk[wڰg1zx3K_]a(EْAWUQLF6GLh xKX]T`G PMQLFEIē^ ,<{#idxR._&4*jI#**C'O8ANN#F(ü.ʭRew}ř2 I^PdL:-N,5O ax?gVs!5-%Sj53PeKxe+UJ*u.<e)qg)# 3Q.ANx\L\BDtr!^$>KҤ=ΪbYl:GCN&ߜH#ݛ&Nf`!>݉CLN&( B*:ajZ1B5VsY $K Aׇ̞VAې@Y9gxEbsfPR1@ xXЅ杗L^[o~wB'd@Б)5̛o' n1Q-ʷŐ'l@`i_^ڞoclYo!}»[QQ cZ$D/zZO`),^8͹fI 7{ 9â ;m` T&@+1׿&иd( D,[c7Lx;x>=;ն ;*>Mɧ:>>NÚO6ż iH-ovJ#;0H@ƛ`oYk*v6X$eB,޴~ҍ\>N AK*$|%k%%Ie[VZ_ům+YU)sp%aR9:!a6)&qu٠Uozx`^WDb\Tʓ7]S͂/(ȃ|u<:1F+T>˰|Vxȡ5PER%@WJ%X2&KC$.\f\Ɣr^@Jr? r$q|gL@דӔ"^-$G":ciqwX@)͋ITceWiv}Yo7%f6I\ٛvKvѮ(Sԗ~u t)yi.&Մ;,kGLM.& 9ebttƊNP0CՇgUd`҈4":^eF: `G'3K=Uɑ^NE̻]XĢdS26hYj2YT٫,J^6suPog_H kC{AslPPr@WOމң+dK+|}͟4N qbtI&Qٕ*:k %lu*Yİ5j&,iTKXC9;L;NIX5}7rЈL-sg0{~05Fyפ_da1[ @CϦ m:Jkئ{CUHr%;'D*8ϡXBJm4alYBթMҢG1kvXI2ҳjě,LѱU8Zz [;V,Vy}H21#ƽ*/B3gsW+p1x @ _B"l,[`uBʼ줪,Y6TJY( &;f;|fjh*?s"#USŨp..MӶ78ZLY+Ky udsȋC@ ^g 1:.TsW,̵ II"%4233EYwp 8,aa%&vy?g\ű WwB7lZWC@ C## !hL'xR4@ @ HTcw4יPhA^ak@ qO`"~kÀ>S9EYP x 9%GFCV˙݈m2;n;PWiۓtxOOI~ts"i୷\@ @W!;~?9ŐO71kGct,GO gQ4zxOϘ|d._&bv*@ _q2U :PtG-4:eQ?E$@.OnO86XϤϒSH>[Kl˞BQu<̼v}) @:Ʒ$|k_^;^~alKV1޺-p8+~'Xn_ʪ3. 5*sp:55Cl 3-R9zҡyt=2b^h2%=G--%oz/'H}oщr(rpj羛ɐ.MQLY9q1id^Q8%n2@ @W4Qp:0[w{N~(U"pL$ 43Z ߽7OϽ^Y6>yF%HV4^jbF̈Łhs"rT&G'5Qΐ|lJ<7>9?,k{~BUzE6PG {w. fb2fw'sGL1I#JƼKק2oB>KysQsXky;4}e23ߟضY*pc$MځS7hal;fS5{&}2:q @ wB}l:uܪ)gn "z7X ˷. IDATW@{R DG-H҇{sν=A6D؆nUٖ f[ +e78Yʮas)ub8[,=5,Q>^ˎ%W̐eT@1\Ӂ;3aGBQV!GB!e`.>lٕ\aA00g&w n(hc骝sS[xKM @?n4ǿbA7_=-BŊ=dٽk58G\1zS33 eeiKv InY•DR`gē@ E/ݴ^kHL׃+[;{ { 1K23Т%>7R50;A-N݄~ܜbʱ$B!?sC̯ m/(`mGΚņir lǫd*(P(x/#V$⻝\M(ΫTH,vsþ"U 5 %aуsfuցF CFz\(?8~ۡ#啫i =zvY}&ý9_W:K2 VJ#T/]8}΄ƞ?3xAB!B򟾀|(aɭYy@O&Sұ<qhl;粡IUZѻ90&7! i\UA ;xVg>6n9rtd5wn[Pf3u'j%`VOr=+Қ~j@B ɤ!Sܠ-:~H *﫩Քj׆Z% PBC}@>ԙ9dL 1_~'$B!x!(mIϑ7B=eV*~kO^Y'j x<̉9d#7jt٩ fǒ ݜDV(s5g5x%c!L+}5p^˚+T AKZ(Ù{^t]ݫ4&V&D<qk~ϡqen}-U~[eq<2GpWA?] 8yGVo1FlťءlZIB7Ɯ;dIa!BdтB`Nj5 Xkw5Lx*ڌ^be, Eq4j3o-aw(mc67zr'0 5}ê{'B׀z>9ՐLPٚas:_<&yCu@|įwّƝ"--B5S09U3(,>}()K }Yq6X;S1ƾp=שI:g%\ۇ⮨$aΡ,^#b_?O?܏n 3f \>DN͋!=-2ţT#jJ1ܾ/ f2EA'ƴfđTm85dvA| sMNӑޭ_m}y !($'r̩TTMJ?EOh4ÑI)k!%>:wK>~n*uV`У1:VVE`0豲aݚU4m}Pn2s#уM-(L*j_vb/ծ\̾fH9޳I,ېE0&rA2*da,Gtww1oҳx}(kuaS?,XI U_ ՇiP ٶNԹWIuOcxWW}43JC2`wK{Xd/ӘW^sm%^')Y+#o@VmsOzZ*G|X)t!sRG'io\b&!̹eZksy,|T6h0x m K:͆yk 1ǣT:hJ t; (*GniXISҪTƲˢ2{+\|*ToՓcrg|fm8Idv1P$`!*{(oGYjCɹDĐq5cp~+ _v|[b= cB1t-5tETRtV)9$=J= PQMLxY%M0V Ȋ (7/J[(woj4Em}y !p(pxsa;<?3FC#1[-[mAk4t1:ZtՃ8{ c818S~^-([^eTx-GI˜yxbR$l՗bݻY3G`/N!&:kH7\!2~9OF|#= aQ_O{VL_Ņ 4g8@ԅ\xUBeZ=xX Ys{oOD6Wz\>]iYL*9s'Om%B¡ktl86b6})iֱ)&u (@Gi;ygS5'TԄP%c}v?&A92s"Kuea#X( Ȼ;~/N)HpWvwX~ݗON` Jfv͌Ze)ƑNO~fj{-݇xu8|H zflLg-)|!kk}˹]GJ| agBe5G{{~i_sm%B¡yzwZ[wy&I&K4d3?N{RIHտ| i1#hُTw fZx&47~Ϙ7BuV@5 Gҿ!L@xIB B%/^`0MI3-)غEl)U(1d`ڃRP4sBnL~~,Qasxƴ3&ۑiHSi6{T*Kg,yHS-aMKg<H7p$c[VI'/DҎOMS%d$p?f#!VX=?m}y !p(ORCgNš)eF8{aj8 {c6uj3cHl G/ܽ z̐ MF-aĢ)3@Vs*>keۂNoԌRr ĐRS ^h e6/Ȑ…{w37+(38x9ʥP#ͬ 71MBgF4""fmzƟ]7] rkWpD 1ˀ|  Sn, ixh@aNOS}.SVOa1 a(S>L֭`o/ jG/^݀.kC<# Σ^6!?5MG08s ,SK5gD:dj ?HʵqǜAGȫ}!-}ʅGpV& ȑ ws?L}ux Z24`d+qsB!%͝1d$ϮzP>-]Y03s\ f޴ߕƉi_0X;ͥQKW DC(Q4zx޴ZVOgML\ks>j.adS}DqB~3<&*Ƀ5}x?E]--d !7Bƍٽ{7 |~TT5jx`hH9nCZ zT4*`2Q#9Uټj.eg/.aȸD%U)a7uǣ_kmGVLeD?cdHqq^"Y1vcN5!?F0yVNeaDN]S[?LgD$8TDɺ_̏C6jdz*z XyWI*O>œa3 MJ>C81< > R2 Ӭ6U/7ţT:D5<^ucn#Z9EL@B(l+2Tx܌"VÆ پ};qqqّVaÆojjPu͜an섟qOs$ZIs2#^9}Wki]l$$a~[t.QqΌd|V'Ľ641wvF0a|?LW;͉,5" kw$bQm0 [АF_tMO?-~=Infcr(Ble/gZ1<6J0DsgF N:+bt҅"f7ٵhû=,r lr+MvEixa+x !Qe^] 4>B;V֭SNĄhZgFN".{a1.=쀟}^'HprU@haT'2jI7!|sqxUJ́xܟZ'Xv,ٍd,M RB(9[Bs/5XwC~ +U(|iGh6/gr&f_v*<eXNDA`34!J?Qq.P[y>!a(a+ !B sy }i|{ZhO '4&ؓ\S7DrX@b8uRQ{s?(+Z{*r}\eSz,sIQߓǀSVAfn0MC!B ݻw/[no?1}0L1:B9/sRL_ģT+P*Ӡ3:l=@R^   Q8{>.lf AF='#ﯖ{y(B!/ŅiӦIq}v4V~t~7Vw;oj[)9q6iNğ7O;E~ZȖk/^.%r؝'˥$}ᖧu͍!>,.K8šX_"SϦz>|~B!B5~(&_ , ~(eY_JYT3fylmfty%Fr@轸/[ BJ֓mÈ1~ci[#Cn]K'Q|^҆rPhfwo6$ fdn]<|ҳV9:6r뢁t+[#XzӓCbxq/Kx9kX+3}Cͪ/<5- b8hLQ[Z4s6 *4X8[{y2? C!B o$fx歠!9pg97 ,p𫄣@ELa׳(BE_T),){.m3x-Ճ'URHjØ[Ȍ3j cpZ C*XEntv>zbS-z9Ef,O`]v: y Fx|8gFh v`g~}h1u&FfrL_MqmĈ1)gM>i|=',n^,i;ٮ\$ d_B!I3SR5)-!3"xǯ׭YEj zz=@բjjh =V6[>}jm@Fzw?zJ 'tG}M'~J*eТU\瓞ʑC(VE ]4!B!p(B!B¡B!B B!B!$ !B!p(B!B?!"fmtdDoc Dgex 14e !B!PdGlZ 6s7O!.G% _WG{4^x^%xdĀYLB!gs0º"?HRJX]FL揍tY\Yܒ6#Bigjv\JOmp֧q~EyZ8|J.yVAvzdB3z8..={Ǖ12b޼yȖ}Lǃt<৙d'OZ8P>B{Y2`Zbş\#aw81esq{ܹ.72\j#B? 7f,XQQQԨQ㭂!8 k)QbPvҨ R۳6CQ;y;RV~JT]RuڙbvcPx< dʜ~.& CD21dzLcz @9GUfp&/,i`pL\DjY>ɱ`,ߘsNr28r!'XhrayRiNݗIқQtɣ!H:3V'!X䣈G"`@5wػx2s7fۗ$Kݖ>%T~7 IXU}͓3Sw1ۘ꾂SaZ>/lHٝz#q&-^RI3̟#z2q-c j6^񨺅}S ֜%~婒Fk/ IDAT<<,c@u@c" 4B!]St֍SbbbBtt4CVei8QS΋@AǸ~y#QVQlȨ]' XM:,gxkΛ8S*~@R@ Rl\LY,;F_}2CZRsuOv숦KoP!G[֐D Tlنy)˙ru2Ng~ZnhGs6h W܎ҍZ.WgخRdf딜+K$2p>(?,`˅4ʕV'^M̑)=݂]G?_cO¡6v3_I\ Duu &a фo|ψ{8eF.i9 {s47pbE~P1rq8casW ͵ŕ4* Ȇ(0YF_|. ]})j ƹVI#ޠFBNC+J@~{.80q8oS`ʝ8Gr4E!Hhh(?@BCCY=o^vS -ZԼyRt`{+{*r#H^kHL׃nBjt?j|n#&Ҿ'i$&} XX1ݚR"cf ..DE?'KP 2[xOfn!F%qGT\OG},q߮s<_U1{.|SdrerNẐM(l=|}BV|zB/]¶J(fW0>Q)W&^{E<_Ixgο %ctd4gpɅW+T҈7pv$ |ک WKse P9T@\UPHGUYH !pw_|Anh0L1:B9/sRL_DF J4 @*xTJ0_T  Q8{>."& %!>ƣrfGp&WsF@FRz{fTP=JCfЩ3:١Du7T./*}*C::lcy5mm<_}Ҏ5I;Q` V^7HRc N>8b0Cxm4)ȥFNLC#{^rJz4O[BM^i {vr֗ndF'bٶӱ7Y*+C'z/0}'u(.P2-IͶaDI1鴭!ܨ>/K_ d\}tR.[gTf*ִ(CGУ1EnkLb.P׸=e63ķN[=Q <Ci_ube*;R? um'P *,׽! 24RG~v_dzs#&՞yE/##bQ{3T3wJ|؅]f h, O*&~UlJ*eТU\瓞ʑC(VE ] B R8 BH8p(߅B!BH8B!B!P!B!C!B!B!BH86}'fOh4B!p(t f-?KF/!B!-}<;ǿO5e. a5qLHao esD @f6ľKY?FG3l&;AB0dzLcz @9GUΊ&pdTO=Fv47n?`o'Y7Ӭuv!?F0yVNeaDN]SdEcܤpd-y6f?/["fSf?Sʻ"MzUGK2n+Q1wIjg}؍Aݪ㡖P@ A\$;B?p=>d`9u8}cɢΤ(!&ǎ]ö As4/^J.u?B~`XDlN߉eO<ĊoĊZ8݅ Q&a ƍƿ\a%2b9yw[e@ ڸX;6Nt-AZ*wB 4 IX?IUjto!f2T| Ľ641wvF0a|?LW;MvEixaK~5{z XCQs{|m4="$}?͑h% N @bf"`? [xa,B!p(pUYU7xD9|<3v^{]CmPBN'-&HR@Sy,݆vGaۅ0%Ba˛4Uon gg*x*]9px+,c |ӎl\LKW olʹҺ`%U, P&;LcǒOfH2X*t ̙#!a(ax<á!ˠƒC$_5(mX0N3#QVQlȨ]' g3W!BH8yיJcRalC>ӜƞԉFǬ"cba^spzv HJ*~N^K[8,zwR ZJh|rX@b{'R)<9H RtyS7!5w>7\l2<.Qf˗!+Z{*r};ClYz *q󶅣 p(B!$<@?sMGcD 'x";X*r|4t2+za|oG>RPmu*#T yPĐ=?  Q8{>y]wӪ@R^An!B!m"2ݝv}1-'NxsNbng&JK|]>?|@=%e{*}hװ"E(UXZCd݊Oȉ_&{@ۘ{z/iwW-ڽ8>F&yl;|2Rl&Oaβ! i7/z%B!zIVuq*T&㾣sp̥@;w`.|zݚUVGctZ-ZFV`cemú5h7~fd~QZsDNlĥ| hR/f1BqzڮS_\&^C=֋N|Рe:wW}iI(vӍ {mԥRlgORTǯWF.{߀:T:@*"{N9B?w9q#{S߲ ɗqjҋ5sǤ9z]է~Sӡ1\ Ч]}Ozߞ7`;!ؼwޱ\mcd2bɾ Na}t\ȯ1H6 ӆESLʶy^0qt1 .f3l<ԁʽ.3>P侟Z~Mb??Eޘ*}{'b;dFs:U@;3ka.H" 8_bU|J}+ФkW|:hؕI.þSXy 2c TAya>tX˨ *nȈyky.YV3F|5;.%ksF<1g׍d.9UC?gP`l|;+')lz+1L&Z6! jBHu+'D >/ׅ:ȐNX+oz:ZtՃ8{ c818ʵyφ)ڌ cGho`C85Rcyf Ȍ; :jtJ1x~jY:k`E̞,\:0п<ϘO q֒pg~P5aNQۙsXЏPxu={P&*}_µHjI^f+X2*)S?f3GJn,lUr*=3CsWVYɄeEX+('xZQpzAa ;0`uX l͂ H0"bp5\?}D hi-[̲Q*Ϟķw*}ԗeث^E)tĐ{T ף !$ ! 0(v'f<ydqDerϔ 빃 } Jx4"!EQ@s$p45}/{|=ԥYφ%r(i.5'ni#t)J(D(t7<vMьs":6ռNpW(m'lruɅky=JҶyWC,)\(xT{ %FxS"T + B YrPC^ƭdrwϞ 8~3:96)]ɐ)k2*ۡ'rrv\N'DvYfo#z.yh/ҡВ(էh0 ;8FYډ6([&?{{ KE@.DY4Gc%Dk{7k4{oذ~RHi2u%gTvO%&6 ;`%X_גҎq,.@ _/ oޢ4Z"Jz^VlFO7y1e[Vhyx%mv$>'^KvĠ5lJ&)k62iEN=˰n!QኯCv̓6ފÜH1= ( @$Ɋ-Y7['dM>U[wqmŗX92۵Ӥi#oNZ*Os0.J ʈܦ0ȃC:*UﻶA̬474!!BI@,1y} Xj^t8Q"p3k rvN|LL=I *&W_}K0rF)Nsawi;bF" z/؄4 IDAT1a>Af4"6:i".)YׅV{oSO!G%sNI")Wݭ?G+`հ󪓈|HÊ8g|?!rkح#儷}H@==; P=!s'7uԛ+weFnBi5LmKfۼYYB"Nd?{zMq?'\KI QWXbBQ“RX8Y}\Mb(UT6(:^{:NvOmet 6x2\"y̚F(BO)9&1/^mNO~o}OE=;n%S|jPk4rGG)fC)k b$Ґe>"uL+XK*vb&56I-jEIٕK6}cܝpqsĸkˍ$"J(_$)~X5ke_ɫzJa׷߽K܌{Fn)о-]qtrZ7ħgs>e-)n=7O+69G&ﻶL|hWϜ1[1Yډ^Vh%Q\iw@BĖ{|?KDR)kլ}lJ>1y|1ũ闔u1ZDVS#dziشR_JNr28%qrFB;% @*9CvT3VѫuŬiNCA4*ЍO5IڻH ge$ܹNZ6nmlJV4~8n:c(h:[zg=غJYUS~% e3FىEۙ5vKf5uX)EMZz yoUS,_q[QiY؏)=QQ'yy/K<\VfCWՓ_>e@k3X8?VٗJ[[@PL+B惛ϟ?gƌ-[ J(QjsLW7+ g5i4yNޖ|$-!~V- &1 4k'Ta/':¦&"g洱+t{d;_"S9HtJs4uMQ$akCսpJof!Ml#A:/_в§(|:Sq ms={$UT7Ĵ a akޥ!s{} *PH[1/(_ϩ>v9˚ZL&O  -~XHwSp,[eҤD2T*T* 8̅)\z~1 ^BHѮҎz&}ٸ+f|__ Ve m'.e烚x3o ZUS3lU87 }4".J&N8c%kYf}(CC:On9Kui[\*!N[XL7o].R"`zjN5?:xs`{BBb:un^ȋ-ᇏx ;ߒq+Y}݄ {(3@Uw'8+EqzE9RmX.a׍$|fLRFN* i޺%'jÊxDlW >Ƃg2y9>Hc+bq3Vg8b![;MmSK;ŴR@ ap}۷oF{%i6;-ٱ}6ntwC^R+ Oc>Jl5GgeB{8Sq<![Oi}*\HM" ɑN՛$(wN 3@5[?֦&SXlg3\of4H Hq03HH' ;az.o҂K5Gϡ[u,MD۲&x rO%¼3w7 ,ϣx"C,v)|x !A.VZt r SU,{_+Φͧv>='x5-K"7=w0Yv3iwu0WVz)NrqV(x%.׆kaYophƫ |۔ʧFFM(:+ *iKvEfq))>xN]$|>(¦۹}OM}|qe]vZ|=Wrk8'@ ƢELnwv ]jGɜk[8*nq:2;a{9 kn1"͌ }vTX@zP ۜeƵM'OCgѻ?DRP Zl83*>/9w/ecָj^m[ywc,yx>ҹ\5nԺ%]߿SbZ.i^RD<1eG̀\3ku->֡+GD75UtHŬZ] I+5AƖ|>1үiVES:Ұi9 _QuƏZFd͜PZ@ @ÿ:[ΐֲ҇wjS!N Cz5.f$Ybs@fnp B^z2]3G݆?&ne $/ْɡ2]͢IEWL ֈq4c@sM,Ԍ,}, nq#gE>k!߳LmVg, E2aƞe Ћ )tLڢAK)z*@nu*xэu\6w.zs~(֩@aY H ](}:#Wg:դE{Јj\aHײijpk؋@ @7#y[hԥ gQ3PX/xt G1fW(_72ZF'֭[0 Pҽg* F:kxU *F1ׯvrǏAIIN.سgNŵh8Ν۷ \|U~_;w+4DN)ܾ% &NpС=+ 9s5c#BL+@ @ @ P @ B~FQlH˒k@ q(ɈÜ *fy?$"/dD"?a͖c<[ @ ġ4Vo T ?L[|ܒ7Y>|$ODu$gp-%]7u/ jobjV6Ӛ~Nlm;t~'OKN ed| 3ho 2 (|$U^:WLG48vZ7cu d]a{go9_'ZDsDVI{ĸ.h°^*Nvѷ8w5 g X|O43JС8UJ$W&@ D}k/`Xo! hWiG=lAo\ۙ__ Ve m'.e烚p: ZUS3lU87 KAe@)P-\2CYzבr+'YKgRga˫ eJt|"% OkӬ:S'<1cǞéP+3Dh÷W."9PQ99{ h\t?s:iDg\ K@ s#Lò΁y:t1l/?:R$j((4DyMa#4@P:#3Ryz g1$#ȐKA.±I7x l3tR.Fi\[1iw%BFzE IAp;N+͑6=^nڹӱ4EZ'? m\?i[8_.zsv fu.:@r f -!%.e]O;ѻf f'PM|adZҠ>2C[OI8SpwB72G&A6(%{K9 IDATl;S+'o,Լz{XI?x>i8NO^} '-^\ŲHd'?Z4,5}4#َ3i6R'/19V$ruP{hZ!swcq !Ƹxm (#;0%cAu+4kgWy;-5ч'wE2!~e!83㽵bS2hr3eQZ5.Ѫ/ڕBh/ҕgiD/bߢ?!%QGd˒|Z3u7i/Lu ÈBXK2/&ǧ'0-I63o Q̂hs"/{wtS"9%n4^n:@Rwv ]jGɜBk[8*nq:2;a{9 kn1"͌ }vTX@zP ۜeƵM'OCgѻ?DRP Zl83*>/9w/ecָj^m[ywc,yx>ҹ\5nԺ%lB 9-;;bի+HMZv (5ә܍QG"ZL߻ mzrHtZSNX̪ũRtklw#fmP$=~# Pe(۪UMaE iI9 ~eN?)f>i/> >+7~9Rgaɰ/<riEŢuOy^{ cঔU(=֬jQTȺ Rm|sG6,1@ g:[ΐֲ҇wjS!N Cz5.f$Ybs@fnp B^z2]3G݆?&ne &/ْɡ2]͢IEWL ֈq4c@sM,Ԍ,}, nq#gE>k!߳LmVga~̰_r~cOP2LE d:f-̡~x= 7ĺL3@fSjRâV=erhP5gdukY`8 5o\SV*j3jFN(HPݍ3yknMQsd~h۝Z-Rd ?3:4oTѨ)mEBcⳁq@ GYد-굢Mzl\Ĉ!4=I*@}eb ],V,jro>ˏdH BbT{;W-JӍi4w1r `P?4Ʈ)8Ju9pwCk TQ:?;Q]Y?F @ ԜzsR$<]{Vi@QG&y%j]QYqFu0ͪd/~A9~7po OY- [EƌZ٘Qy}~}r۵=m:>B73Б_ Ww)Xp"w}9uzm<mȠodiR'CiѲI]P6cfaQmo"RѨ ̊q9: a Z X {7fT E|51جϲ/l"*ͤS]A<1ƒbԡ3l/l_u!zyYĔֹ[%rmdx,A-wÕ%H$qzGaRQv]Q]YUy aP*Eucܚ,Q ETR@=@Ja@)<3' xzh888 ,a%ѶbX^\{7P#oN- QoFl}{{w~6RA1J,b~"Q+[Vfڼ)@RM1m. QמgJ[ ]7gWʺYQlbvT* }QJ+퐳XQy2kyMv&,~ːIdmU~{$qM (hϋܾ%Ĩ@ "FO7Wk;=?hYE%&ڦ#^;0q4^uHd- xm!W4.$=11x+efIbq }b"!gW+tӟx0@&KfV,ۍ)OUk~7%2$$nb.d[5Q 36 .V0 Ϛ׍

2@j׊rgi/%*{3l5s O4b׍q+.5FpL6vz51dAzu60e: ԵwER.ĵS Sm%cYƜk{"y4aT8j?/ ^Ia,hܥ0l)j@̞~#J'>ھEPXzhPE|qGȔ >٤00}FnOAjJ` >[E,'Vh5i0zW2@hC@ ~-t gQ3PXK 8v*qZ BRѨQgmfAU *F1ׯv=?߸9)Ʌ{ :Tmxt G1fW!1ƂWΜ>gI]tƯ՗~Go$(PW~3cn擜Ǩ-.|我C@ Pѽ$eD#z"\ @Fsq=p#_u9s C @ PT}KĴb!;WqNm6hw:oZv@ '9,4#=Nc.J(Jj6Đ0EV\>6o5!PyL3}-ѻ,JII,K*(h?W@pi]RKDEr+1׎Yu +~9?..Mk)Q*L}[[~C潘.ɚ1iRDl@+9ekS#putQ6dmRn.gH4ȲNkiy__a dڢa)xט߽AiIECh_x5>k5ǿNc`ɪO:\5w1OLiMip'ISl6@  B~4jsLW7+ g5icEyNޖ|$-!~V- &1 4k'Ta/':¦&"g洱+t{d;_"S9HtJs4uMQ$akCսpJof!Ml#AZ?1ze5zn$Ǿ]=? bofJNlBik ca gG#4"fz?:De Ȧ-BKdD]t#}e'+eBXG{aH?BT)TRۭ̩ڡ?uuHa9j0Zi\Vq\1yCfPjÏeAV_H 톾$Ϻ)C݌;+n~/f'Y3g }䬙+X @ B}k/`Xo! hWiG=lAo\Q=oH:-ch;q);Ԥc!nUN>ժbҞa¹d8^]@38S43 ,r-ˬeq}_Gɭ8g.m {Ke;DY.&)-[𡢋3X;>MN5?:xs`{BBb:un^ȋA,ᇏx ;ߒq+Y}݄ {EwJEmL$P Jk{^Qqbz}apKu# ?F*s-![WrZdYmXHGsS"&i~a뽒4 vA@ǝؾc7 ;!/)pd'Q;Cl5GgeB{83q<![Oi}*\HM" ɑN՛$(wN 3@5[?70' +lT<Ppѐtg7 GD)ЗE__E yEBF:)Wnp߹It^JEtH jfT2j fǂʠNg3GJ1*#G9/Gŵvܾ%|M@dnl3u :,o1l/?ć  dFSX@3Ȥ̻TCfgGY M#!Z]c.1oٶg\ҸbdK& ^'A&:3Mv̝V#m9zܴsciaeoc@ @ÿM%TF2^Gㇳiib|렝OO? bMҺHP+<#N!à fͤQ RZ\{Z9o`V;ˡ$YaB_RR(X_haYophƫ |۔ʧFFM(:+ *iKq(3ZON]$|>(¦۹}OM}|qe]vZ|=Wrk8'@ Ґ[8BPcNqxjJ7R<視5JrtC#wcԑB[9ݠT!9*juq*$Ԭ[]0HYEIOHæ0~|ʶFmS?j;b}5sBhO@ !?!ќrT>԰cV qZCϨi v1sW$c0{pKrex+_ 9J%~61q+s6g@3yɖL`jN*2LI>ygT@ @ B @ @CQ{8"L%@ >uqrRX+K7_'oJz%T`}녞Pҍdzb&"/HS~la-`qM C> ..Hfp}rB!SP}A ^2W?PQWN Jѧj4Z@P!a"äsJblZ@ p&(>cG"th<9LF׃b";߫`I+ 4<Mo?V2 IXhn$]aG@;zG] dDqd4OI\* ޏ}!nY>9÷tԄ^:PLx+12 ж»Q/uB_zC[~?p;в7k @£+ ը4dnܟFG".bGĝ=5m!o{I~l,o˖^'&"'󿇉b䤤]>4vCB:g1sEG(JTI oN޿7YW4@ >!qAC^1,!5BZ٥umv#Ɛ_/D7+'f_\LXAQMAMh%049C˃L:+Mx1AFL.-3&gټd>_ :om߉\I~̩X9N}*Iyˏ1m3^^\`DzMLYqKex6_bf-,X;Pen#U0">–b;Q"yB75\Lb6 Zû[RBw媞nE-!WjhHBwsv\Irټ--BQ5G)Y tT%,T+cIfk)ZP8){v3M Ix5 HPxoeDz|Զ: cU)UR|ރIHʡģև?94bKnuBM/k3g۸$vѻ#+PX Odx#jBxO<"!xxPUZJ+U  .WH]EWEz)DaFk˼s$q](މR.N?kjPR4q4 C Ela-iw HE_DZM\I$*!uܰk="0O S)HFw M vHJe~R 4@7VOdؿ1'7#?l\mBϡX)g3O+t_\B8]%@\ -{38 Ja&TrD1ݳswq*\1ڙ#Eʍ7}o6SI$lF(r뜎1P@ߑֵKaz冰@ 8Öw#z- Z}y0QίƘ4~r&f,S0%)pLnk;V&xLgc~~[ԇٌ$+GPuE>ŘL0gp&nQ;,ؙ3[6jlWwpiN :M3!p]J2tOKbQ]i_ W/ešm{J8W/alί,ꯌ)M[6Na3(p*_ծ(:`_,ob=Kwָ zz\٩*U=0!:O4Cz-t#kN2Q1Z5:QZG1ƥKw`ncہ{q?9F;SwުNq'qqխe&S',w iBy̳+EAӊ_qR!A/jj|FMMCRxu52R\)}#荃.G/''x^VDmþwLnPudIv۳ Pȑ+q@_b͡w 6AsRϒz&-P> S6);3c3.r;:њ7@tT(Z”d#ktU@!l9žŻؗFSEPehL%뚸LPx $93ҏ?>qxCи:ٔQY3)!6E[EICQ ޞGӷ˧6tYIB%k$qcG+(f$KIʦ aqAB"nDQ@ˡ39i'!,F C5/6òW(yOجi/'оOK;YiGA>r9M6q{̗B6u(!CmRo'NFj8 _cO97Ŋa'=@ 8v6{ ER9Yv.J`ߴ=K(qx&Yn[O*Z,_)uT%1ֶnR1Y),Hٱ`䚆h FC;=Sl2pocc¾jwWa%zW.-p#Ǯ#7ÔVmh~#1bٹE#:TCє,bs1 KSs Uuq7st|',#η8tʙN21\ouIU[ef8t I2 _GҒEe,R||Ť6jJH^[`:泷PdT̿ V ohajfm'E,\?1Iȶ? it^n -@ PL/ Gg 7M :|lsSϲhpf,W$cP]=xӫ0^2 @ %bs K8":4r/vt5}@ @R!<%(Q?ݡ܀˖%qå[̐hVO^]Nk Rl4uj dK9&uFNw)LQɧC*~v7 'T%C*̊zSGΣ #X-@CAc__I둱|cjA7>~\Ň1.`vB jRdqrƿlɄƙp=P,@J|A\ ;t`7ûL ti치Bu;|(nBŲ)a;˩8-UGC: <j|\aǹ[줧^qL;ΞTuzZ߲S% &ٔlyP9ZTBcj'p rh'a^q*5Z~$xGﮣw9gw;/@iQ@ PiH5M OjQ\3"e߭ap8y1 aV܃܇^YԕRNcXu#`rWQm^)Ѷ OOoRa0!~vXUm*H!e)a,ۆ]b4)N\r;>gD;`tkʤPg%\>ʡ?gi@]VUm\ebx7R'Na~^ 9⟢txۿ'DZOu m;yUǩe) |i7n4M Z*{Okm@ ġIȱ\s~Q?fV:;;oS.mdHncڠh56 a&I/0MeǗkd^)Қ標=#o?_G*j gbx5C 3| 4:<Tx0>w#7a:ODq}s{iv6}ŐM'j!&e t^G @K;[;LUqc3v(A3p1FZ謁[:R$a@ rHꯣU?$i@ O㳍shmoq ˿Y0 ] }r+ѣWL&"#[)M&L&& Ɉȸr2wo4o!%%װG렣jيC_e<сmxsq- !]D mْ*|ک4)سdoeQpyH;?=7Q)t ҂dO2KbkYY%M%mݶSHzWtxF_zo u wFa2/n)ﳔ]C/ChkVI!D|7:~޸iRAT9*rS:w}R8x`/5|! Zp ,瀪Ԭ=ը{}TjTv $(>}P^-OVN܉^˜g{|+&P-,5?ږyCb `|u7:Q\-o$E­Vn]Lb@ ^ ĸtv~r'ˆ-r ǵ5{)֠B\NclrE:)J%PdQz2}L86WҸ#v|3RZ{>0mcn@ ϡ@ O*q-18 ?v-0\ͩkǀY:Orom-}5{REZw~r EC}W9N[8?~E= @ *ϡ@ <ḖFg<"+$v/p.UR[Cᆝ2 PUG0tT6CmFFE)~:_u ryp: /,_p.݊sZ1*t%d7]Z hݍ5u?dnfO]y]k{w:吮 V *| =8n%@ #8s7 . '֡>m'E,\?1I++|B-,]F^=d wc8w,g9%c6˘/r9oYW^GJ9k @ ȿ^ϑqN h˪u*1,\e,e<>Ocq^bM #7,R~<Ӝʽ`?&-C~M_ xipM~y*' Ϩ bV¼@  b9$%;0Cbٲd?q6s4S-fHv4g9]T,>4,Дg$¹LKǃ@ bw@ ᫍ&.;KTpVa_ g|􀞄֮ETIe8rԡg5[)jVը#CDqP?q4b.d?z8[ƛuw'?s .{/?e4,$$;f{#P*bzP VRP7޽7UC_Q6;O㮱~y/BkQUBPGfh:r"h5{,}B3it#UKsqcTyJ ǮL2a,V*Ӳ擒c8v1%=_kÊ8_EԀ><_$eC+f.krx>+xBҤ]KExst0ifQ֭3݆Kgp#`M75(z)Nǚ)Lȉ\.9psJxdT"ˏ3e`.N&4-`]̂pzs׭e3p6RڅmbO]~_| };_g$ JoSWGj )-Ly 6/`e6P륧I̥u Fۙh<#P[S&5,<[.Qq=CLtƮHj*sƻiW:EXt n5 =Y<|8}P)Q!UyԢ{so>gwy/[\~@7z[svmLxɍ~L:Z wap*FXI|ңK)LmZGןi9jfψב!^qL"?<Ϡ:xi>̡g}`M>hEb@3|3qPvp=anOw"ʽM_1d7~ZȀIq,q3ONE_h\)Y<6ct1Y1mMʻsH.Th<3GAF`mQ y(:c̚)Cc\ӡ̍hJA'! @ 0A:zZu kC 4>8Fi8Ӆw,g`턾V9ѫ& EӔ&& фdDQd\\Xb;tӽ2ľ;hҼ%\>Nxs s(x8uNrA̪M$%F(fB q(xy1?]D0XF '֡>m'ELF\?1Iȶ? it^n =(XEub,BЊA?m\4q@?I77;g s[<4M JP ŚC@C#P)uSϲhpf,W$cP]=xW*yaWVOh5愋Xd9t; ـkxhF|Ќ3i6 ֩JPQPE8W1 \0ry.N/3 8a":\.Wy}JE&Z[+TҾq9`oYx o 7t]W^s('SI{GaAA1#ad2 V# &sP !lY87\ َfzޅAIW{ZP_o ӸLp$9EziR- %f3_G^^ߘm(ܭuz952=|ۉp#v&d>X>'oգ7àYl^+kFneײ7_HJ/gM2l1GHfLS=]{LחMߌGS0]wjg}6DL6OrlZ[l읉tN1Gbͮoӱ#;fȍ'3KKXj~fS t~#ea6 sqX#k#"F)h EZOh_jK{ٴnUg/'o%c ?"b{ Pb߸ f(~]ՖNhh9t!6,Mt\TfNTrU?M13r_b~`zۢ ݭсmE5$5h/e}k i;q-| F^f٠|w zK9l_0Q9073⬡ lAqC.:S\2!{R5jehE<0^I |NEV @^q0FEa{+ĞG3;3^̹YLB*xR4s fͷ$qtf#e 9W5~E#*1%$ۗB'bǜ`OD|<:X:}"iX:)nK1qIϨݩ, Lö9)$xNH\kbjgVf.,[V-X%bW q(xZL7uQ\ϥ *h\?+7{%lF= ]$^q6\CkRլNQ TQ?CG,Lr8]~|9qz,=7z[?N~'zdU]^~hYXH>I9wZMPGUļ5 ZwkJS# n?{o/$0Wv/lawd 8]c\!^ Z n͘uD$ jX:eet#UKsqcTyJ ǮL2a,V*dJ89(r|_kV$:F.bݥ moQ~>V!H'F 5ies$W,e'*NKg.IJJueDBR$Q q(x*.mb"9:{Ҵ~(ęnC³R8|͑ fNʍr Jvӱf{>!r"ކuy|[?YsYet&Lj<0rYA5 Cԑk>h  %`\B̝]E2or׿Oو䔇7#>~뭿b2;ue)dfǜ 'rmeS*\&=D_JD v|F L.Kt74)_Bͥ>Y݉PJ ^ǨJERr2Zm Q(=r I i!y'3?m®YZ|KNOhKGByƣQG{OVB6ԙEvO̿*kׄ9x1 o!5eJ$ApG>Nk Rl4?8PU wf{%kGG]>S8owTy<}򐩊Gï7lÉE7\2dLf1d!i\|RCP&$w.Uk\rg0@ ġ ĿX=Q#cw>eUo}6=;"!g+ͫRDms̈́S}} +-\3Y1srFznfRWKy`89cցogwp\)F¶yD.TX?/>k3{_Kņ~`U#5~ulJ(wӤT:vsl4m(x fpҭ)BMwL|C\*~A+2ܱnNx/¤xsY2nߓÇا U:r5K\G-7棨!|z7+M4{,.gCS-;W=Kڶ0l+X1w JeO2F6w[Vvjov%K-#i48\4hIRHJL%FJa-*䃳 B QC1iF9y:>vpi#S7^'Er#(ظ@ζ9\N6Ozx4iҷ-;\6 3M|5G|:RQS =ëQw:NIT/-ǃ9,켩֧ Ke}y 4L'j,(\-FCmwoW d}2`|oR`ܠ(Kq3ONE_h\)Y<6ct1Y1mMʻsH.Th<3GAF`mQ y(:c̚)Cc\ӡ̍hJWiOQ 8pz'EXOWo2:QzEy`bbbXfe˅[gWRR \zӧN] eʔ`+0A%:zZu kC 4>8F|d8Ӆw,g`턾V9ѫ& EӔ&& фdDQd\\Xb;t߷{MĐkÇu\8 s :Ma韷nYO$ ܼyd4bgg#I*n\J rOJr쥦0@2D  k 3w! q XB NNRKp !!Y_i;9fW @ bqpn*@ @7!F@ @ C@ @ P @ 9zxM{gCp^d @ !?u<lvh.s*6_cI3<4-J)w72ng ڷ_Xʭ7D& @ ÿh>?XxfMDS꜕c_{O+,8߮divs]?;G[g ϻf7N /)Y_Dž >Gd@ ĴRK.Y嬹׋VbbBx}@ @ / J޽{ܹ3NX!^c_BKС9+B:m?{7ӴFѹE=dSL|dMnmB㟯ԨmLќ!!Ԡymi&MBB*Q(H&{s[Q%='p}$gUXW !$+ZŞpoH4Tn؉qn2k\*!!!Ԣ͠IʀԳBP*hRRHR++C5ϻy׼ѡ4M{pY\O90?-k0^NDGO6 F#2AC-L$35J~<@ rCrۛhN:N5wxx+"eN̳)Tw0B1հyqWRJkl\D\ł]XFx3gP?.8ؓ IDATޔ,ɳ O, 1(DՎèfm^-Ϙ#( 훍gL=H0a.+b^l:BC,n/1DYs5Nr=?G!Kn\)0)^]9 ,Y0i#"np0::<'44[-q6$,hBy5{88Zb#dTs1 ff-v6s3{0t=m @ǖL`f\3fjbL|1ziDa%)KkPGocDI\nII;t||- >=@ ꀥ%\|=z0k֬Dcv7;u۸QA%jHVܰ0()%ꛩ,Zf2eKe!ȜGrc,J3~<ϔ\6/K) Ev1EKL/EyՂFGdYQʔ@IuMoyNA5R{@ ϖk)-%2"?ʗ/ &5W m)'$!% kN5B=<@ s/^ݻx{{ӭ[7F3l;N4\gClܴkm㐗bUpc*g-U}Rs uqjIg k.9vO4PItCI$P 2"qg9̎^ SR%ڴia o'1՛}43z ~=rSKǞƅ :v I VyOgp9JU\UhQ 5zr.CZB l08ˡ۩U??DZPYOT:W^Qrh;j728+Pf4+ ɇA;9t;AFt?yGCMҫ4 R܃`A$֠!`O3w\D @ gcܹPKN$n)|d~SaTύsypW.IiV<27QX܌*V]HĦ8\ڶP`k1'vq^A*8fעW6o~Np5V\=uQf2ƤG=9*g㶅oq6!~;FAmi]xaݣ)سpk12tx'?|LKqp4VLCTtM38t&-Js4o%fԖo\;t` CRـԘxT=sVi_ז>+0\= :nԮ55iO =t.?.e12ChNEH&.ሞ5iOpfFB߇z39ҙ }0ү_el>@8Ƈ;&OٕաbmDTP-LT9Uso6.Г+a eN ٥F)grL1{F$sGl$_3J)* E3X{TFZQ_NżaG@aKpȧbL?i p,ф15(fl +kl'RfL%.1n"C^gا.flE301?e lKO|C$xplH5q֋J15yht -eʀU(Ȝ( 5~݌ BL')IQg8HR6Mƕ37BK:EU 9L>"TIš}'po>8[_f %1p=EX#@H^&*p4+V$ڃ3%,rMq za>TkW从T*Z ij JJBRj053g|ݢG}Ԭې|=}(zƔ,Ult,Qb*٤pgm9 dܜC;(? }sIooۚ&͡M|c}m`n]\kIݝzaRFoEe4?V_fL-b@%QG"/3bꃴ i}3N,3gr~-;"Vu/u%o|Gvc2ڻ6f©a k:xɲ?ؾ `$ 4*qz럜z22mQ2g7kHӖǥz}H(~ ל"KʇѧwG\M0Άbjyg>So4dں`&ܰq3v&O5`Kie|gҐbՔ{ \WƸoAm)5[`ŞĩaČ7~^kQv߬t΢ў4Fu h5M^y{>oج_l:4+`(^bLr_vy6s#H̳kb5'<"PSPzy3.ճC)MaΆ}^Cm`O=ܹ:-'"o64 c$4H1wѤ1%[ Әd74``O^ጮ?En} j?i:@%@8_9p0# "l$nDG(ccpuS2 b!3e[ ѿ3$.yߤXcyDY>dj⯝zZ ~WBڹ9S`Rx|sWJrKAn>Lklb|eGR.YY.Ȓ8p:-1-vI1Ubx&4ZrrXs+#`"FDe` Xӿ)n%.VѫUOl\KWWbY*D[ AM٣<1,BeHͲ>}5*/zldu(oՠVQ$dR3|jw6K~*3ǍƳۿxm<'Rd? [2RpHʚI>R Mal[lL5<;ɋ˜+*YmYE3^PNXm倆6P=̐3ԃ)dߺYVFF  A!vF5kKRn}~@Nxs2j#~>w]wBi2)b)] FPXhSh})wul)fpg$a^.kTƼep5%ΰ*DkI-։ {B$a_ijH_f^cu1"tG[Vp|+kV,gt2ִ |}`_5jl读 JMM5q@ @(`˹,, η Avp W_yz^^>)"AH/I<;S<8zߧpu0)i KWHP^Vx稥uУwUO/_|^cqCSO1z+wJӀ ]r3I2톡WSmpqu՟# ɿܰ2x|. MF|׸^x{aC:S)(M {'9CS O_\MsЧyOoO=|)\yY r+O0rήϕB`~?@W_xr8BosqBcMQśiXUUϻ{!+Y27F*5?\Ŭ&Vu-2"}:۠7Y(̣pCv?c ߉MSRsCinбL}^8-Wp!NͦQ} L/Nc:u9o% !m2W7口z]"Z#R?OUfၧi##q׮_VQ 㠶|'N`;Aa㋗q2F}+'brn(7% _vS?^5ߍ%? b?uC%;-,pWs]^f@PYavDlFRe,*OP=ZCGlPqSxpg~lй+ZV)h^?Fgm2{ 7b$%0n3N.SSZ߆Ҫ= ^=I߬ͅϻf7NX7B0:BìiK2`J-2G- |qa N p֟2ܱ+7aL7ӐZxQ,z7ܑ0q:u"wn&| a )Vʻθ(v"uj]٠6ĥ#3f`4c6*ٖ{*DO[~H-i5:9ZjMuˣliNClt99uJ;SCIA*9N `f2nbC ̔v -͙ɐa[KD[`GAf{^T)Z2T`jdW;~Τ_Lw]ܑB&HE9:WYRwf1S\ʶgT"!Ũd%cw'E+z S 7ӗpn¤9g`aVC"4i@G4%H̰XDrc=*b٥FYX{YÌX% ҫ<)*SQbdlޙZ?4Ջ+;yV$*@AH^&yuiW*r=0͡MȤ~0H"9L]5RLpM\ [ڭx+H xɂ Gvߎ  9|`AEvJJBՠɚ&VPT*T*%ZS3s֮^-Z}Gͺ IKM'g`LRE rN8ٚ!:bMy #-r K3<1&nm[xɲ?ؾ `$ 4{2SJ<@8]I lxUF9~MCzNm] B'%O!0xA)٪'\ HY jɂ.( cZU  ӗM:J#"aFCOj̼5-wDɚqYHmq/YA=33w>υs(@ 홼LwՅs)_-{*R絫W]PT*ZFPTT*TJ*V9kW>Gf݆{G30&d, ~ʎqpx/VɃ33#s(Ḫf0۞ sSfkweӑGoh3b~7>C+W6?*֒O˴jM>dQ\;rO2pמ!:'"Y =k3T55טѲk.aEgOfSW?* XXư맞LT@aOCU y{q`q2H԰yܕu `O4-H$Wu4ELS@ߖ2C+YIdV>0> <0d60n |2b {0s%M/fM&kNB 5PpiDzI`Z=mi&G`[ѝkV,liWfq.&vGorFs=ԑVgѢh 3Ш #[͜;5g>[2p| n.ܦ5çv~jl4K2sh<̡.iHKĺubP SpY0 #)c%߉ynQ3myVѸw#|3voSiYjr8ˡT Wtue5k~?wIK,8 LWJF͋,1:F2+ TB^oEi52fYPɜ܃ M;S h&|!.Y h3eMmIG{]N_~6[3g~nƫѳ-M vٲ,<.dӑ{$bSɆ2k~^jAH1pOF3ZJ]7NǻyfJT9h P?>n } ȸ9w j9e|xg,cOp.՘h7ctZ0vholy V^_|B`<:4@nMTՉ| uX+#eJ1s/O]im>T<_ N'z-MN"ػ(e^#N )]@ƃKX1,mOV]-p 3nW:CKm,7_/@PՕx]rxYY}>0r! lPCu̚]D {G&+I$~\$^}.e)lX/g~Ɔ(QQLRxbKIjH{.+]:0lqS15ܽ1k ,Ryhx|(Kڿi*)UԂ%G$s[أP='.UbBx-y*[`UgW$b$-L8k:u,>1*625 7q+-NCYĝ}Y1 c9t*˼2oۧ$Rأ8 ֵ:1=)Wh 4Y=Q>,n$Mm2'N淧>G< RoQM wTl!#Y'EQ=(ʆY3Ֆyc~o=ö@&A P'*Cz)wؽ(WEnyp3RI]SSpBm)#,KX?k )t$XM$s3}1H%W9v2CFfjP3KnˢLNwʥ)\Z4{ ЬXؼ, 0|>u49V&bϬgDx/bx9lٟS ߡ~Trif @Cp|o1>؄g0sF#[GFFF R42WO`ܐĔ #yc6i ԨhԨD"!x|ܟϡO}Wi6n]y%Vp5#EM"s7`R@̳q@ʛQ* dVϿdp,J!PEF|s}4YLm94Z2uv2r)h4tt |T$r3u^tL,뺂/E(~xk RVE ɥPsjJ0dp{ʘI i*[Eͼ(^2(gƭS#8y) ,ЖBnoqSGowu9%9۸ ^+}xP03P40d#}F؛af>C|?;P\Y˦{vn'j8ʀt$bB5&ǐ Ńio/!MևTeטsD%2)mKC_ҕfK|6(7_㐫 S)*y E9ajoǮ4.6vJMp {9_jV:sQh pQvme#17c5V|xvcGi 5\"΢rH59MϜV1'ƣpAOXue`/HЂؙ•ihܽ \ZzFw rKSкg%{ǜcڜ q+|Mni[_z{*cr8m\ k ;-,N@[xoWa,?epOؘ^D9~iWKVRuj֚M?+a!CdSQB}G.xrk[K:rX% ,zr$eH92jondrk/{K,ݓ_jppo4wZ*cnFk2Z-H|<'AFo7-d wإ>eZlf"Zq.|$ѪOp6$<–J͇=[WX4b&mhoL3z S|6HТiƎx.mM2{Xy!Ze\V Z(g m؂}ks&wW0j52Ga,>q2s{Wg .uzjMO,CVRC[ uzH(YN̬(\z d̀ :f v:ucEk π~nϒg)\8C5tA1q$zC  DqKɤ_f2|) H]mFϗ1RN2 5ţT 6KIuCuu S?w0; &[JXdV/R* SW3eHfb`BY&oZW\~^Q;S@b{ݑT`97aRٳV0l+i]49S]g1h*d0qw 9?fƸǹXf1Pz.H>8J=C$W5}Gԩ˼hꜵm*wBeR/k([zS`*l8ڞv*d6-W ڋLqΎ ^+*-ՠWҰiI i/mQy 3DE-˥ G>P5@coYTat5>]es#2||%G5tkC+p︚sS_XYkτsكе ͮ+*=;=xv * ķg7"ӤvX|QiCR[v@\({˟);õn lɴЩiѬ iN@),{7;ڥ4+Rʬ34*gGʕ,1 gPxhび[Wp'2;*º>%|&p)FͫFiPkǏm};|X*f6w[ 8w7 0ҳYCkԈ`;C@o8P=k~`T4C6/Q+n&]4p7ܺ0F)[^PU>y w`ڑӴN!(mLь93tT0 chxxq>ܮQ"|`^;Sذ`fZзp&}-Ǎh6-;*?o)((JK &S猩sc:  ٱ]؊HDDP~O~99sD(ZNYsԯnsdF&U8{t ໠OBhzc̮2J 1[wLQ5 3ciMޓ4-I63}>lAW #1q0?Z?}2'0x˶e懲ckw0c˰7>gbXҪN '?à֊+lRW9LdBBfz5ȌD]0iQ%%W ɜGYn-1@~>݃l^}gIĐ gOۏ),XТkz׮M ٗ[>'YUwZzȹUdRAbA>J\bF=2+joAY 5sx y}rdRDwmn>ľQ;j]ϺE<ҁkq Ǝu@ _0&:*vϟjZ"Gët֬N]IUТEѠhPk4U*TSSjn*mcs9sJ]Kܾu {wgK,ZGG vo zO-^/=@Y ,8P(@=4Qǘg6ݫCQ\ZOd/ÖUB+ > &/>2 6DC,UK m(8I(fl"-Ti[눌 }AIAO;2s@PR k}J24D"y_o.m@ !Mf_O2#˙Qwҧ^[(Hެ0j>6Պ@ B $ꗜ}-^ߧRyzp %Ag,EkaHz:- V# @ |Y5,֞cKft+7ΧIS]^.:_D1|i$QD2P @ q(5R[=YWwx3J(b`VPH@I-LׂZ^ǣQi@ 8aҵ,C. IDAT$WZ^<:*1+p~$)@ZX>܉z1ic^qtGX0~&N?% )?zm Z%6qx~}$"еl ⋥RnͥM]T]>n:轿 P?gSYt6^%eİO[IF}>f Vѫ|^iR 6rԑgX9m&k%|O1Ȳq3qu9|N:s>Vd[[9$ YIjKKu^_хTV>}x @  5/lTYziBIC :. pߍM-0FsyTfS4-I#Xk5Y/gr)BC}]~iSB']:pX7JF5eiF0 &2w&T7=98 Ό^C,w194縪cxℙӴO\MR|fO Jt!#T֘~S]b9 iŚ`ơ70h5ɕ,&be%, N{>,3FQ.άٿ_9lQ!zQ(R!5j-BC 6>RmSQ>8N&s> @;U&SNTMZdDS$^F +IX8Kإ|J~đN/,J޺"Щ ՝~y8g{~LJu<WJE7tgm ~菟I+&S4jUF>\;җǴYfG4?F8UХX?*ztRrA!)~ )"ʗV\+MPZ[u˸..nhթ# !#rL.gX @C?B Nbю3 =TRV Sc HhԬ` xÍy?p~2ɠ(B $p|8w(RSiC!8/!I[4F zy0H I9swN.(^TJ[zz\8wG'd2)Z�M'-$$7n\xf@ 8|z<6gXx$tKmXMhp6d1{j)ZyJHcd;8,ג!AM:AH$צi~@]"C.M '}R qJ̊!2/ͧ N\uHh%ڌR \Ξ];)R C#1r(@ i~VRxr> M&OgG\<ɺOaAcXj^QY}9;n<qMTtw ;7eFE0&>;RC- Wb{.ir-[;;^D!xtl([HdƮn7/ L y:qrTܽ{;bbnxd@  gEAbo79Ш%{fxm.ؚʈqg96f:HH=xr'ZlkYd;fu1>qKJSMJМHm!'KH遲sxo;/+׳ ˇ¨k *Ck"q3gCytA<]M$\QO=@exT*ؘٙX=sgHUP(卙x\@ 8]q CN`V FCKWʛ@Žs[ {x~8#Uˑɼɼ)teLLZ6!uLY0mX^ƣGl,֎r@j`7J?(. 7R?zmcs=̩c(u ) RP9[].}9y(UjL5@ (_pbu0K~AAx-8_0NuÆfCH gol~P@Wi<'F*@v^ݞl| h ;͡OHoϹr(ע>AUU}3Ւ{At&6G^FA(<ĹI*"hB9 $(͋aoH I EQk>@`..XN6V.1ƻmb޷Ԃn*B~64*Mb8x;L2w}g79FtS@ P >R*"'X:0_9 %o|°IfSSx˸NfZrSG LS*3H8( i2 &ѣh !{-E%2 y5u %gũWtQ@+@C̙?/GRT˷8K7aӄ=A]q}9'#?8aۂGH/QbS`Y<ڷ rI2jӗ>-<16"d[k@׍  d$!عֳ3vIn=FYN^toPܯkEֵl Z+hkx ( ԩsT6{3%d /A=HvpsՀ0 {0Gme4_Wlu!?s92N(AsM+=&J'Ht(۴7CbM-1e{gҔ{sX(`m7eM{9wt(MiKNw=n8A]Iw?ƦiL\[е? "hn5D e4 @ ġ@ Ι˼p˜E_bE̺lKnuőL μ襋 1.uRE#"h"="_25QOq#<5<ۻ2wJ%Wü2. ACߓQ3k/,gN+[da:|VC\iB*VE8:|MF8LIH娔yu8W*?l屄^KYNPsg%'~5g{2ٝmL2A&+X /vfTWD?yyh=]pf3"|o0'Gn%=tQG\dƃ&g%V2҉ml_j ְ`a);,ۏPYcM Nu50hkCa99cvP{ _;(x~~!>8jPը_+i47Leָ8KS,FI96z2dmįp4ǗL`V_;ƒOyf # O`rH =~gBuw|)#<ٗRT&E^ m\Ã,2읥Lg>gj57"ʐꖠC] emv̌֔c֮ q"?bNh_>$Ofߛ""}f@E-R^45@ ?Jj8{mW6)XyiQ=Œ#ڹ baYAЮ<ʻC)l[.N5qRelec8z 7BNv  MLkH)PE^\i2{Rʦ9'ʺc%|)ąU*]IX H3n΂p>UKR"R7S)~܆֭>-ccfT<S ]Q!V}Z+Ћ#e,̇/$YʹOu )vbC ڤ7L )d-K@aFQcFǝ 3F=Y/8NTƱ'r>yC ׹"FeѱOu4jւ | ]ʵH5x]utjLao-zhVJ$ɏ9SoI4ڤ)zIL;sV5 ~k OcԤ]Ɯޅ?%Й3It, 8Ý80CE*eU+㔧J&T #`S&h3e(AQ*5#LhHШ,HX逢跌YWֳ"t=WɔyL;ײx&6yk = 1d2)h/ H=O%fEp30!. .y'!Ϩ7zFbB]մTDּIе4ۚw՝E _a?FSH9~2!5 o"b8pu &Χ}rQRouћO>q'?8)K::h'v FwYsSO`[;枹PtS RE!R|Tۺ#Vz?K?LZv/|!^EaƾVzb.Vv&DR_1$e:6-]$2c{@e䂛<Շ ;~^&Ҵvvv6d>YQQ/XJgGl]uXeQAէؽun K M&OgG\<զwZ\2kKuWutbe ` !Ԑ8ag}?U١~uT=ʹk1'ɛ?g\y7.θ&Vy!էD0k53븞ٗg& e;V@ 7b ?MJ?p1:LYzM|s)&RX8qjO\isDÂ6qC-a; ?.652}D}$aJ.QkMyi=f's@^$duQUhXݚ5 ?d/삭ycc7`VSŜk m~ܼ 1d^GUlJ͢R^\yyk@† I1tN!i,&7HaᆳA<W|NzxAQT,נ2tnfC MEw RPģM4*e21Yz]βgelXf^ZweähTeSESF oB߲`u5ky@;Ve͒qLǙJ_cI"W:M쓙:d¡PvI}5,)Irg9z`Dl:<6q䖸*X9˶a/ɘ)3"NTTf pt.*wbCOAl FlcWye6\uZz%e\+ GWrKa_Bdox5M N}~$48_Kxuc1S is @~&pΡ=UF' C69YaǪ~QvSbDBQ@fvDt"'*TYۿ'O〉Bk] {QV[0ӹFT~F&4Tめz|L/70{RFij@Gaه"5DϋX4c0Ŧv*vH2eoNBjL"-"aTձ;+J3yFer fhH,rI )mt?Yr,g, NW4-\QX8O;0Wi_k8 Z{ѢܭCN,6(/lJFԃ֓Dz$ga0s.f e $6TXF>$xv ә]?+T/j dƁ y8[X7ti%:6Pȩ%Po0̓Xy C 3?x`T0*Q>tnϘ>/l6Aų!R@jZ?eDt-2L52Ma[(zןeT 12?#RsEޛr 9VB ȋR[sH5XT:e  ̹:I ڧ@JDLnN-RtS{[gG3nHLB뢎PZ-[ّ՝ڲ\Vt-{pŬꌒM"Kqi|9\=eM4F"tu:3[u`VR[#<׆~LzƜ;+ e..3"&bTMgjj~M :|3J:73r4ğbD~\oPk]7߳bi;lSs9|'5ջ)aa.ijYů^oOiz\+nB`|ڹQѧ8;3ET?Y-Ptf-Cxg͡\N6pkn:n\;X.NI<ΒGëtRH$$i9Iyx}umLRbbǞ9u |*J6C%`NJ-:t`4.QW ܪ\ ~ <Ҧ@ @ ?&NԝVwպZ}`8լpwӶ="QbcD5ZŚ2!XƜdd}Ě>&5Ư95|6 K|xUqž6f"1ѥhӅ&}3oʐF%N'[x/ɁT@nUэu0s&3fVҲ\k*eݧ)@ 3nҬHT ȞT T $Z @ B @ @9,qau"" 6O & ɹ?[7 \tNd@ bZ@ @ 8@ R41z3Vp%^+2C @ ġC{ 3~EIjxtd/!NVl8@ B~<{죯޵벁'hV%찵2B7Xk$I9~0rZ"_HGR {QV[0ӹxɐ]V?_2YR|*yqv?gsLJHq@r=K>leV:"Y4rVGa@ $ZϬ5 }󬘷 etT 9pڦ+ 'wer^s/84Õ+Wٳ'g] >aՙ?PǴ붅Ѩ3oBxV*V+Z[h~ TF>*`Ԋe+` $3"!Ïq3R3-)W2;v܌tեcac7%0K¥PV4ɜ@:x>qr*!{~§Yz\T'- ^&8֏G!W+3(x6ϥhWJsMZbt9k43.^o҂5l* HOM`QxK O !\"8rۮ'PGDTZ[ 1GRq)'%~- fCnm8y,ȵHĞZǠ1|k%!%.F\+_b$`A8Ȭ_J/$!%4OI.jrYlk}ZǸ=T"/c;:Y$@OǫWw...#O#)wn1:ui#6ovxe;%Pٚ3hͪ> ]ʵH5 fpѻ]K>{.i]TRq"YyCEĕ)]u|sPi*gZfFYBp&"5"\$k8t!)J I`\Xۙ©ĩU$]N_<4o%zXپ>^~j*cP}LdF$0o 8\3c_9¹qh>2yp֔(L)8SZ K} ]ah^G~wR|eDkm;{d决rD{Bՙir蓤WZmn]ZZ@M@uћO>q'?3͑ȐKAք7E[1`|MSdo/*5y IAvNLǀjCqfHV| T&3ȉ i2|MݼЗ001F"cƒC5)7Lz7J4yӕ&Q¶M ? dbUX&E@ϖ r9իWm۶k]dþW8!DѼd聬$*&ڧxMK!vJ"ao6,bj4!vWb=4n%a^'~.]%+:2 ,ݝ1H9Ǒ;TLU<'Pyh~uTҞz 48ae n>%Ik"-!\ȝ*z[iAqfnݕ`].>!^E`aD hrX ?ɬ8@{UUraݏ&U׊r0=[ aY2o-@t5z@ţәŢdNToOvJ PZI_Dr}?6X(6'GB ILm֕.]16*k'hAיS&L,AqH1r!:s*3k n<"0 賟 Sҵsxm'f,sO6',ЭǼJzXm@g֔7=[a* 8ٱ,Xr)۬/uǨne~h^rh`f ' P]:*Tc99VlY$J1vJib7}x[ ÖE So:ӧ]E,ڔ b_׉L'-GI<ܿ{̅?S@)xfr{9T@toA)ciCK,^)WXy:No6sNhbMy^}-Ƨ׵B+VUtA ї3{&v(y@2o޼<y[Ex'|[1[~{V/kR[u ΰfT}hhG` cjtm3["mz@!qr'Ƿەsxo;/ 7[k/g}ٖ6 9),J);9w,bG3ωKʫmJc@ lF)[4+&Ud3 sģ7bZjNF) e[c a2n*lVKWLaҊ|jMQb^hiבץmtI¼W8rԼ*I;Ѩ5ʄ܋~Cowi"̣wEgEM3ɗ&{G7wA'd5juov<JI,ZHOw]{8pם}xYL |)'xH-,djov7yw`i( ҹd?2o 3B-ѧf3pa ЪV$\FAjf3;d 9~6M2$E,91q2ë˨8&,BuT^^<ʥRt *urn ;j¹t1fѫϳ%d=cZdўR-1g Ô0vZٛѤtqՠ֨X>("\=Xõ D)FsbveMr`/LcdÙ5 K)o$@dz~KZ`n Y5`U1y'5]jKbrb#1.4Db++ y~h fS<.yӨըT&EfC^z7+dRO{d>@'~[HDN \ٽ]9Lk^}ZmVHY.t%Vrg&@ 0_HNn8ESwZVjqƃTPmLD 53_7Kr fhH,rI );֜6{ |kʄ`sf2ofa=kP}(ZE,PȱJ@jN_sjlt,=mD>=c~%DK8 M-bv&q[&tͰX= -f<@* 7ªdyƺt9sX3cSAiYN5sH(7A,ҳ&p:@/LVK)edWo=ƺL%ʽ^|~d)U2prGfMz dRx{қv)%E|(_Z.]vB- ess+L+25wLrUNT,̣d)eL̫Xuջ")?!X쾕Hn}*1bׅpRd5$AgOM$Ư:/b _w/J~B4_ڨ2et*by&Ne'8,H Qg~䔱6XvG~|0_gت.C~[0!Uz3{-]KM܄w&c/=rķ Y-Cl 5UbZfzogCo*L;x8hǸv93][G7 [&yEfVV#jDr?)!987bFd?(y]XC+S' EjLm<<ָdH0vDqNrA?) ag^Ƒ`O,oDNwK5ٵeTi5[Φ[נ .g\1%Ue/B$ r1@楳XmȞ1H/ҜHήYȊ=<uQWj8UռޞӳE&E?٧HU,\{_(Gl++(^*ɸ.TߐF jnDO!~ d1BPQ.RBBݿXx3o[nf솻UƒØ9_bӑgy2cL>;hԡM x8Y6Lш+cY!H+PѢ}瀭/&?i޶6eNQeI݅wxį]{}fRཌྷ}M *(զqvqΰ\%!|E [Q3(.G&cjCܜ\p~w4lĐ"̈́Lom[7ɗ:9` ʢ_F69$ X%8oM6q9ݚHs^ OE`fVZRx_jՊخc|-m+"vz{xk]q02QZ5S'(EKX%_6bbOPc 'tY-ۢCOcֳ0ͺBp8|L=Ɉ;8v4/f4pwculm_!CRxMf{SSb)gnǃ#\哣yK0UZgadGޟ^ n:W q(lk24T˒yk4=k<&s{QV_:_O'ثzQVMLΔ+0f.tHFEI xłH0@h IDAT÷ zW2Uu%*l1ƱYXmB1&a.gT\0D* ]MefrxTwJk72a0jW#Ytg*X~0_H/_)TcuJ`\S|h欝A+L*D Hӡ/gu2tWI2 1h RC `f% ldƁDt(p~5jPL~aTړYׯ}zuMzdvGdP![r}awO OxGKnO\:_޵sK~e|NRbǏhr(;wrD#˚ġ%F\ۻ=THt:i.. 1EU4>}Dsj*U}rSy|.(k!^ P=98؝!M'4QQҟҭ>Ji Y1G67U1+Vs qwγy9i=͊+7$\^ L.{dv44OQQa64d8|.ӣ%_dF>,3K|q\"ϟIi*(XLHRqQ0RZLZ?jbfMQeO]>$amG&\ |ʭ࣠~U70)+愣Y)WY2dDg  Tq_*c@ %bҚ Wm zN֟@ 'X9ΰjJ7^\~$ؐrm;n5*3hMֽ6.nC J֗} 9"ɂ/U4qӢt$E`jTPjf/:ǝ45!p[ٌJBq xn} .]2S9[ ?Pǟ7_ @5@ qĕM;y}⯪8o/X'%Mr0>v1IFp. e:t:@60]MшG"C.6&cրPv۷ỉAriP&x/4j ^'A&6=Mz̚V)m?J~ l,ԚSIss#3|i8~L5C %aSϱa @7};v>΋5fwwi1C$h5<#@c\w7Ťjߵ8y[Î\խ4 'cYkZWHE B>$&};DŌP3}Z&C*^((?#u*TEx}*;S#uN-==]0L=T5*PjEaJ7sµɯd[R\-̤r ;Z(s'@ 0mӐؓ8Fp x|mF i?"k[MecNfA%YdyX|y3[qC c:cw`Y}[o ~n:=T\Anx9/ӊD>c()$gEi)*j{dͭo˂!XnNI]4fuT2R󭹸g8qcZ7TGDK'V7Te+0ieOyҷ:l0 S,+%R*to÷3RڕF H|g6D2x7DM>~*b}a4\O@ a~Bh9)n=b'{Gen8=ƴmIԓ \dO8#ǁQQ;L⻯Wт?'l$jTuHS~ 1gfFm&^_Tch3q@ʵ* @jI81j6G=*[!zδ60Vb2.Go#w|7a܀U1(p/'2M+^/|Vcw:͜91i)OWϠ V*,+})ȩ2jl¼ X ki<=kӮ?B?)/4u?2ԉb,yS'}:n6|z~|3+ -+L8h4-(qQ|:UP am%ۉ@ qur/Gdk>ڷuWӴ0<)!,:x1) +߆8A*ˆ1hI9Ӗ( [JHacE䣗':FJ>4C ciTu,al ).{0uVz̢8}K%0.'dǩ c59kW[LB> гnS}rKaP_Rr? c){L>rJY xM1raTfM[4)CqM(n&Qv-&2AaH0SB[v{YGJi<4 [ _p7sDO$|=Vhaڔ]yi:-ͫF["A.2nt1C837w~LT]#MJS>o|M<=ɋC3?=c5gR؇0ZOׄbvYN菃0pZE?]{RT&E^2퇔},QY?|i}< ľnnc 5[¯ɏhмTԠջ9 S*o5c?Üʶ#ǒO.ߛ""͵^4=@ aѣ[iӦ(-ZPiyGcۥlQ%qe۟DЬw@`TJЧ$l\ Fw G}p{2KTL(}ZX[5v˸'+ f\=2C%{! ūYe,АBR\I =oC+d8PJcNEf[eg4=S3 <)=PE]pultP< ?gLS 5 oT)ë )8ԷvյBPV񧂹y!;ݔ1{O#6/n:G:>a9> wMKg|k6MX}%۞Sz|}aV v% ϭ2݌_;kIAkI ~xK+㧴^SѾ"s}]ؽ;>i'z2}LKhPU@Wޮ7TGG 76i  n>P™AgqX(wLߔ Μ6 y% N=˰׷E_%=H^ 1[At+,ɱ[8,(iii|7+W.$iJ%#v2lO̶62/7g0kFS0M7^ʪnhv2r$UsaǾs&C}A"> ,^ &$dH^X/7#,e9}N>s.v]_@Gy3H&&EFг$scp邏+K1 -M-Vp2*|L;Po&Wg#P\Qk6e~ϸue9k=+#&U42oKDB;dq*TFn7ޙT: mnbn\Z:7 (_tpLؘ,{%rS5ư:GASw4"t'g Wچm"v֮Z8Y_~^ޙjxiP` @ }}}.^H޽={v`f6~IfHtqz64t(nnƊ(c.c^zQEN< q+ vprs@kR~WŌSTt+{#S'%rbbBh ="s*EKs෯Sϭ`mVfP#4acEzoս͝3HYCBXJW ]Ƹ-W3)ю.J$>OH$Y ;P9rvuDnڤ<^rx],}m5n_ɬߓ- #c5Fh86ײv#)(H&6I>?<X;nPyNHPCrlg([9s64oϼɯ.uo&-r( J7sµ>ڪj3Ó64JLOfDoGF}(]>+tdO:"p#P#U.PP~xGUhW'/kش`Q IDATvhXҞpz25uu X[ͪAԈb_]Ԛ^ǕvC]Qaz{o8|_^x۷W^ RҐ&NkY=G)޳6dGYP.=GȑcV:Pe)"[aC<5M9rybEšAͳ+7Imr^jt6>gBX.< 6DM>~*b}a4uV3%78.sְhuSxLZNZC1l΁I<[c% 8%pyzh. VK߳ xcZ,ݎE-w,Oy<0$q\sv|逇 g Msv'!34.NV~]q3IeD8j|\p$hG-aL4q5s>M 7 FesGnkX2([XIJs^{lʌ1{8v(b~_7c>.ʧGڃti=m̩iϘyAJ[8O7?6gE =t 4({_Eڏš$Q1MT*͛IyZy3)CJ N^*˒D*CTMHѥ1C>$&Ӂ߆LrM (C('clӖ1ejz63υi8ưh0vcCATBʃbΔs9;{Ϧٮi}z+a!О2_bHU\W {[gǢ6%7;ARo5ɴU܇jRqȂyحN`xR<7UK ĉQ?0P!Ճ 6f2h:c[>vE)ϮV).Jv&8jT(m8-Xy "4 3åF_`40)Ipȸϙ|mN7D1cԼ2ݿ., @#]rGɀGNS #U|B–СҫYCY㻑4m J"8VPRfL 3g*l'0,TNVeZ8t m&"bT?ȕXVZݣNHXT ,_gj@{ ߄tM\CNB!VnyA}Zv,S_ :@ +A.'00ۿGj)֪ºMyvj񷐂$?nnV^03WxZ፨oP*rY”nݍG[ W8y[Î\[i9jp9ɚ>t!?.+yqt>*1dn#d{S@h[ϐ(=i5'ZbP3jɾJj’Mjl!x;7 o}_:é}w(V3<˷NYN9WMy8MK3Zt踕By(Ѣ4nG?w]bBˉkhsnegG@׉t淴~Uw'E>$Jp鲆]'Jw`BDZbՄ!sduRff0a`@ᩖ1{O%=ԧ7x./WNtLۗyAµU{qhp+8,0+9m2khK1DPcPJ nn p][qF1Vs(?rze#Fqcvgw^.K%%lDKg-'s :ǽE{ӣMNJ5"o'4_Z:cňtoIŢe5Tln2ۅP-#{LD3vFST)EmM嫸oۈ.bmNh_^tt14t$~KRnVӷѯԕJ"@ 'ˇPy;MW4v~㸙[Vwj;X|#wfEJbG"HUUzeI ,!/Bcy2ak' c UR|.ȋ4psfF0ofRa%AءdPγPΜ˺Y#XҁʝӨury 4~1CCRuƗ72{s HMq,ߌQ1cKmY-*t2=!{h|#Dݗw_F 'elL;Ν9IՅόC[{~H$$i9Iyp.%IINJړ'0PRBEQpǏ&ʖy-J9I ?zʪjBg@ 0@ @ C@ @ P @ 8@  'J&09cw4)&ȨbQTD(_ͽPok \:Z(A V~ "3ZWŤ$|}?{ h3z{'+7ZT5@ 0bcEJ*TxK[GA95Azy%k#h=i ? @ O r%aզ';oٹ/2-å\[-[MJj ;wu˻нuүelF6'{& TteRҥ )rS{JJU6#qO2C]U*T@tÆk 2μe@ +CO56W㪴1OOsC&`/aq5jxq(w|=qDX=L]AX <hbt9Ҝ T7, 1(F.#ae";gNaƨ”kFzנ0r$)1FoӀV.#zP>{btF LC̕\M.G (&ɍ ;e0W+TyʴR3K#pbFf0? Qxh(*46n,l爞k&aY.6:Iq6z5GbE}h@^).3z3|Deѥ?2kTgTLzkBqc5(%Wc2h`TI\hI>ʰPzEY9ױ@ @ ÿ &g,E\kjbݶHuwGB+|P\+S&,b띪tvθqCJqLn嫸`%s JAńоZP6&7N=˰ KWעo*[YGM@i7)xThnO*WHσW СrY@JLWN gy1bo*KM4/8br!43`_,҃7@yO&$#ual{NRcp[-`ەD+O`M*pgkr!q\beX/ͷjX @ DlUF!nxҴlJf%ŪLLD:;gs%jx"sJZ"6gsg|Lƞsxb(HÍ5kI5Q$MU)*êSHyefFaCy.:o`5tTJi2X' s5j/\!NIQi%9J q'-qMV6b?=$6gKLe('grV<:7Ա@ @ ÿ$lc UY~{3=)o!EIү@kCd:GASw4"t'ȐKAǵ5 mnbn\Z:7K&ZCIɤMOfJ#桟: ɐ?\o!zf4Şa](Z|K+Whr`br@E>{;VYz&!>ф|H {_$,ZĢW Bi[4C?&H?bO1C$9Go1vMWjߵ8y[C~.ƽ'ei]Ww=l߸JPCrl29zxLIog8)/r酂_wNY/ܽ,i%3]{D{WzX{`z7 ZQ؇ߒP )e.Bp^r ;J(Wg[ cc %Y~LPNHG8p&/5h %ءSM ޫYxɕG8(~$s9~9"8@ @ bOnP+UD6jO7pyA\c݂h*`s5.џ c4%Ò˛ ciC=S̲ޢ=~[fw&' JtQ`}V-$%u.=%lޱu>q=%ZEm5@{Y02)ifrѬjBVaa38v̄T n&쏈`4GS-O2n9P5VNaʞ4C{(1oZ9ufٰa7IQ1XVK^(UT߆oW f+JF1m胉en։B;|U"(+Bi2yaUcI?i(eBXNd't}ƮAsݟ.cJMwS5wV}CEVvF^TM4.CfNFfJ@7DK [Ğs7ywOF[ĎOHAK} \G M\h5$@ v0;8Ý-ə돈Ӏ̾>ú㩯ɑ,X '%rL\)[ֶGhbβa"CF)ߘ_RBV@?{}CS/7FIv:I1J$sS}IØ}1ɀ҃ӭz)4ώ\);O0IKZI#xG0r)FƦХh𽅥"U XQTcU5hc+Ƃ{h+6D@ z_vT Y澮wf<󜙹ߡYg9eM?'h2rBʣld4_7(Ni \BgeGǟa׆>?CCZ./TV4Nε*Պ\XyV04i?l NC#@:|ׇzM:ѻe޺$k@ '05׶l<@ֽTTtilMa&s(>iܼASgF@;*;aU{wɏN:<9SY鳘~tP=apkC1U0IЭCbî8DNc[78z,dM=eW:o)fXp|C,!ޱ>sG''7| ŃjPF)5*g%RdRC|fIg`Yrn4IWLkҹ:[ѮDjZ$sv\B-n`J^>&y|n3N##ѵ Kr쨩kIYg܈]nCk!K)[o75 hcS&UizHXb62 O*W, <ߤZfx׬SCUqZ = SeҔeDH[t'le@9~}q)AyΟ^T⎂T,Ǚ8r+^hru:]LSf.[}f#8{7fVBd,cMi퍧btkp6V/*jU^>og#nbɮe\W$;ޕq7rڣ1]%-7nG9J|2(c8t[866ԬQ9G.g+m{/Ltq|oOTT(-VO>qI|IeltޒG}`׮RBQ7I.zaYlKUQZΘƓpNԯyY"Q Ŭ[@O%9\gDžsW"ᗯhK!M3ѠWE٧ʌE IDAT̓kY w!C ZY9E삣Cä^o)-[ԣI.?[XA&%;Z5+%:XYHȸ'˗nĵ{$(I2<KاIx;m/8Dːx=}4)W]gFGO gm :IETވ}`b8=s(7GGϹM,>th¨Uq9&2 uіF)y'~wm}SmL=%HPR%h4 Ku:4 Hx+}گ{~ _wy|FlLq홻gEӑja[qO%Ipp+Gg\aޘc݆#0%w;tdЀ|3܀2ZTF3GwsIıW?Q^ ,S88mc栍lo̤o>еr\sUW2(ǫ V;ɝnD p$)ga oGWXQBW 2S ic\ɷi7NFcrS:`΂o|I12Q<|V#I*ғc戎Qf;8e\}0(| ]:6 7g}Pk4/cY$ܖPXj.sw~ј*\pvsAIVTl܃q!sd?6_"UaMkx=9`g+Ru7kݗ} Fs1}mTI`Gx䂇ſ|#%]쪵I ܄;w+u̿ CZ]9+ w83'` ٿɿ_Ь쫬UME;5"a2We=pMcd۵óu;4DgWyad]Bes=?`=Vr[Zy!B?9sѸy}NKL8w<'d׹RF))WZXy}l}+DEɷW-CStg2?:42'nxa8wOqTMÅѲb^b@+ٺ~gFÃ| )L} g~udFN8dsa& ::^>זm):Y.4N˖r%bZkxd#;^"2:[ atqعEy-ӯI Q z{2y_(0Se`|۲ѳvD $7]8i"z=t ; ѳhTŬSHؚMu!poWLenөoo6 ;ҹF +-'@(.%+Q&Y'T 31kj}⁁L2+ ,1x .cxxo:E Ck#$v>öug$8T"E7` UY~ A'|ߠefzzD'2/c6hc+AG26-3UβͬL@қm-CC,_b[bJClv(G*!4uL!5A_}ʱٿZTȆ,W2]j}pQ(A3cKDbK* [vŅl\r\vp{&3iOa/$9)ag]Pt+G`yܽtsxSOH;=MGd3zg {Qg=tJU?*"HHy{Iw6_[QgOG{9qM2Ùs8W[0sv0:*+kl+Nc7XTWliWAkO$ D}ք_:OݽۀUJ4N8Jzsco?~ 6s??A>ep6x]5#o?p/5v3Bu62Gz+z I}{m@ɭyb$df4>-]-ECCq݁#PX ߦ?ײK@ut[]IkK5io=$M(/#6.o[|fáH1piƘ9C yݚ0qziQr(t: nc D;/C(gSxSY9z16CZ.\TXQI7~7=Xn"5oFo2 3qer$¾1w{Ls@ 5GpzLl䋇U#Wl9s"JBa kYd H?AxzuOl}ʻL xj+Zv JϫsIQ4*V| =?Q_IQֽˁ%s7U7Vqk~R ʓ޷ӇbPn ˗IEa16Cy 2&2F[_NHi5<@K wl#-X#Z[l܏U<$Kq{:FbN/;8_ZMas1r5!*˦0w 6Hss. &tm%t,U.e ZLGrVOn5~nTc^Ln%6Xˎ4ݟ<>c&蠊v4tQ0m$Jql՟lIs}`JwtDá8i5-`]&cƲ' w4b)^nPx mM<ЭD=WwxEBF/R:ràءl4N- gX??*X=-vB+1hT{[-bסdԭuהv1{OF(JnM üB8{Wg5E/k97KY%kCqR^b}|d_a%}L p49ٷ6 F 5]Lqg -qH9>a"j@IKqݝ1V W:b -Qqܟ@B-ܒu-TA8CdJ\.m G\]B _ZŚHSZGW]nRb t7ښ]'5rǘkr}w)zLWxűbOmL$Pj)n4S5jV+6)sONta^Z;Pq hX$zepvs}ϟH8%?V2O:oj:UfVr&>7իTZ%k lVNjP- 5v/q=;n|JW0!.>+zԫmɬI2L J>,uT Co hA%:>iW!ƉӆDI IDAT^T`pi'L*441oΖv[9xҦ=۶o%P| yI1338w3ETRcx"x_4Xp-u%~-#yFG?RBBr(yt O\'W-9wa~VG)ECz.[ϡ;g+ЗfE&T'8T(< p+9DkǧiEn?5u'G1(٠NXp/yHEhabg $fi@OZ oHRyzx#9 YQ{ 6NвkᇽN mԙ+jz-eco?UHB9 ҭ;?,iaGMr+3и.nM7^wTC|{݅4PO"NI!)=U`& pF[wN sg6>T7,&]-P biFI4@RDS{їgG_ȐKA]IE;0}297l— gZsdRP)U~L 4%X04_3j6== ciO 5*, r ׮bȯXQ{OkCʚ:W72k3YQO@_zGT* f֓ n[pzTߺtcs/gRTJTbP7BQ%Y-,-4z53bA@zpDYK06bbR^GlKI~Ym6d~M6(p3љza(}}*|֕[y|e LOz#mAq.j,cZ  5ޠ3%~iQx0ՙ/gAlLDZj1nB/_nS{O&{1s=m0uLqa1y2 ?9r(oZbN9sЛ3F.3D!0l6Mg*ȌqiП-0,ſN9Dͼ˙>tJ-K cA&X-WdoZC9v,5}``05PP~g-;Ѣ_T)<]Hז*ð:YSߗ44=ʥx:~eǼ$ԀқfÆV ]e%o!le|,n@QtKMjM*] ˂\=w¨b6[Aߥ%? X>$I%>w1,j"iGlF [0\":nA?T?UDD"[Kʽʿ?4nފ̌={8 }a[\Y{ӧNe-;gZ։@ݯwtf&4xʍu4:zU?{wvbjjVbJRb"}hAFzNV`=Q 9 >2Ĩ)ܹ9X뽟3TI׉xB}oBݢڐ۲cóYWS-q @hj#LˋSj5s(>tRSS055+t!:Q:ԙǵ=$[1I-KnQB,Bƻ~mCª )4 adjJ“'>s(x Il~:a!?@{H-[p0 ^Q@";KBX彉70@WWJEFF)ɘao(C@8CG NjT&ã7IܿnA\!gWw Ppǀ5 F&B@ CcF @ s>_r.P䍢e=v/җ|,F@ ƽ/reȼ¬i7|fگ:p"&o 7wzQ+EQ@ |B9۷LN8Z^&f]cDv^roI;fxw=>1^krI!@5?`ď?Rw-d2&O$s(x=ܱsjCWW!9.ZNtbps/'Du"EY|_:+VZiii|ݣM ΡuѤfkg,^qA"3z..Ē#~ͻѿG}H%NfSDKB TQӢE=΢/"Pm4d&dr\% ] M)y}GTH1vo@>Og ~̫At fQ$uފ^{R^Prk~p('g@KWtK1/+b'7oBk(I@_QHmPbLߩ\Ƞ50?}$g&X^_ˆQc@ xddeeSA;f ZZ{YYHe2!@ CKs}k0l_):Ȥ/ww4'65L$PU-h?i ;nաs!תjV1#CW!0*0*M̖PȤ 5-Z2Mzdψ3D5Ҷpy(0KU4qt&% Bwïi5xR/|v| GާOy\7OtEn|J_K!D#;߆Γ˫XuՄ^T'7@uOg1./E1|B 9Rc#_ΈtP Ԯ] {y?#XڲS@&M읗)opG-eۍr5.5=C?|CcM ΡeȾ;[nr:ii϶[2"C^R̫ Lvpƫh#ԩ\xTu%~-#yFG?RBBr(yt O\'W-9wa~VG)ECz.[ϡ;g+ЗfE&99R=lLRUJ2/G$U]CԝP2eƠt:g:bGL[!@ CA bnb vOhև8~jr(ꐼ;4h4:Ó2/=QS08$#!Z]c/2w`0l;2drnFل/δzɤRДd2)sӔc|isѧΨ.p)RJP __cQE"o++k>xsRRij5׮M Ȑ$'%[vV(%Tqqv`G#/S-F&ƯC!((DF\%3='7LL%''ĄxbCOOG'R)ZZϛƩGXO,*rs)_Ĩ@ |hi)ÓG=}}m053B 9:O/o"#r;"H3۔l Gcu&)x99*32PД9[4T\ h6~=/J; 2XX  3J"ERRpq+z%/ on23Y2F;<cE"ʃJ"==7o~ںu4RIZj* O(C /\,LVG}O?˫T A>^qDUQ &}ɨEiF+^favv/!J9cNr6BKK`}!]IѠ&2KPt;DT{ Jb sG]RҨjΝ9L*˧+bΡ@qQᫎG\L\},̭CKQr%EzB>pW._DKM^xƒA dUMQQHKMMfdRi<8;6l B6Vi]6]4M4U]?/:cIMnڭ]U&i4b0{/ \OrIt}]}q‚%͏i~A9noC`d>srWv]ɲgN?AĴ"q YփzUSqEM_c۶N<>F '~uAm?|_j~~^m:ҷ(A~˩TÜ.b]}:uLGw[Җ%~qbkW\1t&Q6Cü+dЖʲl>ҩÊrl(_yE/::۶m@sGHl-[җzMMƕTc~y^YHL+HSO&d7/㦷g㲱ȯNvבdg8/ /.-j|ttGfgM ?Yd<Ʀ&9r:fnzlu#a{7U/~lx0qcuβ\Jr纎;kzfZemLc8:w ?#t{6^*)۬<611Xmүy31>] Gv::֞{'U[[F_zB~DFmu+X0kc8(e\5bˑ-I,KdoR˩ԎcOܼ̱p87xCyU|k|Ww.9\Ukg귯VT|ĸN~9rWNܤ٬Y'{ز?~-,̯ ?͞˶%,;}.eqXYǺ{ztĉIׇvtH|>ɤ*VOzЃzݠLBm$YFrs%eV'&r_a~ +8VNeXJ&۸'e*?|~~^z\b(IdRp*KK:{Bڃ㮙m&La@cֵJ~_ӧs U}Sq߷Y[['Ny:\:v(4q3Jr9nU? Shblln瞖Y'z4<GQ*** tt] o^Exd2{l Q5I7o =;Yg}֕s2I`:F߲2gv*s<%ƈrRW#Vn[3SAKmr%su]E__544@w&9ܨyΜ9HuD %c"Ѩ`]{vNW)k14HMMi۶}Qy U%ʀ8RMƛ[Z50ЧO>8-I24ل;c73ѷJ e(o\(SLȎ,Gzjw1Ec KN&zUuܩp~g74} B%M$6?[`[Z\N|}܄ 79%Uc˲rP7G .iOXmuuU}cᚼld 2ycu\-^а/^Ǻ -!tGKK Qu4Z7MS)Nk|5 nh+@0h4[/DT[W~)߾kS,ӡC%jzUs3͑Dw=hTU~ ^ӅX;Zx< jjhT0ԍT~XXR)V>9|=9LR$P漋 DW+H$C!ȍܾ530Oվ%9M ǏPվ@n?ePF|RCJ`[ePV/%gY) emeP&6ZJϲR@J?U>IENDB`./docs/images/test_stages.svg0000644000004100000410000001474414002063564016520 0ustar www-datawww-data image/svg+xml Setup Interact Assert ./docs/images/test_pyramid.svg0000644000004100000410000001146514002063564016674 0ustar www-datawww-data image/svg+xml Unit Tests Integration Tests Functional Tests ./docs/images/otto-64.png0000644000004100000410000001114414002063564015363 0ustar www-datawww-dataPNG  IHDR@@iqsBIT|d pHYs>>ftEXtSoftwarewww.inkscape.org<IDATxyT՝?U}Emm `P#H! 5u&]!1b2ē1O N#8hh轪kyW &9_}.۫;& RH)tMF[l7O

hmm;XIuFǤБ@%f-VUdU@OOOmxp8L) =Oޅ]wOq>岬峅*?GUUUZ^|u'[~9%Iwq7g(-<!##~ם\Nځ!oO&7d-kIpW ))|_xכۙ63u f}%#PI׿Yvn-c_"'({`x,a:΢ %5k7e$4sꯢI>H$bӒ^h/EY-˂iJ1Y­ck+]=}s?ڏ388磼a~aF uA>o}3]8le6(`ɲﭢ\-7~+In4- 40"ݧ=?Ï<ʒEz=%M#k!ڎPheԍDWs"@h1eoʍ|!6W2UȤ>?zPJExulYnkĢ|zщO;50cڤc ?  oO̝p2ge#Af)@)3Qз*i*z&(<#;C~Zf6殯RuGAƀG"NZ7rقg e6-P`$JaO. t'/[)%O=Gy77D N[:NI{a<ǔeKp$q^(*Kȧ|H KpW0gXGu p<۸Jjy6 X N'͓q+):i;^ϵ B0u~Q$IR(#(%]V 'f㧏#lhe >~|xJ^0=EhW_HDuO a18џ 緿}:F(* P1zSҁW+[߸.jJPrMDD "QήVST 4K>Xc Y&3C@`>XˌL8|6IX*1M$t2ဟBHX^q#,+IZDF_::2P@P΍2{%OO?5SLal c,~b/E^wx84ad;Ʃoht Ϥ{KcJ)̘yı^uSaJ @I53uRc ҐIm>9@uy)EI j+iS&c0MIwO/UEH&E!7g,p~"kKs{EWdt4A(l0s(*6 1l zn .K][Ok}-0uVSv)ǔǝaiKdFQ@xVBa1-f8uj_;ڒ>' BU@LNtd4KbJo}p[ܮ^2M=Mey ?z% ['SbBnk2VߗFݣ2-қ!Ba"eS)p\:.N݉ӡ7 ,Z Scl9`!:b2t4ݍpph8:N]K/cv&4&4 MjpyFw4-z84SR/j+_WC;oR~G4ϼZ: ؼy3?# l;{|'YjU}ر n=^4gJIReHat;ٽxNyvٟ555188ȎsUSS%KY ufNkY?zD"a9u,BH8ӜʅFww֬YCKK MMMpwZօ1n8fΜO<x0i ۷aF [#)o$& (4GDs97ۖ{1hnn >w/K466244Dggg|wns0" c!$R99= ز2?7̵9Eh4G0 1 30033f N 6OgyӦM :L:M6Y>sMwsѼ ]I$02LgO=|HlpfY{s|+ 耊)a)/ફ~w?¶mۘ5k&LvXj`=zիWi%%%r):::R2egXVG"xl`ݦ!}DnJ<@@N_}sa(URJ'lyvMMMx^r-ݻ7n$:UUUL<(UM$2høGp&vfl0AG 1>P=!כf !&vr, /볂8t=ryLs iWh J 7۫:AE֎jGVZzJ]lA:"4Wk켒SaL bcC{kZ] fX2Ta79tOxMgߥf@ PD p-w<@kkkݍЮ0 cI >AY8rZ6n=|$y욆=#H9^bJ+Je[R0A>Eqwp:kR؂Dh7=!eB1xݕ8y%bWj;`[ى焂 4'\4B|/t6c #49).^u 1<&zp88۩Hi= ϼ8aNLLclދB'УhZ1G=@(to>ߗJ¡{fa |ԧ! #eI xc[Ux6"> f|<-+D4N]? M F#3opTG M!D!^N?8+y}6. )' IzAf.|y;ph.T<0}"W"eG?o\}ŝ'7=qOYG{go콜H2S#0EZL]{1͏I׍+Ov(,y4n],+]4/]D?xeeN__7J*{(*bB+ ӝ@B- DQrzk!JُQӃj*N`_ that caused all Boolean properties to be exported as integers instead of boolean values. This in turn meant that test code would fail to return the correct objects when using selection criteria such as:: visible_buttons = app.select_many("GtkPushButton", visible=True) and instead had to write something like this:: visible_buttons = app.select_many("GtkPushButton", visible=1) This bug has now been fixed, and using the integer selection will fail. :py:meth:`~autopilot.testcase.AutopilotTestCase.select_single` Changes ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ The :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.select_single` method used to return ``None`` in the case where no object was found that matched the search criteria. This led to rather awkward code in places where the object you are searching for is being created dynamically:: for i in range(10): my_obj = self.app.select_single("MyObject") if my_obj is not None: break time.sleep(1) else: self.fail("Object 'MyObject' was not found within 10 seconds.") This makes the authors intent harder to discern. To improve this situation, two changes have been made: 1. :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.select_single` raises a :class:`~autopilot.introspection.dbus.StateNotFoundError` exception if the search terms returned no values, rather than returning ``None``. 2. If the object being searched for is likely to not exist, there is a new method: :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.wait_select_single` will try to retrieve an object for 10 seconds. If the object does not exist after that timeout, a :class:`~autopilot.exceptions.StateNotFoundError` exception is raised. This means that the above code example should now be written as:: my_obj = self.app.wait_select_single("MyObject") .. _dbus_backends: DBus backends and :class:`~autopilot.introspection.dbus.DBusIntrospectionObject` changes ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Due to a change in how :class:`~autopilot.introspection.dbus.DBusIntrospectionObject` objects store their DBus backend a couple of classmethods have now become instance methods. These affected methods are: * :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.get_all_instances` * :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.get_root_instance` * :meth:`~autopilot.introspection.dbus.DBusIntrospectionObject.get_state_by_path` For example, if your old code is something along the lines of:: all_keys = KeyCustomProxy.get_all_instances() You will instead need to have something like this instead:: all_keys = app_proxy.select_many(KeyCustomProxy) .. _python3_support: Python 3 ++++++++ Starting from version 1.4, autopilot supports python 3 as well as python 2. Test authors can choose to target either version of python. Porting to Autopilot v1.3.x =========================== The 1.3 release included many API breaking changes. Earlier versions of autopilot made several assumptions about where tests would be run, that turned out not to be correct. Autopilot 1.3 brought several much-needed features, including: * A system for building pluggable implementations for several core components. This system is used in several areas: * The input stack can now generate events using either the X11 client libraries, or the UInput kernel driver. This is necessary for devices that do not use X11. * The display stack can now report display information for systems that use both X11 and the mir display server. * The process stack can now report details regarding running processes & their windows on both Desktop, tablet, and phone platforms. * A large code cleanup and reorganisation. In particular, lots of code that came from the Unity 3D codebase has been removed if it was deemed to not be useful to the majority of test authors. This code cleanup includes a flattening of the autopilot namespace. Previously, many useful classes lived under the ``autopilot.emulators`` namespace. These have now been moved into the ``autopilot`` namespace. .. note:: There is an API breakage in autopilot 1.3. The changes outlined under the heading ":ref:`dbus_backends`" apply to version 1.3.1+13.10.20131003.1-0ubuntu1 and onwards . ``QtIntrospectionTestMixin`` and ``GtkIntrospectionTestMixin`` no longer exist ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ In autopilot 1.2, tests enabled application introspection services by inheriting from one of two mixin classes: ``QtIntrospectionTestMixin`` to enable testing Qt4, Qt5, and Qml applications, and ``GtkIntrospectionTestMixin`` to enable testing Gtk 2 and Gtk3 applications. For example, a test case class in autopilot 1.2 might look like this:: from autopilot.introspection.qt import QtIntrospectionTestMixin from autopilot.testcase import AutopilotTestCase class MyAppTestCase(AutopilotTestCase, QtIntrospectionTestMixin): def setUp(self): super(MyAppTestCase, self).setUp() self.app = self.launch_test_application("../../my-app") In Autopilot 1.3, the :class:`~autopilot.testcase.AutopilotTestCase` class contains this functionality directly, so the ``QtIntrospectionTestMixin`` and ``GtkIntrospectionTestMixin`` classes no longer exist. The above example becomes simpler:: from autopilot.testcase import AutopilotTestCase class MyAppTestCase(AutopilotTestCase): def setUp(self): super(MyAppTestCase, self).setUp() self.app = self.launch_test_application("../../my-app") Autopilot will try and determine the introspection type automatically. If this process fails, you can specify the application type manually:: from autopilot.testcase import AutopilotTestCase class MyAppTestCase(AutopilotTestCase): def setUp(self): super(MyAppTestCase, self).setUp() self.app = self.launch_test_application("../../my-app", app_type='qt') .. seealso:: Method :py:meth:`autopilot.testcase.AutopilotTestCase.launch_test_application` Launch test applications. ``autopilot.emulators`` namespace has been deprecated +++++++++++++++++++++++++++++++++++++++++++++++++++++ In autopilot 1.2 and earlier, the ``autopilot.emulators`` package held several modules and classes that were used frequently in tests. This package has been removed, and it's contents merged into the autopilot package. Below is a table showing the basic translations that need to be made: +-------------------------------+--------------------------------------+ | Old module | New Module | +===============================+======================================+ | ``autopilot.emulators.input`` | :py:mod:`autopilot.input` | +-------------------------------+--------------------------------------+ | ``autopilot.emulators.X11`` | Deprecated - use | | | :py:mod:`autopilot.input` for input | | | and :py:mod:`autopilot.display` for | | | getting display information. | +-------------------------------+--------------------------------------+ | ``autopilot.emulators.bamf`` | Deprecated - use | | | :py:mod:`autopilot.process` instead. | +-------------------------------+--------------------------------------+ .. TODO - add specific instructions on how to port tests from the 'old and busted' autopilot to the 'new hotness'. Do this when we actually start the porting work ourselves. ./docs/_templates/0000755000004100000410000000000014002063564014330 5ustar www-datawww-data./docs/_templates/indexcontent.html0000644000004100000410000000712014002063564017720 0ustar www-datawww-data{% extends "defindex.html" %} {% block tables %}

Autopilot is a tool for writing functional tests for GUI applications.

It works out-of-the-box for Several GUI toolkits, including Gtk2, Gtk3, Qt4, and Qt5/Qml.

Parts of the documentation:

Indices and tables:

{% endblock %} ./docs/_templates/layout.html0000644000004100000410000000202614002063564016533 0ustar www-datawww-data{% extends '!layout.html' %} {% set css_files = css_files + ["_static/centertext.css"] %} {% block footer %} {% endblock %} ./docs/appendix/0000755000004100000410000000000014002063564014003 5ustar www-datawww-data./docs/appendix/appendix.rst0000644000004100000410000000036014002063564016344 0ustar www-datawww-dataAppendices ########### These technical documents describe features of the autopilot ecosystem that test authors probably don't need to know about, but may be useful to developers of autopilot itself. .. toctree:: :maxdepth: 2 protocol ./docs/appendix/protocol.rst0000644000004100000410000004127414002063564016406 0ustar www-datawww-dataXPathSelect Query Protocol ########################## This document defines the protocol used between autopilot and the application under test. In almost all cases, the application under test needs no knowledge of this protocol, since it is handled by one of the UI toolkit specific autopilot drivers (we support both Gtk and Qt). If you are a test author, you should be aware that this document likely contains no useful information for you! .. contents:: **Who should read this document:** * People wanting to hack on autopilot itself. * People wanting to hack on xpathselect. * People wanting to use autopilot to test an application not supported by the current autopilot UI toolkit drivers. * People wanting to write a new UI toolkit driver. DBus Interface ============== Every application under test must register itself on a DBus bus. Traditionally this is the DBus session bus, but autopilot supports any DBus bus that the user running autopilot has access to. Applications may choose to us ea well-known connection name, but it is not required. The only requirement for the DBus connection is that the ``com.canonical.Autopilot.Introspection`` interface is presented on exactly one exported object. The interface has two methods: * ``GetVersion()``. The ``GetVersion`` method takes no parameters, and returns a string describing the DBus wire protocol version being used by the application under test. Autopilot will refuse to connect to DBus wire protocol versions that are different than the expected version. The current version string is described later in this document. The version string should be in the format "X.Y", where ``X``, ``Y`` are the major and minor version numbers, respectively. * ``GetState(...)``. The ``GetState`` method takes a single string parameter, which is the "XPath Query". The format of that string parameter, and the return value, are the subject of the rest of this document. Object Trees ============ Autopilot assumes that the application under test is constructed of a tree of objects. This is true for both Gtk and Qt applications, where the tree usually starts with an "application" object, and roughly follows widget stacking order. The purpose of the XPathSelect protocol is to allow autopilot to select the parts of the application that it is interested in. Autopilot passes a query string to the ``GetState`` method, and the application replies with the objects selected (if any), and their internal state details. The object tree is a tree of objects. Objects have several properties that are worth mentioning: * Each object *must* have a name that can be used as a python identifier. This is usually the class name or widget type (for example, ``QPushButton``, in a Qt GUI application). * Each object *must* have an attribute named "id", whose value is an integer that is guaranteed to be unique for that object (note however that the same object may appear multiple times in the introspection tree in different places. In this case, the object id must remain consistent between each appearance). * Objects *may* have more attributes. Each attribute name must be a string that can be represented as a python variable name. Attribute values can be any type that is representable over dbus. * Objects *may* have zero or more child objects. This tree of objects is known as an "introspection tree" throughout the autopilot documentation. Selecting Objects ================= Objects in a tree are selected in a very similar fashion to files in a filesystem. The ``/`` character is used to separate between levels of the tree. Selecting the Root Object +++++++++++++++++++++++++ A shortcut exists for selecting the root object in the introspection tree. A query of ``/`` will always return the root object. This is used when autopilot does not yet know the name of, the root object. Absolute Queries ++++++++++++++++ Absolute queries specify the entire path to the object, or objects autopilot is interested in. They must start with ``/`` and specify object names, separated by ``/`` characters. For example: .. list-table:: **XPathSelect Absolute Queries** :header-rows: 1 * - Query: - Selects: * - ``/`` - The root object (see above). * - ``/foo`` - The root object, which must be named 'foo'. * - ``/foo/bar`` - Object 'bar', which is a direct child of the root object 'foo'. Using absolute queries, it is possible to select nodes in a tree of objects. However, there is no requirement for an absolute query path to match to exactly one object in the tree. For example, given a tree that looks like this: .. graphviz:: digraph foo { node [shape=box]; a [label="foo"]; b [label="bar"]; c [label="bar"]; d [label="bar"]; a -> b; a -> c; a -> d; } a query of ``/foo/bar`` will select two objects. This is allowed, but not always what we want. There are several ways to avoid this, they will be covered later in this document. Relative Queries ++++++++++++++++ Absolute queries are very fast for the application under test to process, and are used whenever autopilot knows where the object it wants to look at exists within the introspection tree. However, sometimes we need to be able to retrieve all the objects of a certain type within the tree. XPathSelect understands relative queries, which will select objects of a specified type anywhere in the tree. For example: .. list-table:: **XPathSelect Relative Queries** :header-rows: 1 * - Query: - Selects: * - ``//foo`` - All objects named 'foo', anywhere in the tree. Relative queries are much slower for the application under test to process, since the entire introspection tree must be searched for the objects that match the search criteria. Additionally, relative queries can generate a large amount of data that has to be sent across DBus, which can slow things down further. Mixed Queries +++++++++++++ Absolute and relative queries can be mixed. All the relative queries in the above table will search the entire object tree. However, sometimes you only want to search part of the object tree, in which case you can use a mixed query: .. list-table:: **XPathSelect Mixed Queries** :header-rows: 1 * - Query: - Selects: * - ``/foo/bar//baz`` - Select all objects named 'baz' which are in the tree beneath '/foo/bar' * - ``/foo/far//bar/baz`` - Select all 'baz' objects which are immeadiate children of a 'bar' object, which itself is in the subtree beneath '/foo/far'. As you can see, mixed queries can get reasonably complicated. Attribute Queries +++++++++++++++++ Sometimes we want to select an object whose attributes match certain values. For example, if the application under test is a Qt application, we may want to retrieve a list of 'QPushButton' objects whose 'active' attribute is set to ``True``. The XPathSelect query protocol supports three value types for attributes: * Boolean attribute values are represented as ``True`` or ``False``. * String attribute values are represented as strings inside double quote characters. The XPathSelect library understands the common character escape codes, as well as the ``\x__`` hexidecimal escape sequence (For exmaple: ``"\x41"`` would evaluate to a string with a single character 'A'.). * Integer attribute values are supported. Integers may use a sign (either '+' or '-'). The sign may be omitted for positive numbers. The range for integer values is from :math:`-2^{32}` to :math:`2^{31}-1`. Attribute queries are done inside square brackets (``[...]``) next to the object they apply to. The following table lists a number of attribute queries, as examples of what can be achieved. .. list-table:: **XPathSelect Attribute Queries** :header-rows: 1 * - Query: - Selects: * - ``//QPushButton[active=True]`` - Select all ``QPushbutton`` objects whose "active" attribute is set to True. * - ``//QPushButton[label="Deploy Robots!"]`` - Select all ``QPushButton`` objects whose labels are set to the string "Deploy Robots". * - ``//QPushButton[label="Deploy Robots!",active=True]`` - Select all ``QPushButton`` objects whose labels are set to the string "Deploy Robots", *and* whose "active" attribute is set to True. * - ``//QSpinBox[value=-10]`` - Select all ``QSpinBox`` objects whose value attribute is set to -10. .. note:: While the XPathSelect protocol has a fairly limited list of supported types for attribute matching queries, it is important to note that autopilot transparently supports matching object attributes of any type. Autopilot will send attribute filters to the application under test using the XPathSelect protocol only if the attribute filters are supported by XPathSelect. In all other cases, the filtering will be done within autopilot. At worst, the test author may notice that some queries take longer than others. Wildcard Nodes ============== As well as selecting a node in the introspection tree by node name, one can also use ``*`` to select any node. However, there are a few restrictions on this feature, to stop the inadvertent selection of the entire tree. Selecting All Children ++++++++++++++++++++++ Wildcard nodes are often used to select all the children of a particular object. For example, if the query ``/path/to/object[id=123]`` returns the parent object you are interested in, then the query ``/path/to/object[id=123]/*`` will return all the immediate children of that object. Selecting Nodes based on Attributes +++++++++++++++++++++++++++++++++++ The second use of wildcard nodes is to select nodes based purely on their attributes, rather than their type. For example, to select every object with a 'visible' property set to 'True', the following query will work: ``//*[visible=True]``. However, care must be taken - this query will traverse the entire introspection tree, and may take a long time. Additionally, a large amount of data may be returned over DBus. Invalid Wildcard Queries ++++++++++++++++++++++++ The wildcard specifier may only be used after a search separator if you have also specified attribute filters. For example, all the following queries are invalid: **Invalid Queries** * ``//*`` * ``/path/to/some/node//*`` * ``//node//*`` However, the following queries are all valid: **Valid Queries** * ``//node/*`` * ``/node//*[key="value"]`` * ``//node//*[key=True]`` Returning State Data ==================== Once the application under test has parsed the XPathSleect query, and has a list (possibly empty!) of objects that match the given query, it must serialize those objects back across DBus as the return value from the ``GetState`` method. The data structure used is reasonably complex, and is described below: * At the top level, the return type must be an array of objects. Each item in the array represents an object that matched the supplied query. If no objects matched the supplied query, an empty array must be returned. * Each object is a DBus structure that has two parts: a string, and a map. The string specifies the full tree path to the object being returned (for example "/path/to/object"). * The map represents the object state, and is a map of strings to arrays. The keys in this map are property names (for example "visible"). * The arrays represents the property value. It contains at least two parts, a value type id (see below for a list of these ids and what they mean), and one or more values. The values can be any type representable over dbus. Some values may actually be arrays of values, for example. .. graphviz:: digraph dbus_data { node [shape=record]; objects [label="Object|Object|..."]; object2 [label="Object_name|Object_state"]; object_state [label="property|property|..."] property [label="key|value_type|value|..."] objects:f1 -> object2; object2:f1 -> object_state; object_state:f0 -> property; } Valid IDs +++++++++ The following table lists the various type Ids, and their meaning. .. list-table:: **Autopilot Type IDs and their meaning** :header-rows: 1 :widths: 5 90 * - Type ID: - Meaning: * - 0 - Simple Type. The value is a DBus integer, boolean, or string, and that is exactly how it should be represented to the user. * - 1 - Rectangle. The next four values are all integers, and represent a rectangle in cartesian space. The four numbers must represent the x, y, width and height of the rectangle, respectively. Autopilot will likely present the four values as 'x', 'y', 'w' and 'h' to test authors. Autopilot makes no assumptions about the coordinate space used. * - 2 - Point. The next two values are integers, and represent an X, Y, point in catesian space. * - 3 - Size. The next two value are integers, and represent a width,height pair, describing a size in catesian space. * - 4 - Color. The next four values are all integers, and represent the red, green, blue, and alpha components of the color, respectively. Each component is bounded between 0 and 255. * - 5 - Date or Date/Time. The next value is an integer representing the number of seconds since the unix epoch (1970-01-011 00:00:00), UTC time. * - 6 - Time. The next values are all integers, and represent hours, minutes, seconds, milliseconds. * - 7 - 3D Point. The next values are all integers, and represent the X, Y and Z coordinates of a point in 3D cartesian space. Special Attributes ++++++++++++++++++ Most attributes that are returned will be attributes of the UI toolkit class itself. However, there are two special attributes: * The ``id`` attribute *must* be present, and must contain an integer number. This number must be unique for this instance of the object. This number must also be within the range suitable for integer parameter matching. * The ``Children`` attribute *may* be present if the object being serialized has any children in the introspection tree. If it is present, it must be an array of strings, where each string is the class name of the immediate child object. * The ``globalRect`` property should be present for any components that have an on-screen presence. It should be a 4-element array containing the x,y,w,h values of the items on-screen coordinates. Note that these coordinates should be in screen-space, rather than local application space. Example GetState Return Values ++++++++++++++++++++++++++++++ All the examples in this section have had whitespace added to make them more readable. **Example 1: Unity Shell** Query: ``/`` Return Value:: [ ( '/Unity', { 'id': [0, 0], 'Children': [0, [ 'DashController', 'HudController', 'LauncherController', 'PanelController', 'Screen', 'SessionController', 'ShortcutController', 'SwitcherController', 'WindowManager' ] ] } ) ] **Example 2: Qt Creator Menu** This is a much larger object, and shows the ``globalRect`` attribute. Query: ``/QtCreator/QMenu[objectName="Project.Menu.Session"]`` Return Value:: [ ( '/QtCreator/QMenu', { '_autopilot_id': [0, 3], 'acceptDrops': [0, False], 'accessibleDescription': [0, ''], 'accessibleName': [0, ''], 'autoFillBackground': [0, False], 'baseSize': [3, 0, 0], 'Children': [0, ['QAction', 'DBusMenu']], 'childrenRect': [1, 0, 0, 0, 0], 'contextMenuPolicy': [0, 1], 'enabled': [0, True], 'focus': [0, False], 'focusPolicy': [0, 0], 'frameGeometry': [1, 0, 0, 100, 30], 'frameSize': [3, 100, 30], 'fullScreen': [0, False], 'geometry': [1, 0, 0, 100, 30], 'globalRect': [1, 0, 0, 100, 30], 'height': [0, 30], 'id': [0, 3], 'inputMethodHints': [0, 0], 'isActiveWindow': [0, False], 'layoutDirection': [0, 0], 'maximized': [0, False], 'maximumHeight': [0, 16777215], 'maximumSize': [3, 16777215, 16777215], 'maximumWidth': [0, 16777215], 'minimized': [0, False], 'minimumHeight': [0, 0], 'minimumSize': [3, 0, 0], 'minimumSizeHint': [3, -1, -1], 'minimumWidth': [0, 0], 'modal': [0, False], 'mouseTracking': [0, True], 'normalGeometry': [1, 0, 0, 0, 0], 'objectName': [0, 'ProjectExplorer.Menu.Debug'], 'pos': [2, 0, 0], 'rect': [1, 0, 0, 100, 30], 'separatorsCollapsible': [0, True], 'size': [3, 100, 30], 'sizeHint': [3, 293, 350], 'sizeIncrement': [3, 0, 0], 'statusTip': [0, ''], 'styleSheet': [0, ''], 'tearOffEnabled': [0, False], 'title': [0, '&Debug'], 'toolTip': [0, ''], 'updatesEnabled': [0, True], 'visible': [0, False], 'whatsThis': [0, ''], 'width': [0, 100], 'windowFilePath': [0, ''], 'windowIconText': [0, ''], 'windowModality': [0, 0], 'windowModified': [0, False], 'windowOpacity': [0, 1.0], 'windowTitle': [0, ''], 'x': [0, 0], 'y': [0, 0] } ) ] Note that most attributes are given the "plain" type id of 0, but some (such as 'pos', 'globalRect', and 'size' in the above example) are given more specific type ids. ./docs/man.py0000644000004100000410000000565514002063564013333 0ustar www-datawww-data# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- # # Autopilot Functional Test Tool # Copyright (C) 2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import argparse from docutils import nodes from sphinx.util.compat import Directive from autopilot.run import _get_parser # Let's just instantiate this once for all the directives PARSER = _get_parser() def setup(app): app.add_directive('argparse_description', ArgparseDescription) app.add_directive('argparse_epilog', ArgparseEpilog) app.add_directive('argparse_options', ArgparseOptions) app.add_directive('argparse_usage', ArgparseUsage) def format_text(text): """Format arbitrary text.""" global PARSER formatter = PARSER._get_formatter() formatter.add_text(text) return formatter.format_help() class ArgparseDescription(Directive): have_content = True def run(self): description = format_text(PARSER.description) return [nodes.Text(description)] class ArgparseEpilog(Directive): have_content = True def run(self): epilog = format_text(PARSER.epilog) return [nodes.Text(epilog)] class ArgparseOptions(Directive): have_content = True def run(self): formatter = PARSER._get_formatter() for action_group in PARSER._action_groups: formatter.start_section(action_group.title) formatter.add_text(action_group.description) formatter.add_arguments(action_group._group_actions) formatter.end_section() options = formatter.format_help() return [nodes.Text(options)] class ArgparseUsage(Directive): have_content = True def run(self): usage_nodes = [ nodes.Text(format_text('autopilot [-h]') + '.br\n'), nodes.Text(format_text('autopilot [-v]') + '.br\n'), ] for action in PARSER._subparsers._actions: if type(action) == argparse._SubParsersAction: choices = action.choices break for choice in choices.values(): parser_usage = choice.format_usage() usage_words = parser_usage.split() del usage_words[0] usage_words[0] = 'autopilot' usage = ' '.join(usage_words) + '\n.br\n' usage_nodes.append(nodes.Text(usage)) return usage_nodes ./COPYING0000644000004100000410000010451314002063564012302 0ustar www-datawww-data GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 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 General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ./lttng_module/0000755000004100000410000000000014002063564013740 5ustar www-datawww-data./lttng_module/autopilot_tracepoint.c0000644000004100000410000000356014002063564020360 0ustar www-datawww-data #define TRACEPOINT_CREATE_PROBES /* * The header containing our TRACEPOINT_EVENTs. */ #define TRACEPOINT_DEFINE #include "autopilot_tracepoint.h" /// Python module stuff below here: #include static PyObject * emit_test_started(PyObject *self, PyObject *args) { const char *mesg_text; /* In Python 3, the argument must be a UTF-8 encoded Unicode. */ if(!PyArg_ParseTuple(args, "s", &mesg_text)) { return NULL; } tracepoint(com_canonical_autopilot, test_event, "started", mesg_text); Py_RETURN_NONE; } static PyObject * emit_test_ended(PyObject *self, PyObject *args) { const char *mesg_text; /* In Python 3, the argument must be a UTF-8 encoded Unicode. */ if(!PyArg_ParseTuple(args, "s", &mesg_text)) { return NULL; } tracepoint(com_canonical_autopilot, test_event, "ended", mesg_text); Py_RETURN_NONE; } static PyMethodDef TracepointMethods[] = { {"emit_test_started", emit_test_started, METH_VARARGS, "Generate a tracepoint for test started."}, {"emit_test_ended", emit_test_ended, METH_VARARGS, "Generate a tracepoint for test started."}, {NULL, NULL, 0, NULL} /* Sentinel */ }; #if PY_MAJOR_VERSION >= 3 static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "tracepoint", /* m_name */ NULL, /* m_doc */ -1, /* m_size */ TracepointMethods, /* m_methods */ NULL, /* m_reload */ NULL, /* m_traverse */ NULL, /* m_clear */ NULL /* m_free */ }; PyMODINIT_FUNC PyInit_tracepoint(void) { return PyModule_Create(&moduledef); } #else /* Python 2 */ PyMODINIT_FUNC inittracepoint(void) { (void) Py_InitModule("tracepoint", TracepointMethods); } #endif /* PY_MAJOR_VERSION >= 3 */ ./lttng_module/autopilot_tracepoint.h0000644000004100000410000000137614002063564020370 0ustar www-datawww-data #undef TRACEPOINT_PROVIDER #define TRACEPOINT_PROVIDER com_canonical_autopilot #undef TRACEPOINT_INCLUDE #define TRACEPOINT_INCLUDE "./autopilot_tracepoint.h" #ifdef __cplusplus extern "C"{ #endif /* __cplusplus */ #if !defined(AUTOPILOT_TRACEPOINT_H) || defined(TRACEPOINT_HEADER_MULTI_READ) #define AUTOPILOT_TRACEPOINT_H #include TRACEPOINT_EVENT( com_canonical_autopilot, test_event, TP_ARGS(const char *, started_or_stopped, const char *, test_id), /* Next are the fields */ TP_FIELDS( ctf_string(started_or_stopped, started_or_stopped) ctf_string(test_id, test_id) ) ) #endif /* AUTOPILOT_TRACEPOINT_H */ #include #ifdef __cplusplus } #endif /* __cplusplus */ ./make_coverage.sh0000755000004100000410000000400314002063564014367 0ustar www-datawww-data#!/bin/sh # # Autopilot Functional Test Tool # Copyright (C) 2013 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # set -e SHOW_IN_BROWSER="yes" INCLUDE_TEST_FILES="no" TEST_SUITE_TO_RUN="autopilot.tests.unit" usage() { echo "usage: $0 [-h] [-n] [-t] [-s suite]" echo echo "Runs unit tests under python 3, gathering coverage data." echo echo "By default, will open HTML coverage report in the default browser. If -n" echo "is specified, will re-generate coverage data, but won't open the browser." echo echo "If -t is specified the HTML coverage report will include the test files." echo echo "If -s is specified, the next argument must be a test id to run. If not" echo "specified, the default is '$TEST_SUITE_TO_RUN'." } while getopts ":hnts:" o; do case "${o}" in n) SHOW_IN_BROWSER="no" ;; t) INCLUDE_TEST_FILES="yes" ;; s) TEST_SUITE_TO_RUN=$OPTARG ;; *) usage exit 0 ;; esac done if [ -d htmlcov ]; then rm -r htmlcov fi python3 -m coverage erase python3 -m coverage run --append --branch --include "autopilot/*" -m autopilot.run run $TEST_SUITE_TO_RUN if [ "$INCLUDE_TEST_FILES" = "yes" ]; then python3 -m coverage html else python3 -m coverage html --omit "autopilot/tests/*" fi if [ "$SHOW_IN_BROWSER" = "yes" ]; then xdg-open htmlcov/index.html fi ./setup.py0000644000004100000410000000301014002063567012752 0ustar www-datawww-data#!/usr/bin/env python3 # # Autopilot Functional Test Tool # Copyright (C) 2012-2014 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from setuptools import find_packages, setup, Extension import sys assert sys.version_info >= (3,), 'Python 3 is required' VERSION = '1.6.1' autopilot_tracepoint = Extension( 'autopilot.tracepoint', libraries=['lttng-ust'], include_dirs=['lttng_module'], sources=['lttng_module/autopilot_tracepoint.c'] ) setup( name='autopilot', version=VERSION, description='Functional testing tool for Ubuntu.', author='Thomi Richards', author_email='thomi.richards@canonical.com', url='https://launchpad.net/autopilot', license='GPLv3', packages=find_packages(), test_suite='autopilot.tests', scripts=['bin/autopilot3-sandbox-run'], ext_modules=[autopilot_tracepoint], entry_points={ 'console_scripts': ['autopilot3 = autopilot.run:main'] } ) ./MANIFEST.in0000644000004100000410000000011114002063564012772 0ustar www-datawww-datagraft bin graft build/sphinx/html recursive-include lttng_module *.c *.h ./autopilot.10000644000004100000410000000337114002063564013351 0ustar www-datawww-data.TH AUTOPILOT 1 LOCAL .SH NAME autopilot - functional test tool for Ubuntu .SH SYNOPSYS .B autopilot [-h] .br .B autopilot list suite [suite...] .br .B autopilot run [-v] [-r] [--rd directory] suite [suite...] .SH DESCRIPTION .B autopilot is a tool for writing functional test suites for graphical applications for Ubuntu. .SH OPTIONS .SS General Options .TP 5 -h --help Get help from autopilot. This command can also be present after a sub-command (such as run or list) to get help on the specific command. Further options are restricted to particular autopilot commands. .TP 5 suite Suites are listed as a python dotted package name. Autopilot will do a recursive import in order to find all tests within a python package. .SS list [options] suite [suite...] List the autopilot tests found in the given test suite. .TP 5 -ro, --run-order List tests in the order they will be run in, rather than alphabetically (which is the default). .SS run [options]suite [suite...] Run one or more test suites. .TP 5 -o FILE, --output FILE Specify where the test log should be written. Defaults to stdout. If a directory is specified the file will be created with a filename of _.log .TP 5 -f FORMAT, --format FORMAT Specify the format for the log. Valid options are 'xml' and 'text' for JUnit XML and text format, respectively. .TP 5 -r, --record Record failed tests. Using this option requires the 'recordmydesktop' application be installed. By default, videos are stored in /tmp/autopilot .TP 5 -rd DIR, --record-directory DIR Directory where videos should be stored (overrides the default set by the -r option). .TP 5 -v, --verbose Causes autopilot to print the test log to stdout while the test is running. .SH AUTHOR Thomi Richards (thomi.richards@canonical.com)