diff --git a/README.md b/README.md index 2a902bb..d7a3a8c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,151 @@ # Parakeet +![Parakeet](parakeet-logo-small.png?raw=true "Parakeet") + UI tests automation utilities library. +The aim is to easy the development of tests for applications that follow a +known set of technologies: + +- Angular JS +- Angular JS Material +- Google OAuth2 +- Chrome Browser + +This library depends, and sometime implements useful abstractions, on the following libraries: + +- [Lettuce](http://lettuce.it/) +- [Splinter](https://splinter.readthedocs.io/) +- [Selenium](https://www.seleniumhq.org/projects/webdriver/) +- [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/) + +We designed it overcome some challenges of developing BDD tests considering the technologies below: + +![Parakeet stack](parakeet-stack.png?raw=true "Parakeet Stack") + +## Installation + +You can install Parakeet using the package manager PIP. + +``` +pip install parakeet -U +``` + +## Configuration + +Parakeet reads a **config.yaml** file that can be used to parameterize it's behaviours. + +It need to be saved on the home directory of the operation system running the tests. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescriptionValuesDefault
browserThis parameter define which browser will be used to run the tests. Today we only support the Chrome Browserchromechrome
headlessDefine if the tests will open the browser or perform the tests with graphical mode off. It is very useful in developer mode, when the developer need to debug or understand some issues. Case True, doesn't open the browser.True
False
False
default_implicitly_wait_secondsSetup time in seconds that the tests will wait until some component are ready to be used.Integer Number30
default_poll_frequency_secondsInterval time during the default_implicitly_wait_seconds, that the application will wait until check if the component are ready to be used.Integer Number2
log_levelYou can define the level of the application logs.INFO
WARN
DEBUG
TRACE
ERROR
INFO
log_nameYou can setup the name of the logs that you want to see. For example if you want to see everyone you don't need to setup this field. But you can setup this parameter to just see the Parakeet logs or just the loggers that you application are logging.Ex.: google.tests.e2eEmpty
retrySome resources on the application use the retry feature. This item give to the user the ability to setup how many times the test will try to click on some button for example.Integer Number1
login_providerThe parakeet provide to you an abrastraction mechanism in order to login in the Google account (if your application have something like it). Thinking of it, we created a parameter where the user can setup the version of this mechanism.google_oauthgoogle_oauth
google_oauth_gapi2
window_size:
    width: 9999
    height: 9999
Setup the size of the screen where the tests will be performed.Integer NumberEmpty
users:
    file: 'users.yaml'
The parakeet provide a way to setup which users you will use on your tests. So this file can be setup hereFile PathEmpty
home_urlThe home url used to access the application that will be tested.http://localhostEmpty
system_page_titleThe system page title, this is used in order to check if the user are logged on the application. That's the way we check if the test already passed the login phase.Text defined on the tag title on the applicationEmpty
+ +### Configuration example + +(TODO - create/maintain example files and reference it here) + +## Users configuration + +Parakeet reads a **users.yaml** file that can be used to store users credentials +that will be used in the tests.. + +It need to be saved on the home directory of the operation system running the tests. + +### Users configuration example + +(TODO - create/maintain example files and reference it here) + +## Lettuce terrain + +In order to bootstrap and start using this library in your Lettuce tests, you +should initialize it in the "terrain" file. + +### Lettuce terrain example + +(TODO - create/maintain example files and reference it here) + ## Why Parakeet? Github suggested it. + +### Logo attribution + +We found our "logo" image here: Free Vector Graphics by www.vecteezy.com diff --git a/parakeet-logo-small.png b/parakeet-logo-small.png new file mode 100644 index 0000000..f827b06 Binary files /dev/null and b/parakeet-logo-small.png differ diff --git a/parakeet-logo.png b/parakeet-logo.png new file mode 100644 index 0000000..ad1d017 Binary files /dev/null and b/parakeet-logo.png differ diff --git a/parakeet-stack.png b/parakeet-stack.png new file mode 100644 index 0000000..e1dd9d2 Binary files /dev/null and b/parakeet-stack.png differ diff --git a/parakeet/__version__.py b/parakeet/__version__.py index a116b2f..4b5e18e 100644 --- a/parakeet/__version__.py +++ b/parakeet/__version__.py @@ -5,4 +5,4 @@ # |_| \__,_|_| \__,_|_|\_\___|\___|\__| -__version__ = '0.0.12' +__version__ = '0.0.13' diff --git a/parakeet/auth.py b/parakeet/auth.py index 0617fa6..b0912e3 100644 --- a/parakeet/auth.py +++ b/parakeet/auth.py @@ -39,7 +39,13 @@ def __init__(self, browser, home_title): def fill_email(self, email): LOG.debug('fill_email') - self.browser.find_element_by_id('identifierId').type(email) + try: + self.browser.find_element_by_id('identifierId').type(email) + except TimeoutException: + LOG.debug('click_on_identifier_before_fill_email') + self.browser.find_element_by_id('identifierLink').click() + self.browser.find_element_by_id('identifierId').type(email) + return self def click_next(self): diff --git a/parakeet/browser.py b/parakeet/browser.py index 731fb7b..e9a8308 100644 --- a/parakeet/browser.py +++ b/parakeet/browser.py @@ -11,6 +11,8 @@ from time import sleep from parakeet.lettuce_logger import LOG +from .utils import next_image + class ParakeetElement(object): """ @@ -45,10 +47,35 @@ def click_and_wait_disappear(self): self.wait_invisibility_of_element_located() return self - def type(self, value): + def type(self, value, type_pause=0): + _type_pause = type_pause if type_pause > 0 else self.parakeet.type_pause + LOG.debug('type {} {}'.format(value, _type_pause)) self.element = self.wait_visibility_of_element_located() - self.element.send_keys(value) + self._type_handle(_type_pause, value) + self.debounce() + + return self + + def _type_handle(self, _type_pause, value): + if _type_pause > 0: + self._type_slowly(value, _type_pause) + else: + LOG.debug('type_normal {}'.format(value)) + self.element.send_keys(value) + + def _type_slowly(self, value, type_pause): + LOG.debug('type_slowly {}'.format(value)) + + if not (type_pause and isinstance(type_pause, float) and 0.0 < type_pause < 1): + raise ValueError( + 'The type_pause argument need to be an float valid number between 0.01 and 0.99' + ) + + for character in value: + self.element.send_keys(character) + time.sleep(type_pause) + return self def get_attribute(self, name): @@ -114,16 +141,18 @@ def __init__(self, config): self.selenium = self.splinter.driver self.waiting_time = int(config.get('default_implicitly_wait_seconds')) self.poll_frequency = int(config.get('default_poll_frequency_seconds')) + self.snapshot_debug = config.get('snapshot_debug', False) + self.type_pause = float(config.get('type_pause', 0)) self.retry_get_element = int(config.get('retry', 1)) self.selenium.implicitly_wait(self.waiting_time) self.selenium.set_window_size(int(config['window_size']['width']), int(config['window_size']['height'])) - def find_element_by_id(self, element_id): + def find_element_by_id(self, element_id, waiting_time=None): LOG.debug('find_element_by_id({})' .format(element_id)) locator = (By.ID, element_id) - element = self.get_element_waiting_for_its_presence(locator) + element = self.get_element_waiting_for_its_presence(locator, waiting_time) return ParakeetElement(element, locator, self) def find_element_by_xpath(self, element_xpath): @@ -160,14 +189,21 @@ def visit_home(self): LOG.debug('visit_home') self.visit(self.config['home_url']) - def get_element_waiting_for_its_presence(self, locator): + def get_element_waiting_for_its_presence(self, locator, waiting_time=None): + _waiting_time = waiting_time if waiting_time else self.waiting_time LOG.debug('get_element_waiting_for_its_presence({}, {}, {})' - .format(locator, self.waiting_time, self.poll_frequency)) - element = WebDriverWait(self.selenium, self.waiting_time, self.poll_frequency).until( + .format(locator, _waiting_time, self.poll_frequency)) + element = WebDriverWait(self.selenium, _waiting_time, self.poll_frequency).until( ec.presence_of_element_located(locator) ) return element + def get_element_waiting_for_its_presence_by_xpath(self, xpath, waiting_time=None): + _waiting_time = waiting_time if waiting_time else self.waiting_time + LOG.debug('get_element_waiting_for_its_presence_by_xpath({}, {}, {})' + .format(xpath, _waiting_time, self.poll_frequency)) + return self.get_element_waiting_for_its_presence((By.XPATH, xpath), _waiting_time) + # noinspection PyBroadException def retry(self, method=None, **kwargs): """ @@ -185,12 +221,31 @@ def retry(self, method=None, **kwargs): .format(_next_iterator, _retry, method.__name__)) kwargs.pop(_next, None) + result = method(**kwargs) + + if isinstance(result, bool) and \ + not result and \ + _next_iterator < _retry: + return self._perform_method(_next, _next_iterator, kwargs, method) - return method(**kwargs) + return result except Exception as ex: LOG.error('Exception: {}'.format(ex.message)) if _next_iterator < _retry: - kwargs[_next] = _next_iterator + 1 - sleep(randint(1, 3)) - return self.retry(method=method, **kwargs) + return self._perform_method(_next, _next_iterator, kwargs, method) + self.selenium.save_screenshot('parakeet_error_{:05d}_{}.png' + .format(next_image(), method.__name__)) raise ex + + def _perform_method(self, next, next_iterator, kwargs, method): + """ + Perform the method and return the value + :param next: + :param next_iterator: + :param kwargs: + :param method: + :return: + """ + kwargs[next] = next_iterator + 1 + sleep(randint(1, 3)) + return self.retry(method=method, **kwargs) diff --git a/parakeet/lettuce_logger.py b/parakeet/lettuce_logger.py index ccf2b4e..d7e56c6 100644 --- a/parakeet/lettuce_logger.py +++ b/parakeet/lettuce_logger.py @@ -1,6 +1,9 @@ import logging import sys from colorlog import ColoredFormatter +from lettuce import world + +from parakeet import next_image log_level = {'INFO': logging.INFO, 'WARNING': logging.WARNING, @@ -8,6 +11,7 @@ 'ERROR': logging.ERROR} APP_LOGGER = 'google.tests.e2e' +SNAPSHOT_DEBUG = 'SNAPSHOT_DEBUG' formatter = ColoredFormatter( "%(green)s%(asctime)s - %(name)s -%(reset)s %(log_color)s%(levelname)-8s%(reset)s" @@ -16,7 +20,7 @@ reset=True, log_colors={ 'DEBUG': 'cyan', - 'INFO': 'green', + 'INFO': 'blue', 'WARNING': 'yellow', 'ERROR': 'red', 'CRITICAL': 'red', @@ -24,6 +28,17 @@ ) +class CustomLogging(logging.getLoggerClass()): + """ + The ideia is override our custom logs and do some additional stuffs. + """ + def debug(self, msg, *args, **kwargs): + if world.browser.snapshot_debug: + world.browser.selenium.save_screenshot( + 'parakeet_debug_{:05d}.png'.format(next_image())) + return super(CustomLogging, self).debug(msg, *args, **kwargs) + + def init_logs(level='INFO', logger=None): """ Setup the logs inside of the tests. @@ -45,6 +60,7 @@ def get_logger(): Return the default logger. :return: """ + logging.setLoggerClass(CustomLogging) return logging.getLogger(APP_LOGGER) diff --git a/parakeet/utils.py b/parakeet/utils.py index 5ff332f..d58ab12 100644 --- a/parakeet/utils.py +++ b/parakeet/utils.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- import yaml import os +from lettuce import world + +NEXT_IMAGE_SEQUENCE = 'next_image_sequence' + def load_yaml(yaml_file): """ @@ -17,3 +21,25 @@ def load_yaml(yaml_file): with open(_local_file, 'r') as f: yaml_content = yaml.load(f) return yaml_content + + +def next_image(): + """ + Return the next image number + :return: + """ + + if world.container.get(NEXT_IMAGE_SEQUENCE, None) is None: + world.container[NEXT_IMAGE_SEQUENCE] = range_generator() + + return world.container[NEXT_IMAGE_SEQUENCE].next() + + +def range_generator(max_register=99999): + """ + Create a generator register + :return: + """ + my_list = range(1, max_register) + for i in my_list: + yield i