├── .github └── workflows │ └── run_tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── appium_fields.rst ├── area.rst ├── best_practices.rst ├── conf.py ├── field.rst ├── getting_started.rst ├── index.rst ├── make.bat ├── page.rst ├── repeating.rst ├── request_spy.rst ├── returning_objects.rst ├── splinter_fields.rst ├── test_page │ ├── fetch_test_page.html │ ├── test_page.html │ └── xhr_test_page.html └── workflows.rst ├── readthedocs.yml ├── requirements ├── docs.txt ├── lint.txt └── tests.txt ├── scripts └── upload_ios_app.py ├── setup.py ├── stere ├── __init__.py ├── areas │ ├── __init__.py │ ├── area.py │ ├── areas.py │ ├── repeating.py │ └── repeating_area.py ├── browser_spy │ ├── __init__.py │ ├── fetch.py │ ├── fetch_script.py │ ├── xhr.py │ └── xhr_script.py ├── browserenabled.py ├── event_emitter.py ├── fields │ ├── __init__.py │ ├── appium │ │ ├── __init__.py │ │ ├── button.py │ │ └── input.py │ ├── build_element.py │ ├── decorators.py │ ├── field.py │ ├── generic │ │ ├── __init__.py │ │ ├── root.py │ │ └── text.py │ └── splinter │ │ ├── __init__.py │ │ ├── button.py │ │ ├── checkbox.py │ │ ├── clickable.py │ │ ├── dropdown.py │ │ ├── input.py │ │ ├── link.py │ │ ├── money.py │ │ └── shadow_root.py ├── page.py ├── py.typed ├── strategy │ ├── __init__.py │ ├── appium.py │ ├── element_strategy.py │ ├── splinter.py │ └── strategy.py ├── utils.py └── value_comparator.py ├── tests ├── appium │ ├── conftest.py │ ├── pages │ │ ├── __init__.py │ │ └── app_main.py │ └── test_appium.py ├── config │ └── test_config_file.py ├── conftest.py ├── splinter │ ├── browser_spy │ │ ├── test_fetch_spy.py │ │ └── test_xhr_spy.py │ ├── conftest.py │ ├── pages │ │ ├── __init__.py │ │ ├── dummy.py │ │ ├── dummy_invalid.py │ │ └── spy_dummy.py │ ├── repeating_area │ │ ├── test_areas_integration.py │ │ └── test_repeating_area.py │ ├── splinter_fields │ │ ├── test_button.py │ │ ├── test_dropdown.py │ │ ├── test_input.py │ │ ├── test_link.py │ │ ├── test_money.py │ │ ├── test_shadow_root.py │ │ └── test_splinter_fields.py │ ├── test_area.py │ ├── test_field.py │ ├── test_page.py │ ├── test_repeating.py │ └── test_strategy_methods.py └── stere │ ├── splinter │ ├── test_element_builder.py │ └── test_splinter_strategies.py │ ├── test_area.py │ ├── test_areas.py │ ├── test_browser_enabled.py │ ├── test_element_strategy.py │ ├── test_event_emitter.py │ ├── test_event_emitter_integration.py │ ├── test_field.py │ ├── test_repeating.py │ ├── test_utils.py │ └── test_value_comparator.py └── tox.ini /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | 19 | # This workflow contains a single job called "build" 20 | build: 21 | # The type of runner that the job will run on 22 | runs-on: ubuntu-latest 23 | 24 | strategy: 25 | matrix: 26 | include: 27 | - PY_VER: py38 28 | python-version: 3.8 29 | - PY_VER: py39 30 | python-version: 3.9 31 | - PY_VER: py310 32 | python-version: '3.10' 33 | - PY_VER: py311 34 | python-version: '3.11' 35 | 36 | # Steps represent a sequence of tasks that will be executed as part of the job 37 | steps: 38 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 39 | - uses: actions/checkout@v3 40 | 41 | - uses: actions/setup-python@v4 42 | with: 43 | python-version: ${{matrix.python-version}} 44 | 45 | - name: Install test dependencies 46 | run: pip install tox coveralls requests 47 | 48 | - name: Set env 49 | run: echo "USE_SAUCE_LABS=True" >> $GITHUB_ENV 50 | 51 | - name: Run lint 52 | run: tox run -e lint; 53 | 54 | - name: Run unit tests 55 | run: tox run -e ${{matrix.PY_VER}}-stere; 56 | 57 | - uses: saucelabs/sauce-connect-action@v2.3.2 58 | if: ${{ matrix.PY_VER == 'py311' }} 59 | with: 60 | username: ${{secrets.SAUCE_USERNAME}} 61 | accessKey: ${{secrets.SAUCE_ACCESS_KEY}} 62 | tunnelName: github-action-tunnel 63 | 64 | - name: Run stere integration tests on Sauce Labs 65 | if: ${{ matrix.PY_VER == 'py311' }} 66 | env: 67 | SAUCE_USERNAME: ${{secrets.SAUCE_USERNAME}} 68 | SAUCE_ACCESS_KEY: ${{secrets.SAUCE_ACCESS_KEY}} 69 | run: | 70 | python scripts/upload_ios_app.py; 71 | tox run -e ${{matrix.PY_VER}}-splinter -- --splinter-webdriver=remote --sauce-remote-url=http://${{secrets.SAUCE_USERNAME}}:${{secrets.SAUCE_ACCESS_KEY}}@ondemand.us-west-1.saucelabs.com/wd/hub; 72 | tox run -e ${{matrix.PY_VER}}-appium -- --sauce-remote-url=http://${{secrets.SAUCE_USERNAME}}:${{secrets.SAUCE_ACCESS_KEY}}@ondemand.us-west-1.saucelabs.com/wd/hub; 73 | 74 | - name: "Upload coverage to Codecov" 75 | if: ${{ matrix.PY_VER == 'py311' }} 76 | uses: codecov/codecov-action@v3 77 | with: 78 | fail_ci_if_error: true 79 | verbose: true 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | chromedriver* 4 | geckodriver* 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # dotenv 88 | .env 89 | 90 | # virtualenv 91 | .venv 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [0.31.0] - 2021-10-28 5 | ### Changed 6 | - Field.value_equals() and Field.value_contains() return an object that evaluates to a bool, instead of a bool. 7 | 8 | ## [0.30.0] - 2021-09-13 9 | ### Added 10 | - Area.text_to_dict() and Repeating.text_to_dict() methods. 11 | 12 | ## [0.29.0] - 2021-08-27 13 | ### Added 14 | - Type hints are now exposed by the package. 15 | 16 | ## [0.28.0] - 2021-05-25 17 | ### Added 18 | - Fields can now register and emit events. 19 | - Fields emit 'before' and 'after' events when performer methods are called. 20 | 21 | ## [0.27.0] - 2021-05-19 22 | ### Added 23 | - Splinter Input Field now has a highlight() method. 24 | 25 | ## [0.26.2] - 2021-05-18 26 | ### Fixed 27 | - If XHRSpy.add() is called multiple times on the same page, the active and total counters are no longer reset. 28 | 29 | ## [0.26.1] - 2021-05-12 30 | ### Fixed 31 | - If XHRSpy.add() is called multiple times on the same page, the hook is only added once. 32 | 33 | ## [0.26.0] - 2021-03-22 34 | ### Added 35 | - Network spies to assist with navigating async web apps. 36 | 37 | ## [0.25.0] - 2021-02-24 38 | ### Added 39 | - Repeating.has_children() now takes argument for minimum number of children that should exist. 40 | 41 | ## [0.24.0] - 2021-02-10 42 | ### Added 43 | - Support for shadow DOM in the form of the ShadowRoot Field. 44 | 45 | ## [0.23.0] - 2021-01-18 46 | ### Fixed 47 | - Nesting Repeating, Area, and RepeatingArea now correctly assigns root. 48 | 49 | ## [0.22.0] - 2021-01-13 50 | ### Changed 51 | - Bumped minimum version of py-moneyed to 1.0. 52 | - 'items' as a keyword is now allowed in Area and RepeatingArea. 53 | - Missing root keyword in RepeatingArea now throws TypeError instead of ValueError. 54 | 55 | ### Fixed 56 | - Area with no root inside a RepeatingArea should now work correctly. 57 | - Area with no root inside a Repeating should now work correctly. 58 | 59 | 60 | ## [0.21.0] - 2020-12-08 61 | ### Changed 62 | - Repeating and RepeatingArea can now be placed inside an Area. 63 | 64 | ## [0.20.0] - 2020-09-25 65 | ### Changed 66 | - Stere.retry_time is used when searching for an attribute inside an element. 67 | - Nicer error message is thrown when an element is not found while doing an attribute lookup. 68 | 69 | ## [0.19.0] - 2020-09-22 70 | ### Changed 71 | - is_visible / is_not_visible methods try to handle stale element exceptions by retrying search. 72 | 73 | ## [0.18.0] - 2020-09-14 74 | ### Changed 75 | - Button and Link Fields wait for visible/clickable status before clicking. 76 | 77 | 78 | ## [0.17.0] - 2020-09-02 79 | ### Fixed 80 | - Fields inside an Area with a root now pass wait_time to the root Field. 81 | 82 | 83 | ## [0.16.0] - 2020-04-20 84 | ### Changed 85 | - Field.value_contains and Field.value_equals use Stere.retry_time as a default value. 86 | - Splinter Dropdown.select() retries if value is not found. 87 | 88 | 89 | ## [0.15.0] - 2019-12-15 90 | ### Changed 91 | - Speed up is_not_ methods. Requires splinter >=0.13.0. 92 | 93 | ## [0.14.0] - 2019-11-19 94 | ### Fixed 95 | - Repeating.has_children no longer fails if no children found. 96 | - Repeating.has_children no longer builds a list of children containers, just checks roots. 97 | 98 | ## [0.13.0] - 2019-11-16 99 | ### Added 100 | - Field.is_ and Field.is_not_ methods now use Stere.retry_time if not specified. 101 | - Stere.retry_time can be set through the stere.ini file. 102 | - Repeating and RepeatingArea now have the has_children() method. 103 | 104 | ### Changed 105 | - FindByDataStarAttribute renamed to FindByAttribute. 106 | 107 | ### Fixed 108 | - Field.is_present() and Field.is_not_present() now work correctly with FindByAttribute. 109 | 110 | ## [0.12.0] - 2019-10-21 111 | ### Fixed 112 | - Field.is_present() and Field.is_not_present() now work with Fields inside a RepeatingArea. 113 | 114 | ## [0.11.0] - 2019-10-17 115 | ### Added 116 | - Page.page_url now built from Stere.base_url and Page.url_suffix. 117 | 118 | ## [0.10.0] - 2019-10-09 119 | ### Changed 120 | - An Area can now be placed inside a RepeatingArea. 121 | - Areas.containing now accepts nested values. 122 | - Areas.contain now accepts nested values. 123 | 124 | ## [0.9.0] - 2019-09-12 125 | ### Added 126 | - .is_clickable() and .is_not_clickable() are now available for splinter Fields. 127 | 128 | ## [0.8.0] - 2019-05-30 129 | ### Added 130 | - Added Money Field in Splinter integration. [py-moneyed](https://github.com/limist/py-moneyed) is used to provide functionality. 131 | 132 | ## [0.7.0] - 2019-03-14 133 | ### Added 134 | - Splinter and Appium Input Fields can now take a default_value parameter. 135 | - Stere.url_navigator has a default value when Splinter is used. 136 | 137 | ### Changed 138 | - If an invalid locator strategy is used, the error message now reports valid strategies. 139 | 140 | ## [0.6.1] - 2019-02-22 141 | ### Changed 142 | - Base Field, Root, and Text now use @stere_performer instead of a custom perform method. 143 | 144 | ### Fixed 145 | - Implicit Field calls now work with all Fields. 146 | 147 | ## [0.6.0] - 2019-02-22 148 | ### Added 149 | - Field can take the keyword argument "returns". The object given will be returned after Field.perform() is called. 150 | - Field now executes Field.perform() when called. 151 | 152 | ### Changed 153 | - Stere decorators can now be used by importing Field.decorators. 154 | 155 | ## [0.5.0] - 2019-01-15 156 | ### Added 157 | - Add Field.value_equals() and Field.value_contains() methods. 158 | - Add Areas.containing(). 159 | - Add Repeating class to handle ridiculously nested collections. 160 | 161 | ### Changed 162 | - Deprecated RepeatingArea.area_with(). 163 | - Areas container only accepts Area objects inside it. 164 | 165 | ### Fixed 166 | - FindByDataStarAttribute inherits from SplinterBase. 167 | 168 | ## [0.4.0] - 2019-01-02 169 | ### Added 170 | - Added RepeatingArea.areas.contain() method. 171 | 172 | ### Changed 173 | - RepeatingArea.areas now returns a list-like object instead of a list. 174 | - Page.navigate() returns the Page instance. 175 | 176 | ### Fixed 177 | - If a Field is found multiple times, ensure an error is thrown when Field.find() is used. 178 | 179 | ## [0.3.0] - 2018-11-06 180 | ### Added 181 | - Appium compatibility started. 182 | 183 | ### Changed 184 | - RepeatingArea can now use any Field as a root. 185 | - Root Field no longer overrides Field.find(). 186 | 187 | ## [0.2.3] - 2018-10-19 188 | ### Fixed 189 | - Preserve class name on Fields that implement a performer. 190 | - Fix implementation of is_visible and is_not_visible when using Splinter. 191 | 192 | ## [0.2.2] - 2018-10-16 193 | ### Added 194 | - python 3.7 now supported. 195 | - stere.ini config file can be used to specify automation library. 196 | - Field implements the \__repr__ method. 197 | - RepeatingArea implements the \__len__ method. 198 | 199 | ### Changed 200 | - Splinter specific implementation refactored in Field.find(). 201 | 202 | ## [0.2.1] - 2018-09-12 203 | ### Added 204 | - Area.perform() can now take keyword arguments. 205 | 206 | ## [0.2.0] - 2018-08-23 207 | ### Added 208 | - Page class is now a Context Manager. 209 | - Added is_visible and is_not_visible methods to Field. 210 | - Added CHANGELOG file. 211 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Joshua Fehler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Stere 2 | ===== 3 | 4 | 5 | .. image:: https://img.shields.io/pypi/v/stere.svg 6 | :target: https://pypi.org/project/stere 7 | :alt: PyPI 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/stere.svg 10 | :alt: PyPI - Python Version 11 | :target: https://github.com/jsfehler/stere 12 | 13 | .. image:: https://img.shields.io/github/license/jsfehler/stere.svg 14 | :alt: GitHub 15 | :target: https://github.com/jsfehler/stere/blob/master/LICENSE 16 | 17 | .. image:: https://pyup.io/repos/github/jsfehler/stere/shield.svg 18 | :alt: Updates 19 | :target: https://pyup.io/repos/github/jsfehler/stere 20 | 21 | .. image:: https://github.com/jsfehler/stere/workflows/CI/badge.svg 22 | :target: https://github.com/jsfehler/stere/actions/workflows/run_tests.yml 23 | :alt: Build status 24 | 25 | .. image:: https://codecov.io/gh/jsfehler/stere/branch/master/graph/badge.svg?token=C1vfu8YgWn 26 | :target: https://codecov.io/gh/jsfehler/stere 27 | 28 | .. image:: https://saucelabs.com/buildstatus/jsfehler 29 | :target: https://saucelabs.com/u/jsfehler 30 | 31 | Stere is a library for writing Page Objects, designed to work on top of an existing automation library. 32 | 33 | 34 | Design Philosophy 35 | ----------------- 36 | 37 | Many implementations of the Page Object model focus on removing the duplication of element locators inside tests. 38 | Stere goes one step further, offering a complete wrapper over the code that drives automation. 39 | 40 | The goals of this project are to: 41 | 42 | 1 - Eliminate implementation code in test functions. Tests should read like a description of behaviour, not Selenium commands. 43 | 44 | 2 - Reduce the need for hand-written helper methods in Page Objects. Common actions should have universal solutions. 45 | 46 | 3 - Provide a simple pattern for writing maintainable Page Objects. 47 | 48 | No automation abilities are built directly into the project; it completely relies on being hooked into other libraries. 49 | However, implementations using `Splinter `_ and `Appium `_ are available out of the box. 50 | 51 | 52 | Documentation 53 | ------------- 54 | 55 | https://stere.readthedocs.io/en/latest/ 56 | 57 | 58 | Basic Usage 59 | ----------- 60 | 61 | Fundamentally, a Page Object is just a Python class. 62 | 63 | A minimal Stere Page Object should: 64 | 65 | 1 - Subclass the Page class 66 | 67 | 2 - Declare Fields and Areas in the __init__ method 68 | 69 | As an example, here's the home page for Wikipedia: 70 | 71 | .. code-block:: python 72 | 73 | from stere import Page 74 | from stere.areas import Area, RepeatingArea 75 | from stere.fields import Button, Input, Link, Root, Text 76 | 77 | 78 | class WikipediaHome(Page): 79 | def __init__(self): 80 | self.search_form = Area( 81 | query=Input('id', 'searchInput'), 82 | submit=Button('xpath', '//*[@id="search-form"]/fieldset/button') 83 | ) 84 | 85 | self.other_projects = RepeatingArea( 86 | root=Root('xpath', '//*[@class="other-project"]'), 87 | title=Link('xpath', '//*[@class="other-project-title"]'), 88 | tagline=Text('xpath', '//*[@class="other-project-tagline"]') 89 | ) 90 | 91 | The search form is represented as an `Area `_ with two `Fields `_ inside it. 92 | 93 | A Field represents a single item, while an Area represents a unique collection of Fields. 94 | 95 | The query and submit Fields didn't have to be placed inside an Area. 96 | However, doing so allows you to use Area's `perform() `_ method. 97 | 98 | The links to other products are represented as a `RepeatingArea `_ . 99 | A RepeatingArea represents a non-unique collection of Fields on the page. 100 | Using the root argument as the non-unique selector, RepeatingArea will find all instances of said root, 101 | then build the appropriate number of Areas with all the other Fields inside. 102 | 103 | It's just as valid to declare each of the other products as a separate Area 104 | one at a time, like so: 105 | 106 | .. code-block:: python 107 | 108 | self.commons = Area( 109 | root=Root('xpath', '//*[@class="other-project"][1]'), 110 | title=Link('xpath', '//*[@class="other-project-title"]'), 111 | tagline=Text('xpath', '//*[@class="other-project-tagline"]') 112 | ) 113 | 114 | self.wikivoyage = Area( 115 | root=Root('xpath', '//*[@class="other-project"][2]'), 116 | title=Link('xpath', '//*[@class="other-project-title"]'), 117 | tagline=Text('xpath', '//*[@class="other-project-tagline"]') 118 | ) 119 | 120 | Which style you pick depends entirely on how you want to model the page. 121 | RepeatingArea does the most good with collections where the number of areas and/or the contents of the areas 122 | can't be predicted, such as inventory lists. 123 | 124 | Using a Page Object in a test can be done like so: 125 | 126 | .. code-block:: python 127 | 128 | def test_search_wikipedia(): 129 | home = WikipediaHome() 130 | home.search_form.perform('kittens') 131 | 132 | 133 | License 134 | ------- 135 | 136 | Distributed under the terms of the `MIT`_ license, "Stere" is free and open source software 137 | 138 | 139 | Issues 140 | ------ 141 | 142 | If you encounter any problems, please `file an issue`_ along with a detailed description. 143 | 144 | 145 | Thanks 146 | ------ 147 | 148 | Cross-browser Testing Platform and Open Source <3 Provided by `Sauce Labs`_ 149 | 150 | 151 | .. _`file an issue`: https://github.com/jsfehler/stere/issues 152 | .. _`MIT`: http://opensource.org/licenses/MIT 153 | .. _`Sauce labs`: https://saucelabs.com 154 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Stere 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/appium_fields.rst: -------------------------------------------------------------------------------- 1 | Appium Integration 2 | ------------------ 3 | 4 | Stere contains Fields designed specifically for when Appium is connected. 5 | Each implements a specific performer method. 6 | 7 | 8 | Fields 9 | ~~~~~~ 10 | 11 | Button 12 | ++++++ 13 | 14 | .. class:: stere.fields.Button() 15 | 16 | Convenience Class on top of Field, it implements `click()` as its performer. 17 | 18 | .. automethod:: stere.fields.appium.button.Button.click() 19 | 20 | 21 | Input 22 | +++++ 23 | 24 | .. class:: stere.fields.Input() 25 | 26 | A simple wrapper over Field, it implements `send_keys()` as its performer. 27 | 28 | The `default_value` argument can be provided, which will be used if send_keys() is called with no arguments. 29 | 30 | .. code-block:: python 31 | 32 | self.quantity = Dropdown('id', 'qty', default_value='555') 33 | 34 | .. automethod:: stere.fields.appium.input.Input.send_keys() 35 | 36 | Fills the element with value. 37 | 38 | 39 | Locator Strategies 40 | ~~~~~~~~~~~~~~~~~~ 41 | .. _locator_strategies: 42 | 43 | These represent the way a locator will be searched for. 44 | 45 | By default, the strategies available are: 46 | 47 | - accessibility_id 48 | - android_uiautomator 49 | - ios_class_chain 50 | - ios_predicate 51 | - ios_uiautomation 52 | 53 | These strategies can be overridden with a custom strategy (ie: You can create a custom accessibility_id strategy with different behaviour). 54 | -------------------------------------------------------------------------------- /docs/area.rst: -------------------------------------------------------------------------------- 1 | Areas 2 | ===== 3 | 4 | Areas represent groupings of Fields on a Page. 5 | 6 | The following Area objects are available: 7 | 8 | - Area: A non-hierarchical, unique group of Fields. 9 | - RepeatingArea: A hierarchical, non-unique group of Areas. They require a Root Field. 10 | 11 | 12 | .. autoclass:: stere.areas.Area() 13 | 14 | .. automethod:: stere.areas.Area.perform() 15 | 16 | .. automethod:: stere.areas.Area.workflow() 17 | 18 | 19 | .. autoclass:: stere.areas.RepeatingArea() 20 | 21 | .. autoattribute:: stere.areas.RepeatingArea.areas() 22 | 23 | 24 | .. autoclass:: stere.areas.Areas() 25 | 26 | .. automethod:: stere.areas.Areas.containing() 27 | 28 | .. automethod:: stere.areas.Areas.contain() 29 | 30 | 31 | Reusing Areas 32 | ------------- 33 | 34 | Sometimes an identical Area may be present on multiple pages. 35 | Areas do not need to be created inside a page object, they can be created outside and then called from inside a page. 36 | 37 | .. code-block:: python 38 | 39 | header = Area( 40 | ... 41 | ) 42 | 43 | class Items(Page): 44 | def __init__(self, *args, **kwargs): 45 | self.header = header 46 | 47 | 48 | Subclassing Areas 49 | ----------------- 50 | 51 | If an Area appears on many pages and requires many custom methods, 52 | it may be better to subclass the Area instead of embedding the methods in the Page Object: 53 | 54 | .. code-block:: python 55 | 56 | class Header(Area): 57 | def __init__(self, *args, **kwargs): 58 | super().__init__(*args, **kwargs) 59 | 60 | def my_custom_method(self, *args, **kwargs): 61 | ... 62 | 63 | 64 | class Main(Page): 65 | def __init__(self, *args, **kwargs): 66 | self.header = Header() 67 | 68 | 69 | class Other(Page): 70 | def __init__(self, *args, **kwargs): 71 | self.header = Header() 72 | -------------------------------------------------------------------------------- /docs/best_practices.rst: -------------------------------------------------------------------------------- 1 | Best Practices 2 | ============== 3 | 4 | A highly opinionated guide. Ignore at your own peril. 5 | 6 | Favour adding methods to Fields and Areas over Page Objects 7 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 8 | 9 | If a new method is acting on a specific Field, subclass the Field and add the 10 | method there instead of adding the method to the Page Object. 11 | 12 | 13 | **Wrong:** 14 | 15 | .. code-block:: python 16 | 17 | class Inventory(Page): 18 | def __init__(self): 19 | self.medals = Field('id', 'medals') 20 | 21 | def count_the_medals(self): 22 | return len(self.medals.find()) 23 | 24 | 25 | def test_you_got_the_medals(): 26 | inventory = Inventory() 27 | assert 3 == inventory.count_the_medals() 28 | 29 | 30 | **Right:** 31 | 32 | .. code-block:: python 33 | 34 | class Medals(Field): 35 | def count(self): 36 | return len(self.find()) 37 | 38 | 39 | class Inventory(Page): 40 | def __init__(self): 41 | self.medals = Medals('id', 'medals') 42 | 43 | 44 | def test_you_got_the_medals(): 45 | inventory = Inventory() 46 | assert 3 == inventory.medals.count() 47 | 48 | 49 | **Explanation:** 50 | 51 | Even if a Field or Area initially appears on only one page, subclassing will 52 | lead to code that is more easily reused and/or moved. 53 | 54 | In this example, inventory.count_the_medals() may look easier to read than 55 | inventory.medals.count(). However, creating methods with long names and 56 | specific verbiage makes your Page Objects less predictable and more prone to 57 | inconsistency. 58 | 59 | 60 | Favour page composition over inheritance 61 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 62 | 63 | When building Page Objects for something with many reused pieces 64 | (such as a settings menu) don't build an abstract base Page Object. 65 | Build each component separately and call them in Page Objects that reflect the application. 66 | 67 | **Inheritance:** 68 | 69 | .. code-block:: python 70 | 71 | class BaseSettings(Page): 72 | def __init__(self): 73 | self.settings_menu = Area(...) 74 | 75 | 76 | class SpecificSettings(BaseSettings): 77 | def __init__(self): 78 | super().__init__() 79 | 80 | 81 | **Composition:** 82 | 83 | .. code-block:: python 84 | 85 | from .another_module import settings_menu 86 | 87 | class SpecificSettings(Page): 88 | def __init__(self): 89 | self.menu = settings_menu 90 | 91 | 92 | **Explanation:** 93 | 94 | Doing so maintains the benefits of reusing code, but prevents the creation of 95 | Page Objects that don't reflect actual pages in an application. 96 | 97 | Creating abstract Page Objects to inherit from can make it confusing as to 98 | what Fields are available on a page. 99 | 100 | 101 | Naming Fields 102 | ~~~~~~~~~~~~~ 103 | 104 | Describing the Field VS Describing the Field's Action 105 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ 106 | 107 | When naming a field instance, the choice is usually between a description of 108 | the field or a description of what the field does: 109 | 110 | **Describing the Field:** 111 | 112 | .. code-block:: python 113 | 114 | class Navigation(Page): 115 | def __init__(self): 116 | self.settings_button = Button('id', 'settingsLink') 117 | 118 | 119 | **Describing the Action:** 120 | 121 | .. code-block:: python 122 | 123 | class Navigation(Page): 124 | def __init__(self): 125 | self.goto_settings = Button('id', 'settingsLink') 126 | 127 | 128 | At the outset, either option can seem appropriate. Consider the usage inside 129 | a test: 130 | 131 | .. code-block:: python 132 | 133 | nav_page = Navigation() 134 | nav_page.settings_button.click() 135 | 136 | VS 137 | 138 | .. code-block:: python 139 | 140 | nav_page = Navigation() 141 | nav_page.goto_settings.click() 142 | 143 | 144 | However, consider what happens when a Field returns a Page: 145 | 146 | .. code-block:: python 147 | 148 | class Navigation(Page): 149 | def __init__(self): 150 | self.settings_page = Button('id', 'settingsLink', returns=NextPage()) 151 | 152 | .. code-block:: python 153 | 154 | class Navigation(Page): 155 | def __init__(self): 156 | self.goto_settings = Button('id', 'settingsLink', returns=NextPage()) 157 | 158 | .. code-block:: python 159 | 160 | nav_page = Navigation() 161 | settings_page = nav_page.settings_button.perform() 162 | 163 | .. code-block:: python 164 | 165 | nav_page = Navigation() 166 | settings_page = nav_page.goto_settings.perform() 167 | 168 | 169 | Or, calling the perform method implicitly: 170 | 171 | .. code-block:: python 172 | 173 | nav_page = Navigation() 174 | settings_page = nav_page.settings_button() 175 | 176 | 177 | .. code-block:: python 178 | 179 | nav_page = Navigation() 180 | settings_page = nav_page.goto_settings() 181 | 182 | 183 | In the end, naming Fields will depend on what they do and how your tests use them. 184 | 185 | 186 | Single blank line when changing page object 187 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 188 | 189 | **Wrong:** 190 | 191 | .. code-block:: python 192 | 193 | def test_the_widgets(): 194 | knicknacks = Knicknacks() 195 | knicknacks.menu.gadgets.click() 196 | knicknacks.gadgets.click() 197 | gadgets = Gadgets() 198 | gadgets.navigate() 199 | 200 | gadgets.add_widgets.click() 201 | gadgets.add_sprocket.click() 202 | 203 | 204 | **Right:** 205 | 206 | .. code-block:: python 207 | 208 | def test_the_widgets(): 209 | knicknacks = Knicknacks() 210 | knicknacks.menu.gadgets.click() 211 | knicknacks.gadgets.click() 212 | 213 | gadgets = Gadgets() 214 | gadgets.navigate() 215 | gadgets.add_widgets.click() 216 | gadgets.add_sprocket.click() 217 | 218 | 219 | **Explanation:** 220 | 221 | Changing pages usually indicates a navigation action. 222 | Using a consistent line break style visually helps to indicate the steps of a test. 223 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Stere documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Jul 8 17:30:05 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('../')) 23 | from stere import * # NOQA 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.coverage', 38 | 'sphinx.ext.napoleon', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = 'Stere' 55 | copyright = '2018, Joshua Fehler' 56 | author = 'Joshua Fehler' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '0.31.0' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '0.31.0' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This patterns also effect to html_static_path and html_extra_path 77 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = 'sphinx' 81 | 82 | # If true, `todo` and `todoList` produce output, else they produce nothing. 83 | todo_include_todos = False 84 | 85 | 86 | # -- Options for HTML output ---------------------------------------------- 87 | 88 | # The theme to use for HTML and HTML Help pages. See the documentation for 89 | # a list of builtin themes. 90 | # 91 | html_theme = "sphinx_rtd_theme" 92 | 93 | # Theme options are theme-specific and customize the look and feel of a theme 94 | # further. For a list of options available for each theme, see the 95 | # documentation. 96 | # 97 | # html_theme_options = {} 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ['_static'] 103 | 104 | # Custom sidebar templates, must be a dictionary that maps document names 105 | # to template names. 106 | # 107 | # This is required for the alabaster theme 108 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 109 | html_sidebars = { 110 | '**': [ 111 | 'relations.html', # needs 'show_related': True theme option to display 112 | 'searchbox.html', 113 | ], 114 | } 115 | 116 | 117 | # -- Options for HTMLHelp output ------------------------------------------ 118 | 119 | # Output file base name for HTML help builder. 120 | htmlhelp_basename = 'Steredoc' 121 | 122 | 123 | # -- Options for LaTeX output --------------------------------------------- 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | 134 | # Additional stuff for the LaTeX preamble. 135 | # 136 | # 'preamble': '', 137 | 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'Stere.tex', 'Stere Documentation', 148 | 'Joshua Fehler', 'manual'), 149 | ] 150 | 151 | 152 | # -- Options for manual page output --------------------------------------- 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [ 157 | (master_doc, 'stere', 'Stere Documentation', 158 | [author], 1), 159 | ] 160 | 161 | 162 | # -- Options for Texinfo output ------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | (master_doc, 'Stere', 'Stere Documentation', 169 | author, 'Stere', 'One line description of project.', 170 | 'Miscellaneous'), 171 | ] 172 | 173 | 174 | # Example configuration for intersphinx: refer to the Python standard library. 175 | intersphinx_mapping = {'https://docs.python.org/': None} 176 | -------------------------------------------------------------------------------- /docs/field.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ------ 3 | 4 | Field 5 | ~~~~~ 6 | 7 | .. autoclass:: stere.fields.Field() 8 | 9 | .. automethod:: stere.fields.Field.includes() 10 | 11 | .. automethod:: stere.fields.Field.before() 12 | 13 | .. automethod:: stere.fields.Field.after() 14 | 15 | .. automethod:: stere.fields.Field.value_contains() 16 | 17 | .. automethod:: stere.fields.Field.value_equals() 18 | 19 | 20 | Root 21 | ~~~~ 22 | 23 | .. autoclass:: stere.fields.Root() 24 | 25 | 26 | Text 27 | ~~~~ 28 | 29 | .. autoclass:: stere.fields.Text() 30 | 31 | 32 | Performer method 33 | ~~~~~~~~~~~~~~~~ 34 | 35 | A Field can have a single method be designated as a performer. 36 | This method will be called when the Field is inside an Area and that Area's perform() method is called. 37 | 38 | For example, Input's performer is the fill() method, and Button's performer is the click() method. Given the following Area: 39 | 40 | .. code-block:: python 41 | 42 | search = Area( 43 | query=Input('id', 'xsearch'), 44 | submit=Button('id', 'xsubmit'), 45 | ) 46 | 47 | and the following script: 48 | 49 | .. code-block:: python 50 | 51 | search.perform('Orange') 52 | 53 | 54 | When ``search.perform('Orange')`` is called, ``query.fill('Orange')`` is called, followed by ``submit.click()``. 55 | 56 | See the documentation for `Area `_ for more details. 57 | 58 | 59 | Calling the performer method explicitly 60 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 61 | 62 | The performer method is available as ``Field.perform()``. 63 | Calling it will run the performer method, but they are not aliases. 64 | 65 | No matter what the return value of the performer method is, 66 | the return value from calling Field.perform() will always be the Field.returns attribute. 67 | 68 | Using the splinter Button Field as an example, the only difference between 69 | `Button.click()` and `Button.perform()` is that perform will return the object 70 | set in the `Field.returns` attribute. 71 | See `Returning Objects `_ for more details. 72 | 73 | Calling the performer method implicitly 74 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | 76 | When a page instance is called directly, the `perform()` method will be executed. 77 | 78 | The following code will produce the same results: 79 | 80 | .. code-block:: python 81 | 82 | button = Button() 83 | button.perform() 84 | 85 | 86 | .. code-block:: python 87 | 88 | button = Button() 89 | button() 90 | 91 | 92 | Events 93 | ~~~~~~ 94 | 95 | Fields inherit from an EventEmitter class. 96 | 97 | By default, they emit the following events: 98 | 99 | - before: Before methods with the `@use_before` decorator are called. 100 | - after: After methods with the `@use_after` decorator are called. 101 | 102 | .. automethod:: stere.fields.Field.on() 103 | 104 | .. automethod:: stere.fields.Field.emit() 105 | 106 | .. autoattribute:: stere.fields.Field.events() 107 | 108 | 109 | Subclassing Field 110 | ~~~~~~~~~~~~~~~~~ 111 | 112 | Field can be subclassed to suit your own requirements. 113 | 114 | If the __init__() method is overwritten, make sure to call super() before your own code. 115 | 116 | If your class needs specific behaviour when interacting with Areas, it must be wrapped with the @stere_performer decorator to specify a performer method. 117 | 118 | When creating a new type of Field, the stere_performer class decorator should used to assign a performer method. 119 | 120 | 121 | Field Decorators 122 | ~~~~~~~~~~~~~~~~ 123 | 124 | .. automethod:: stere.fields.decorators.stere_performer() 125 | 126 | .. automethod:: stere.fields.decorators.use_before() 127 | 128 | .. automethod:: stere.fields.decorators.use_after() 129 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | --------------- 3 | 4 | 5 | Requirements 6 | ============ 7 | 8 | Python >= 3.6 9 | 10 | 11 | Installation 12 | ============ 13 | 14 | Stere can be installed with pip using the following command: 15 | 16 | .. code-block:: bash 17 | 18 | pip install stere 19 | 20 | 21 | Setup 22 | ===== 23 | 24 | Specifying the automation library 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | Using a `stere.ini` file, the automation library used can be specified. 28 | This determines which library specific Fields are loaded. 29 | 30 | While only Splinter and Appium have custom Fields to take advantage of their 31 | specific capabilities, any automation library that implements an API similar to 32 | Selenium should be possible to connect to Stere. 33 | 34 | `splinter` is used by default, and `appium` is supported. 35 | Any other value will be accepted, which will result in no specific Fields 36 | being loaded. 37 | 38 | 39 | .. code-block:: ini 40 | 41 | [stere] 42 | library = appium 43 | 44 | 45 | Stere.browser 46 | ~~~~~~~~~~~~~ 47 | 48 | Stere requires a browser (aka driver) to work with. 49 | This can be any class that ultimately drives automation. 50 | Pages, Fields, and Areas inherit their functionality from this object. 51 | 52 | Here's an example with `Splinter `_: 53 | 54 | .. code-block:: python 55 | 56 | from stere import Stere 57 | from splinter import Browser 58 | 59 | Stere.browser = Browser() 60 | 61 | 62 | As long as the base Stere object has the browser set, the browser's 63 | functionality is passed down to everything else. 64 | 65 | Stere.base_url 66 | ~~~~~~~~~~~~~~ 67 | 68 | Optionally, an attribute called base_url can be provided a string that will 69 | be used as the base for all urls returned by `Page.page_url` 70 | 71 | .. code-block:: python 72 | 73 | from stere import Stere 74 | from splinter import Browser 75 | 76 | Stere.browser = Browser() 77 | Stere.base_url = 'http://foobar.com/' 78 | 79 | class MyPage(Page): 80 | def __init__(self): 81 | self.url_suffix = 'mysuffix' 82 | 83 | >>> MyPage().page_url == 'http://foobar.com/mysuffix' 84 | 85 | Stere.url_navigator 86 | ~~~~~~~~~~~~~~~~~~~ 87 | 88 | Optionally, an attribute called `url_navigator` can be provided a string that 89 | maps to the method in the browser that opens a page. 90 | 91 | In Splinter's case, this is the `visit` method. 92 | 93 | .. code-block:: python 94 | 95 | from stere import Stere 96 | from splinter import Browser 97 | 98 | Stere.browser = Browser() 99 | Stere.url_navigator = 'visit' 100 | 101 | This attribute is used by the `Page` class to make url navigation easier. 102 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Stere documentation master file, created by 2 | sphinx-quickstart on Sun Jul 8 17:30:05 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Stere's documentation! 7 | ================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | Documentation 14 | ============= 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | getting_started 20 | page 21 | field 22 | splinter_fields 23 | appium_fields 24 | area 25 | repeating 26 | workflows 27 | returning_objects 28 | request_spy 29 | best_practices 30 | 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Stere 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/page.rst: -------------------------------------------------------------------------------- 1 | Pages 2 | ----- 3 | 4 | .. autoclass:: stere.Page() 5 | 6 | .. autoattribute:: stere.Page.page_url 7 | 8 | .. automethod:: stere.Page.navigate() 9 | 10 | 11 | Using Page as a Context Manager 12 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 13 | 14 | Page contains __enter__() and __exit__() methods. This allows any page 15 | to be used as a Context Manager. 16 | 17 | Example: 18 | 19 | .. code-block:: python 20 | 21 | from pages import Home 22 | 23 | with Home() as p: 24 | p.login_button.click() 25 | -------------------------------------------------------------------------------- /docs/repeating.rst: -------------------------------------------------------------------------------- 1 | Repeating 2 | ========= 3 | 4 | .. autoclass:: stere.areas.Repeating() 5 | 6 | .. automethod:: stere.areas.Repeating.new_container() 7 | 8 | .. automethod:: stere.areas.Repeating.children() 9 | 10 | .. automethod:: stere.areas.Repeating.has_children() 11 | -------------------------------------------------------------------------------- /docs/request_spy.rst: -------------------------------------------------------------------------------- 1 | Network Request Spies 2 | ===================== 3 | 4 | Network Request Spies inject hooks into the web browser to track activity. 5 | 6 | The preferred usage is through the default instances on Page objects via the 7 | following attributes: 8 | 9 | ``Page.fetch_spy`` 10 | 11 | ``Page.xhr_spy`` 12 | 13 | 14 | .. autoclass:: stere.browser_spy.FetchSpy() 15 | 16 | .. automethod:: stere.browser_spy.FetchSpy.add() 17 | 18 | .. automethod:: stere.browser_spy.FetchSpy.wait_for_no_activity() 19 | 20 | .. autoattribute:: stere.browser_spy.FetchSpy.active() 21 | 22 | .. autoattribute:: stere.browser_spy.FetchSpy.total() 23 | 24 | 25 | .. autoclass:: stere.browser_spy.XHRSpy() 26 | 27 | .. automethod:: stere.browser_spy.XHRSpy.add() 28 | 29 | .. automethod:: stere.browser_spy.XHRSpy.wait_for_no_activity() 30 | 31 | .. autoattribute:: stere.browser_spy.XHRSpy.active() 32 | 33 | .. autoattribute:: stere.browser_spy.XHRSpy.total() 34 | -------------------------------------------------------------------------------- /docs/returning_objects.rst: -------------------------------------------------------------------------------- 1 | Fields Returning Objects 2 | ======================== 3 | 4 | Fields take an optional `returns` argument. This can be any object. 5 | When the Field's perform() method is called, this object will be returned. 6 | 7 | This can be used to return another Page Object. 8 | 9 | .. code-block:: python 10 | 11 | class Navigation(Page): 12 | def __init__(self): 13 | self.goto_settings = Button('id', 'settingsLink', returns=NextPage()) 14 | 15 | .. code-block:: python 16 | 17 | def test_navigation(): 18 | page = Navigation() 19 | next_page = page.goto_settings.perform() 20 | 21 | Fields inside an Area 22 | +++++++++++++++++++++ 23 | 24 | When a Field is inside an Area and has the returns argument set, only the 25 | object for the last Field in the Area will be returned when `Area.perform()` is called. 26 | 27 | 28 | .. code-block:: python 29 | 30 | class Address(Page): 31 | def __init__(self): 32 | self.form = Area( 33 | address=Input('id', 'formAddress'), 34 | city=Input('id', 'formCity', returns=FooPage()), 35 | submit=Button('id', 'formsubmit', returns=NextPage()), 36 | ) 37 | 38 | 39 | .. code-block:: python 40 | 41 | def test_address_form(): 42 | page = Address() 43 | next_page = page.form.perform() 44 | -------------------------------------------------------------------------------- /docs/splinter_fields.rst: -------------------------------------------------------------------------------- 1 | Splinter Integration 2 | -------------------- 3 | 4 | Stere contains Fields designed specifically for when Splinter is connected. 5 | Each implements a specific performer method. 6 | 7 | 8 | All Fields designed for Splinter also inherit the following convenience methods: 9 | 10 | .. automethod:: stere.strategy.splinter.SplinterBase.is_clickable() 11 | .. automethod:: stere.strategy.splinter.SplinterBase.is_not_clickable() 12 | .. automethod:: stere.strategy.splinter.SplinterBase.is_present() 13 | .. automethod:: stere.strategy.splinter.SplinterBase.is_not_present() 14 | .. automethod:: stere.strategy.splinter.SplinterBase.is_visible() 15 | .. automethod:: stere.strategy.splinter.SplinterBase.is_not_visible() 16 | 17 | Example: 18 | 19 | .. code-block:: python 20 | 21 | class Inventory(Page): 22 | def __init__(self): 23 | self.price = Link('css', '.priceLink') 24 | 25 | 26 | assert Inventory().price.is_present(wait_time=6) 27 | 28 | 29 | Fields 30 | ~~~~~~ 31 | 32 | Button 33 | ++++++ 34 | 35 | .. class:: stere.fields.Button() 36 | 37 | Convenience Class on top of Field, it implements `click()` as its performer. 38 | 39 | .. automethod:: stere.fields.Button.click() 40 | 41 | 42 | Checkbox 43 | ++++++++ 44 | 45 | .. class:: stere.fields.Checkbox() 46 | 47 | By default, the Checkbox field works against HTML inputs with type="checkbox". 48 | 49 | Can be initialized with the `default_checked` argument. If True, the Field assumes the checkbox's default state is checked. 50 | 51 | It implements `opposite()` as its performer. 52 | 53 | .. automethod:: stere.fields.Checkbox.set_to() 54 | 55 | .. automethod:: stere.fields.Checkbox.toggle() 56 | 57 | .. automethod:: stere.fields.Checkbox.opposite() 58 | 59 | 60 | Dropdown 61 | ++++++++ 62 | 63 | .. class:: stere.fields.Dropdown() 64 | 65 | By default, the Dropdown field works against HTML Dropdowns. 66 | However, it's possible to extend Dropdown to work with whatever implementation of a CSS Dropdown you need. 67 | 68 | It implements `select()` as its performer. 69 | 70 | The `option` argument can be provided to override the default implementation. 71 | This argument expects a Field. The Field should be the individual options in the dropdown you wish to target. 72 | 73 | .. code-block:: python 74 | 75 | self.languages = Dropdown('id', 'langDrop', option=Button('xpath', '/h4/a/strong')) 76 | 77 | 78 | .. automethod:: stere.fields.Dropdown.options() 79 | 80 | .. automethod:: stere.fields.Dropdown.select() 81 | 82 | 83 | Input 84 | +++++ 85 | 86 | .. class:: stere.fields.Input() 87 | 88 | A simple wrapper over Field, it implements `fill()` as its performer. 89 | 90 | The `default_value` argument can be provided, which will be used if fill() is called with no arguments. 91 | 92 | .. code-block:: python 93 | 94 | self.quantity = Input('id', 'qty', default_value='555') 95 | 96 | .. automethod:: stere.fields.Input.fill() 97 | .. automethod:: stere.fields.Input.highlight() 98 | 99 | 100 | Link 101 | ++++ 102 | 103 | .. class:: stere.fields.Link() 104 | 105 | A simple wrapper over Field, it implements `click()` as its performer. 106 | 107 | .. automethod:: stere.fields.Link.click() 108 | 109 | 110 | Money 111 | +++++ 112 | 113 | .. class:: stere.fields.Money() 114 | 115 | Money has methods for handling Fields where the text is a form of currency. 116 | 117 | .. automethod:: stere.fields.Money.money() 118 | 119 | .. autoattribute:: stere.fields.Money.number 120 | 121 | 122 | Locator Strategies 123 | ~~~~~~~~~~~~~~~~~~ 124 | .. _locator_strategies: 125 | 126 | These represent the way a locator can be searched for. 127 | 128 | By default, the strategies available with Splinter are: 129 | 130 | - css 131 | - xpath 132 | - tag 133 | - name 134 | - text 135 | - id 136 | - value 137 | 138 | These strategies can be overridden with a custom strategy (ie: You can create a custom css strategy with different behaviour). 139 | 140 | 141 | Custom Locator Strategies 142 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 143 | 144 | Custom strategies can be defined using the `@strategy` decorator on top of a Class. 145 | 146 | Any class can be decorated with @strategy, as long as the _find_all and _find_all_in_parent methods are implemented. 147 | 148 | In the following example, the 'data-test-id' strategy is defined. 149 | It wraps Splinter's find_by_xpath method to simplify the locator required on the Page Object. 150 | 151 | 152 | .. code-block:: python 153 | 154 | from stere.strategy import strategy 155 | 156 | 157 | @strategy('data-test-id') 158 | class FindByDataTestId(): 159 | def _find_all(self): 160 | """Find from page root.""" 161 | return self.browser.find_by_xpath(f'.//*[@data-test-id="{self.locator}"]') 162 | 163 | def _find_all_in_parent(self): 164 | """Find from inside parent element.""" 165 | return self.parent_locator.find_by_xpath(f'.//*[@data-test-id="{self.locator}"]') 166 | 167 | 168 | With this implemented, Fields can now be defined like so: 169 | 170 | .. code-block:: python 171 | 172 | my_button = Button('data-test-id', 'MyButton') 173 | 174 | 175 | Support for data-* attributes is also available via the `add_data_star_strategy` function: 176 | 177 | .. code-block:: python 178 | 179 | from stere.strategy import add_data_star_strategy 180 | 181 | 182 | add_data_star_strategy('data-test-id') 183 | 184 | This will automatically add the desired data-* attribute to the valid Splinter strategies. 185 | -------------------------------------------------------------------------------- /docs/test_page/fetch_test_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/test_page/xhr_test_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/workflows.rst: -------------------------------------------------------------------------------- 1 | Workflows 2 | ========= 3 | 4 | When working with an Area that has multiple possible routes, there may be Fields which you do not want 5 | the .perform() method to call under certain circumstances. 6 | 7 | Take the following example Page Object: 8 | 9 | .. code-block:: python 10 | 11 | class AddSomething(Page): 12 | def __init__(self): 13 | self.form = Area( 14 | item_name=Input('id', 'itemName'), 15 | item_quantity=Input('id', 'itemQty'), 16 | save=Button('id', 'saveButton'), 17 | cancel=Button('id', 'cancelButton') 18 | ) 19 | 20 | Calling `AddSomething().form.perform()` would cause the save button and then the cancel button to be acted on. 21 | 22 | In these sorts of cases, Workflows can be used to manage which Fields are called. 23 | 24 | .. code-block:: python 25 | 26 | class AddSomething(Page): 27 | def __init__(self): 28 | self.form = Area( 29 | item_name=Input('id', 'itemName', workflows=["success", "failure"]), 30 | item_quantity=Input('id', 'itemQty', workflows=["success", "failure"]), 31 | save=Button('id', 'saveButton', workflows=["success"]), 32 | cancel=Button('id', 'cancelButton', workflows=["failure"]) 33 | ) 34 | 35 | 36 | Calling `AddSomething().form.workflow("success").perform()` will ensure that only Fields with a matching workflow are called. 37 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | image: latest 5 | 6 | python: 7 | version: 3.8 8 | install: 9 | - method: pip 10 | path: . 11 | extra_requirements: 12 | - splinter 13 | - requirements: requirements/docs.txt 14 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | selenium==4.8.2 2 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | pydocstyle==6.3.0 2 | flake8==7.0.0 3 | flake8-broken-line==1.0.0 4 | flake8-builtins==2.2.0 5 | flake8-bugbear==24.1.17 6 | flake8-commas==2.1.0 7 | flake8-comprehensions==3.14.0 8 | flake8-docstrings==1.7.0 9 | flake8-eradicate==1.5.0 10 | flake8-import-order==0.18.2 11 | flake8-multiline-containers==0.0.19 12 | flake8-mutable==1.2.0 13 | pep8-naming==0.13.3 14 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | Appium-Python-Client==2.5.0 2 | py-moneyed==3.0 3 | pytest==8.0.0 4 | pytest-cov==4.1.0 5 | pytest-splinter4==0.4.0 6 | pytest-xdist==3.5.0 7 | -------------------------------------------------------------------------------- /scripts/upload_ios_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | 5 | 6 | SAUCE_USERNAME = os.getenv('SAUCE_USERNAME') 7 | SAUCE_ACCESS_KEY = os.getenv('SAUCE_ACCESS_KEY') 8 | SAUCE_UPLOAD_URL = 'https://api.us-west-1.saucelabs.com/v1/storage/upload' 9 | 10 | APP_URL = ( 11 | 'https://github.com/jsfehler/stere_ios_test_app/' 12 | 'raw/master/build/stere_ios_test_app.zip' 13 | ) 14 | APP_FILENAME = 'stere_ios_test_app.zip' 15 | 16 | 17 | def get_app(): 18 | """Download the test app and save it to disk.""" 19 | response = requests.get(APP_URL) 20 | 21 | with open(APP_FILENAME, 'wb') as f: 22 | for block in response.iter_content(): 23 | f.write(block) 24 | 25 | 26 | def upload_app_to_sauce(): 27 | """Upload the test app to sauce labs.""" 28 | with open(APP_FILENAME, 'rb') as f: 29 | response = requests.post( 30 | SAUCE_UPLOAD_URL, 31 | files={'payload': f, 'name': APP_FILENAME}, 32 | auth=(SAUCE_USERNAME, SAUCE_ACCESS_KEY), 33 | ) 34 | 35 | return response 36 | 37 | 38 | if __name__ == '__main__': 39 | # Upload the IOS test app to sauce storage before running any tests. 40 | get_app() 41 | upload_app_to_sauce() 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def read(filename): 7 | path = os.path.join(os.path.dirname(__file__), filename) 8 | with open(path, 'r') as f: 9 | return f.read() 10 | 11 | 12 | setup( 13 | name="stere", 14 | version="0.31.0", 15 | description="A nice way of implementing the Page Object pattern.", 16 | long_description=read('README.rst'), 17 | author="Joshua Fehler", 18 | author_email="jsfehler@gmail.com", 19 | license="MIT", 20 | url="https://github.com/jsfehler/stere", 21 | package_data={'stere': ['py.typed']}, 22 | zip_safe=False, 23 | packages=find_packages(), 24 | install_requires=[ 25 | 'py-moneyed==1.0', 26 | ], 27 | extras_require={ 28 | 'splinter': ['splinter==0.18.0'], 29 | }, 30 | classifiers=( 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Development Status :: 4 - Beta", 38 | ), 39 | ) 40 | -------------------------------------------------------------------------------- /stere/__init__.py: -------------------------------------------------------------------------------- 1 | from .browserenabled import BrowserEnabled as Stere 2 | from .page import Page 3 | 4 | __all__ = [ 5 | "Stere", 6 | "Page", 7 | ] 8 | -------------------------------------------------------------------------------- /stere/areas/__init__.py: -------------------------------------------------------------------------------- 1 | from .area import Area 2 | from .areas import Areas 3 | from .repeating import Repeating 4 | from .repeating_area import RepeatingArea 5 | 6 | __all__ = [ 7 | 'Area', 8 | 'Areas', 9 | 'Repeating', 10 | 'RepeatingArea', 11 | ] 12 | -------------------------------------------------------------------------------- /stere/areas/area.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, TypeVar, Union 2 | 3 | from .repeating import Repeating 4 | from ..fields import Field 5 | 6 | 7 | T = TypeVar('T', bound='Area') 8 | 9 | 10 | class Area: 11 | """A collection of unique objects on a page. 12 | 13 | Area takes any number of the following object types: 14 | - Field 15 | - Area 16 | - Repeating 17 | 18 | Example: 19 | 20 | >>> from stere.areas import Area 21 | >>> from stere.fields import Button 22 | >>> 23 | >>> class Album(Page): 24 | >>> def __init__(self): 25 | >>> self.genres = Area( 26 | >>> jazz=Button('css', '.genreJazz'), 27 | >>> metal=Button('css', '.genreMetal'), 28 | >>> rock=Button('xpath', '//div[@id="genreRock"]'), 29 | >>> ) 30 | >>> 31 | >>> def test_album_genres(): 32 | >>> album = Album() 33 | >>> album.genres.rock.click() 34 | """ 35 | 36 | def __init__( 37 | self, 38 | root: Optional[Field] = None, 39 | **kwargs: Union[Field, T, Repeating], 40 | ): 41 | self.root = root 42 | 43 | # Store kwargs 44 | self._items = {} 45 | 46 | for key, value in kwargs.items(): 47 | if not isinstance(value, (Field, Area, Repeating)): 48 | raise ValueError( 49 | ( 50 | 'Areas must only be initialized with: ' 51 | 'Field, Area, Repeating types' 52 | ), 53 | ) 54 | self._items[key] = value 55 | 56 | # Sets the root for the element, if provided. 57 | if self.root is not None and value is not self.root: 58 | # Set the Field's _element's root 59 | if isinstance(value, Field): 60 | value._element.root = self.root 61 | 62 | elif isinstance(value, Area): 63 | # Area has a root, give that root a root. 64 | if value.root is not None: 65 | value.root._element.root = self.root 66 | # Area has no root, set every Field's _element's root 67 | else: 68 | for _k, v in value._items.items(): 69 | v._element.root = self.root 70 | 71 | # Repeating sets its root Field's root. 72 | elif isinstance(value, Repeating): 73 | value.root._element.root = self.root 74 | 75 | # Field can be called directly. 76 | setattr(self, key, value) 77 | 78 | self._workflow: Optional[str] = None 79 | 80 | def _set_parent_locator(self, element) -> None: 81 | """For every item in the Area, set a parent_locator. 82 | 83 | Arguments: 84 | element: The found element belonging to the parent of this object. 85 | """ 86 | if self.root is not None: 87 | self.root._set_parent_locator(element) 88 | else: 89 | for _, v in self._items.items(): 90 | v._set_parent_locator(element) 91 | 92 | def workflow(self: T, value: str) -> T: 93 | """Set the current workflow for an Area. 94 | 95 | Designed for chaining before a call to perform(). 96 | 97 | Arguments: 98 | value (str): The name of the workflow to set. 99 | 100 | Returns: 101 | Area: The calling Area 102 | 103 | Example: 104 | 105 | >>> my_area.workflow('Foobar').perform() 106 | 107 | """ 108 | self._workflow = value 109 | return self 110 | 111 | def perform(self, *args, **kwargs): 112 | """For every Field in an Area, "do the right thing" 113 | by calling the Field's perform() method. 114 | 115 | Fields that require an argument can either be given sequentially 116 | or with keywords. 117 | 118 | Arguments: 119 | args: Arguments that will sequentially be sent to Fields 120 | in this Area. 121 | kwargs: Arguments that will be sent specifically to the Field 122 | with a matching name. 123 | 124 | Example: 125 | 126 | Given the following Page Object: 127 | 128 | >>> from stere.areas import Area 129 | >>> from stere.fields import Button, Input 130 | >>> 131 | >>> class Login(): 132 | >>> def __init__(self): 133 | >>> self.form = Area( 134 | >>> username=Input('id', 'app-user'), 135 | >>> password=Input('id', 'app-pwd'), 136 | >>> submit=Button('id', 'app-submit') 137 | >>> ) 138 | 139 | Any of the following styles are valid: 140 | 141 | >>> def test_login(): 142 | >>> login = Login() 143 | >>> login.my_area.perform('Sven', 'Hoek') 144 | 145 | >>> def test_login(): 146 | >>> login = Login() 147 | >>> login.my_area.perform(username='Sven', password='Hoek') 148 | 149 | >>> def test_login(): 150 | >>> login = Login() 151 | >>> login.my_area.perform('Sven', password='Hoek') 152 | """ 153 | arg_index = 0 154 | workflow = self._workflow 155 | for field_name, field in self._items.items(): 156 | # If the Field isn't in the current workflow, skip it entirely. 157 | if workflow is not None and workflow not in field.workflows: 158 | continue 159 | 160 | if field_name in kwargs: 161 | result = field.perform(kwargs[field_name]) 162 | else: 163 | if args: 164 | result = field.perform(args[arg_index]) 165 | else: 166 | result = field.perform() 167 | 168 | # If we've run out of arguments, don't increase the index. 169 | if field.consumes_arg and len(args) > (arg_index + 1): 170 | arg_index += 1 171 | 172 | self._workflow = None 173 | 174 | return result 175 | 176 | def text_to_dict(self) -> Dict[str, Any]: 177 | """Get the text from every child of the Area. 178 | 179 | Fields give back the text of the field. 180 | Areas give back a dict with the text of every child. 181 | Repeatings give back a list of dicts with the text of every child. 182 | 183 | Returns: 184 | dict: A mapping of the name of the child and the found text. 185 | """ 186 | rv: Dict[str, Any] = {} 187 | 188 | for k, v in self._items.items(): 189 | if isinstance(v, Field): 190 | rv[k] = v.text 191 | elif isinstance(v, Area) or isinstance(v, Repeating): 192 | rv[k] = v.text_to_dict() 193 | 194 | return rv 195 | -------------------------------------------------------------------------------- /stere/areas/areas.py: -------------------------------------------------------------------------------- 1 | from collections import UserList 2 | 3 | from .area import Area 4 | from ..utils import rgetattr 5 | 6 | 7 | class Areas(UserList): 8 | """Searchable collection of Areas. 9 | 10 | Behaves like a list. 11 | """ 12 | 13 | def append(self, item: Area) -> None: 14 | """Add a new Area to the container. 15 | 16 | Raises: 17 | TypeError: If a non-Area object is given. 18 | 19 | """ 20 | if not isinstance(item, Area): 21 | raise TypeError( 22 | f"{item} is not an Area. " 23 | "Only Area objects can be inside Areas.", 24 | ) 25 | 26 | self.data.append(item) 27 | 28 | def containing(self, field_name: str, field_value: str): 29 | """Search for Areas where the Field's value 30 | matches the expected value and then returns an Areas object with all 31 | matches. 32 | 33 | Arguments: 34 | field_name (str): The name of the Field object. 35 | field_value (str): The value of the Field object. 36 | 37 | Returns: 38 | Areas: A new Areas object with matching results 39 | 40 | Example: 41 | 42 | >>> class Inventory(Page): 43 | >>> def __init__(self): 44 | >>> self.items = RepeatingArea( 45 | >>> root=Root('xpath', '//my_xpath_string'), 46 | >>> description=Text('xpath', '//my_xpath_string') 47 | >>> ) 48 | >>> 49 | >>> def test_stuff(): 50 | >>> # Ensure 10 items have a price of $9.99 51 | >>> inventory = Inventory() 52 | >>> found_areas = inventory.items.areas.containing( 53 | >>> "price", "$9.99") 54 | >>> assert 10 == len(found_areas) 55 | 56 | """ 57 | containing = Areas() 58 | for area in self: 59 | field = rgetattr(area, field_name) 60 | 61 | if field.value == field_value: 62 | containing.append(area) 63 | 64 | return containing 65 | 66 | def contain(self, field_name: str, field_value: str) -> bool: 67 | """Check if a Field in any Area contains a specific value. 68 | 69 | Arguments: 70 | field_name (str): The name of the Field object. 71 | field_value (str): The value of the Field object. 72 | 73 | Returns: 74 | bool: True if matching value found, else False 75 | 76 | Example: 77 | 78 | >>> class Inventory(Page): 79 | >>> def __init__(self): 80 | >>> self.items = RepeatingArea( 81 | >>> root=Root('xpath', '//div[@id='inventory']'), 82 | >>> description=Text('xpath', './td[1]') 83 | >>> ) 84 | >>> 85 | >>> def test_stuff(): 86 | >>> inventory = Inventory() 87 | >>> assert inventory.items.areas.contain( 88 | >>> "description", "Bananas") 89 | 90 | """ 91 | for area in self: 92 | field = rgetattr(area, field_name) 93 | 94 | if field.value == field_value: 95 | return True 96 | 97 | return False 98 | -------------------------------------------------------------------------------- /stere/areas/repeating.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import ( 3 | Any, Dict, List, Optional, TYPE_CHECKING, Type, TypeVar, Union, 4 | ) 5 | 6 | if TYPE_CHECKING: # pragma: no cover 7 | from .area import Area 8 | from ..fields import Field 9 | from ..utils import _retry 10 | 11 | 12 | T = TypeVar('T', bound='Repeating') 13 | 14 | 15 | class Repeating: 16 | """ 17 | Arguments: 18 | root (Field): A non-unique root to search for. 19 | repeater (Repeating | Area): The object that repeats on the page. 20 | 21 | Represents abstract non-unique collections that repeat, 22 | based on a common root. 23 | 24 | This can be used to identify anything that not only appears multiple times, 25 | but also contains things which appear multiple times. 26 | 27 | Repeating are inherently confusing and should only be used if something 28 | appears multiple times, contains something else that appears multiple 29 | times, and is truly non-unique with no other way to target it. 30 | 31 | The last object in a chain of Repeating must be a RepeatingArea. 32 | This is because ultimately, there must be an end to the number of things 33 | that repeat. 34 | 35 | Example: 36 | 37 | A page where multiple "project" containers appear, each with a 38 | table of items. 39 | 40 | >>> from stere.areas import Repeating, RepeatingArea 41 | >>> from stere.fields import Root, Link, Text 42 | >>> 43 | >>> projects = Repeating( 44 | >>> root=Root('css', '.projectContainer'), 45 | >>> repeater=RepeatingArea( 46 | >>> root=Root('xpath', '//table/tr'), 47 | >>> description=Link('xpath', './td[1]'), 48 | >>> cost=Text('xpath', './td[2]'), 49 | >>> ) 50 | >>> ) 51 | >>> 52 | >>> assert 2 == len(projects.children) 53 | >>> first_project = projects.children[0] 54 | >>> assert first_project.areas.contains( 55 | >>> 'description', 'Solar Panels') 56 | >>> 57 | >>> second_project = projects.children[1] 58 | >>> assert second_project.areas.contains( 59 | >>> 'description', 'Self-Driving Cars') 60 | 61 | """ 62 | 63 | def __init__(self, root: Field, repeater: Union[Type[T], Type['Area']]): 64 | self.root = root 65 | self.repeater = repeater 66 | self.repeater_name = type(self.repeater).__name__ 67 | 68 | def _set_parent_locator(self, element): 69 | """Set the parent_locator of the root.""" 70 | self.root._element.parent_locator = element 71 | 72 | def new_container(self) -> Any: 73 | """Must return an object to contain results from Repeater.children() 74 | 75 | By default a list is returned. 76 | 77 | Returns: 78 | list 79 | 80 | """ 81 | return [] 82 | 83 | def __len__(self) -> int: 84 | """Return the number of times the root was found. 85 | 86 | Does not actually build the children. 87 | 88 | """ 89 | all_roots = self.root.find_all() 90 | return len(all_roots) 91 | 92 | def _all_roots(self): 93 | """Search for all instances of the root. 94 | 95 | Only intended for use inside Repeating.children(). 96 | Otherwise use self.root.find_all(). 97 | 98 | Raises: 99 | ValueError: If no instances of the root were found. 100 | 101 | Returns: 102 | All instances of the Repeating's root 103 | 104 | """ 105 | all_roots = self.root.find_all() 106 | if 0 == len(all_roots): 107 | raise ValueError( 108 | f'Could not find any {self.repeater_name} using the root: ' 109 | f'{self.root.locator}', 110 | ) 111 | return all_roots 112 | 113 | def children(self) -> Any: 114 | """Find all instances of the root, 115 | then return a collection containing children built from those roots. 116 | 117 | The type of collection is determined by the Repeating.new_container() 118 | method. 119 | 120 | Returns: 121 | list-like collection of every repeater that was found. 122 | 123 | """ 124 | all_roots = self._all_roots() 125 | container = self.new_container() 126 | 127 | for root in all_roots: 128 | copy_repeater = copy.deepcopy(self.repeater) 129 | # Set the repeater's parent locator to the found root instance 130 | copy_repeater._set_parent_locator(root) 131 | 132 | container.append(copy_repeater) 133 | 134 | return container 135 | 136 | def has_children( 137 | self, 138 | minimum: int = 1, 139 | retry_time: Optional[int] = None, 140 | ) -> bool: 141 | """Check if any children can be found. 142 | 143 | Arguments: 144 | minimum: Minimum number of children that must exist. 145 | retry_time: Number of seconds to check for. 146 | 147 | Returns: 148 | bool 149 | 150 | """ 151 | return _retry( 152 | lambda: len(self) >= minimum, 153 | retry_time=retry_time, 154 | ) 155 | 156 | def text_to_dict(self) -> List[Dict[str, Any]]: 157 | """Get the text from every child of the Repeating. 158 | 159 | Areas give back a dict with the text of every child. 160 | Repeatings give back a list of dicts with the text of every child. 161 | 162 | Returns: 163 | list[dict]: The text from every child of the Repeating. 164 | """ 165 | rv = [] 166 | 167 | for c in self.children(): 168 | rv.append(c.text_to_dict()) 169 | 170 | return rv 171 | -------------------------------------------------------------------------------- /stere/areas/repeating_area.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Union 3 | 4 | from .area import Area 5 | from .areas import Areas 6 | from .repeating import Repeating 7 | from ..fields import Field 8 | 9 | 10 | class RepeatingArea(Repeating): 11 | """ 12 | Represents multiple identical Areas on a page. 13 | 14 | A root argument is required, which is expected to be a non-unique Field 15 | on the page. 16 | 17 | A collection of Areas are built from every instance of the root that is 18 | found. Every other Field provided in the arguments is populated inside 19 | each Area. 20 | 21 | In the following example, there's a table with 15 rows. Each row has 22 | two cells. The sixth row in the table should have an item with the 23 | name "Banana" and a price of "$7.00" 24 | 25 | >>> from stere.areas import RepeatingArea 26 | >>> from stere.fields import Root, Link, Text 27 | >>> 28 | >>> class Inventory(Page): 29 | >>> def __init__(self): 30 | >>> self.inventory_items = RepeatingArea( 31 | >>> root=Root('xpath', '//table/tr'), 32 | >>> name=Link('xpath', './td[1]'), 33 | >>> price=Text('xpath', './td[2]'), 34 | >>> ) 35 | 36 | >>> inventory = Inventory() 37 | >>> assert 15 == len(inventory.areas) 38 | >>> assert "Banana" == inventory.areas[5].name 39 | >>> assert "$7.00" == inventory.areas[5].price 40 | """ 41 | 42 | def __init__(self, root: Field, **kwargs: Union[Field, Area]): 43 | self.root = root 44 | self.repeater = Area 45 | self.repeater_name = self.repeater.__name__ 46 | 47 | self._items = {} 48 | for k, v in kwargs.items(): 49 | if not isinstance(v, (Field, Area)): 50 | raise ValueError( 51 | 'RepeatingArea arguments can only be a Field or Area.', 52 | ) 53 | if k != 'root': 54 | self._items[k] = v 55 | # Field (in plural) can be accessed directly. 56 | setattr(self, f'{k}s', v) 57 | 58 | def new_container(self) -> Areas: 59 | """Get a new instance of the container this class uses. 60 | 61 | Returns: 62 | Areas 63 | 64 | """ 65 | return Areas() 66 | 67 | @property 68 | def areas(self) -> Areas: 69 | """Find all instances of the root, 70 | then return a list of Areas: one for each root. 71 | 72 | Returns: 73 | Areas: list-like collection of every Area that was found. 74 | 75 | Example: 76 | 77 | >>> def test_stuff(): 78 | >>> listings = MyPage().my_repeating_area.areas 79 | >>> listings[0].my_input.fill('Hello world') 80 | 81 | """ 82 | return self.children() 83 | 84 | def children(self) -> Areas: 85 | """Find all instances of the root, 86 | then return a list of Areas: one for each root. 87 | 88 | Returns: 89 | Areas: list-like collection of every Area that was found. 90 | 91 | Example: 92 | 93 | >>> def test_stuff(): 94 | >>> listings = MyPage().my_repeating_area.areas 95 | >>> listings[0].my_input.fill('Hello world') 96 | 97 | """ 98 | all_roots = self._all_roots() 99 | container = self.new_container() 100 | 101 | for root in all_roots: 102 | copy_items = copy.deepcopy(self._items) 103 | for field_name in copy_items.keys(): 104 | child = copy_items[field_name] 105 | 106 | child._set_parent_locator(root) 107 | 108 | new_area = self.repeater(**copy_items) 109 | container.append(new_area) 110 | return container 111 | -------------------------------------------------------------------------------- /stere/browser_spy/__init__.py: -------------------------------------------------------------------------------- 1 | from .fetch import FetchSpy 2 | from .xhr import XHRSpy 3 | 4 | 5 | __all__ = [ 6 | "FetchSpy", 7 | "XHRSpy", 8 | ] 9 | -------------------------------------------------------------------------------- /stere/browser_spy/fetch.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import TypeVar 3 | 4 | from .fetch_script import js_script 5 | from ..browserenabled import BrowserEnabled 6 | 7 | 8 | T = TypeVar('T', bound='FetchSpy') 9 | 10 | 11 | class FetchSpy(BrowserEnabled): 12 | """Spy for Fetch instances in the browser. 13 | 14 | Allows scripts to block until network activity has stopped. 15 | 16 | Example: 17 | >>> from stere.browser_spy import FetchSpy 18 | >>> 19 | >>> spy = FetchSpy() 20 | >>> spy.add() 21 | >>> # Browser interaction 22 | >>> spy.wait_for_no_activity() 23 | >>> # More browser interaction 24 | 25 | Can also be used as a context manager, with add() called automatically: 26 | 27 | Example: 28 | >>> from stere.browser_spy import FetchSpy 29 | >>> 30 | >>> with FetchSpy() as spy: 31 | >>> # Browser interaction 32 | >>> spy.wait_for_no_activity() 33 | >>> # More browser interaction 34 | 35 | """ 36 | 37 | def __enter__(self: T) -> T: 38 | """As a context manager, the spy is added on enter.""" 39 | self.add() 40 | return self 41 | 42 | def __exit__(self, exc_type, exc_value, exc_traceback): 43 | """Nothing happens on exit.""" 44 | pass 45 | 46 | def add(self) -> None: 47 | """Inject the spy onto the page. 48 | 49 | Tracks the active and total amount of requests. 50 | """ 51 | self.browser.execute_script(js_script) 52 | 53 | def wait_for_no_activity(self, timeout: int = 30) -> bool: 54 | """Until timeout, keep checking if Fetch is done. 55 | 56 | Requires the spy to already be present via FetchSpy.add() 57 | 58 | Arguments: 59 | timeout (int): Number of seconds to wait 60 | 61 | Raises: 62 | TimeoutError 63 | """ 64 | end = time.time() + timeout 65 | no_active: bool = False 66 | 67 | while not no_active: 68 | no_active = self.active == 0 69 | 70 | # If no Fetch events, wait and retry. 71 | # This confirms no new events were added. 72 | if no_active: 73 | time.sleep(BrowserEnabled.fetch_spy_sleep_time) 74 | no_active = self.active == 0 75 | 76 | if time.time() > end: 77 | raise TimeoutError( 78 | f'Fetch events took longer than {timeout} seconds.', 79 | ) 80 | 81 | return no_active 82 | 83 | @property 84 | def active(self) -> int: 85 | """Get the number of active Fetch events.""" 86 | return self.browser.execute_script("return document.activeFetchEvents") 87 | 88 | @property 89 | def total(self) -> int: 90 | """Get the number of total Fetch events.""" 91 | return self.browser.execute_script("return document.totalFetchEvents") 92 | -------------------------------------------------------------------------------- /stere/browser_spy/fetch_script.py: -------------------------------------------------------------------------------- 1 | # Javascript intended to be opened as a string and injected into the browser. 2 | js_script = """ 3 | // Number of fetch events that are active. 4 | document.activeFetchEvents = 0; 5 | 6 | // Number of fetch events that have been processed. 7 | document.totalFetchEvents = 0; 8 | 9 | // Store original fetch 10 | var origFetch = window.fetch; 11 | 12 | // Patch Fetch to record active requests 13 | window.fetch = async (...args) => { 14 | 15 | // Increment counters before making call. 16 | document.activeFetchEvents += 1; 17 | document.totalFetchEvents += 1; 18 | 19 | // Fetch 20 | const response = await origFetch(...args); 21 | 22 | // Decrement counter on completion 23 | response 24 | .clone() 25 | .blob() 26 | .then(r => {document.activeFetchEvents -= 1}) 27 | .catch(err => console.error(err)) 28 | ; 29 | 30 | // return original response 31 | return response; 32 | 33 | }; 34 | """ 35 | -------------------------------------------------------------------------------- /stere/browser_spy/xhr.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import TypeVar 3 | 4 | from .xhr_script import js_script 5 | from ..browserenabled import BrowserEnabled 6 | 7 | 8 | T = TypeVar('T', bound='XHRSpy') 9 | 10 | 11 | class XHRSpy(BrowserEnabled): 12 | """Spy for XMLHttpRequest instances in the browser. 13 | 14 | Allows scripts to block until network activity has stopped. 15 | 16 | Example: 17 | >>> from stere.browser_spy import XHRSpy 18 | >>> 19 | >>> spy = XHRSpy() 20 | >>> spy.add() 21 | >>> # Browser interaction 22 | >>> spy.wait_for_no_activity() 23 | >>> # More browser interaction 24 | 25 | Can also be used as a context manager, with add() called automatically: 26 | 27 | Example: 28 | >>> from stere.browser_spy import XHRSpy 29 | >>> 30 | >>> with XHRSpy() as spy: 31 | >>> # Browser interaction 32 | >>> spy.wait_for_no_activity() 33 | >>> # More browser interaction 34 | 35 | """ 36 | 37 | def __enter__(self: T) -> T: 38 | """As a context manager, the spy is added on enter.""" 39 | self.add() 40 | return self 41 | 42 | def __exit__(self, exc_type, exc_value, exc_traceback): 43 | """Nothing happens on exit.""" 44 | pass 45 | 46 | def add(self) -> None: 47 | """Inject the spy onto the page. 48 | 49 | Tracks the active and total amount of requests. 50 | """ 51 | self.browser.execute_script(js_script) 52 | 53 | def wait_for_no_activity(self, timeout: int = 30) -> bool: 54 | """Until timeout, keep checking if XHR is done. 55 | 56 | Requires the spy to already be present via FetchSpy.add() 57 | 58 | Arguments: 59 | timeout (int): Number of seconds to wait 60 | 61 | Raises: 62 | TimeoutError 63 | """ 64 | end = time.time() + timeout 65 | no_active: bool = False 66 | 67 | while not no_active: 68 | no_active = self.active == 0 69 | 70 | # If no XHR events, wait and retry. 71 | # This confirms no new requests were made. 72 | if no_active: 73 | time.sleep(BrowserEnabled.xhr_spy_sleep_time) 74 | no_active = self.active == 0 75 | 76 | if time.time() > end: 77 | raise TimeoutError(f'XHR took longer than {timeout} seconds.') 78 | 79 | return no_active 80 | 81 | @property 82 | def active(self) -> int: 83 | """Get the number of active XHR requests.""" 84 | return self.browser.execute_script("return document.activeXHRrequests") 85 | 86 | @property 87 | def total(self) -> int: 88 | """Get the number of total XHR requests.""" 89 | return self.browser.execute_script("return document.totalXHRrequests") 90 | -------------------------------------------------------------------------------- /stere/browser_spy/xhr_script.py: -------------------------------------------------------------------------------- 1 | # Javascript intended to be opened as a string and injected into the browser. 2 | js_script = """ 3 | // Prevent patch from stacking if applied multiple times. 4 | if (!window.oldSend) { 5 | // Number of requests that are active. 6 | document.activeXHRrequests = 0; 7 | 8 | // Number of requests that have been completed. 9 | document.totalXHRrequests = 0; 10 | 11 | window.oldSend = XMLHttpRequest.prototype.send; 12 | 13 | // Patch a request counter onto XMLHttpRequest.send() 14 | XMLHttpRequest.prototype.send = function() { 15 | countRequests(this); 16 | // Call the native send() 17 | window.oldSend.apply(this, arguments); 18 | } 19 | 20 | // Increment a counter every time a request starts, 21 | // and add an event to decrement it when the request is complete. 22 | var countRequests = function(xhr) { 23 | document.activeXHRrequests += 1; 24 | document.totalXHRrequests += 1; 25 | 26 | xhr.addEventListener( 27 | 'loadend', () => { 28 | // Decrement counter 29 | document.activeXHRrequests -= 1; 30 | 31 | // For live debugging 32 | var remaining_msg = `remaining: ${document.activeXHRrequests}`; 33 | var total_msg = `total: ${document.totalXHRrequests}`; 34 | console.log('XHR loadend,', remaining_msg, total_msg); 35 | } 36 | ) 37 | } 38 | } 39 | """ 40 | -------------------------------------------------------------------------------- /stere/browserenabled.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import typing 4 | 5 | 6 | def _parse_config() -> configparser.ConfigParser: 7 | parser = configparser.ConfigParser() 8 | cwd = os.getcwd() 9 | file_path = f'{cwd}/stere.ini' 10 | parser.read(file_path) 11 | return parser 12 | 13 | 14 | def _get_config_option( 15 | parser: configparser.ConfigParser, 16 | section: str = 'stere', 17 | option: str = '', 18 | default: typing.Optional[typing.Any] = '', 19 | ) -> typing.Any: 20 | """Get an option from a config section, if it exists. 21 | 22 | Arguments: 23 | section(str): The name of the section 24 | option(str): The name of the option 25 | 26 | Returns: 27 | str: The found value, or else the default value 28 | 29 | """ 30 | if parser.has_option(section, option): 31 | return parser.get(section, option) 32 | return default 33 | 34 | 35 | class BrowserEnabled: 36 | """Base class that stores attributes at the class level, 37 | shared by every object that inherits from this class. 38 | 39 | If a stere.ini file is found, the attributes will be set from there. 40 | 41 | Attributes: 42 | browser (object): Pointer to what is driving the automation. 43 | base_url (str): Used as the url when navigating to pages. 44 | url_suffix (str): Appended to base_url when navigating to pages. 45 | retry_time (int): The maximum amount of time in seconds to try and 46 | find an element on a page. 47 | library (str): Name of the automation library to use. Default is 48 | splinter. 49 | url_navigator (str): Name of the function that opens a page. 50 | 51 | """ 52 | 53 | browser = None 54 | base_url: str = '' 55 | url_suffix: str = '' 56 | retry_time: int = 5 57 | 58 | xhr_spy_sleep_time = 1 59 | fetch_spy_sleep_time = 1 60 | 61 | # Default values for automation libraries 62 | library_defaults = { 63 | 'splinter': { 64 | 'url_navigator': 'visit', 65 | }, 66 | } 67 | 68 | library: str = 'splinter' 69 | url_navigator: str = library_defaults[library]['url_navigator'] 70 | 71 | # If a config file exists, get settings from there. 72 | parser = _parse_config() 73 | if parser.has_section('stere'): 74 | library = _get_config_option(parser, option='library', default=library) 75 | url_navigator = _get_config_option( 76 | parser, option='url_navigator', default=url_navigator, 77 | ) 78 | base_url = _get_config_option( 79 | parser, option='base_url', default=base_url, 80 | ) 81 | url_suffix = _get_config_option( 82 | parser, option='url_suffix', default=url_suffix, 83 | ) 84 | retry_time = _get_config_option( 85 | parser, option='retry_time', default=retry_time, 86 | ) 87 | -------------------------------------------------------------------------------- /stere/event_emitter.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, List 2 | 3 | 4 | class EventEmitter: 5 | """A basic Event Emitter. 6 | 7 | Attributes: 8 | _events_data: A dict of every registered event and the listeners 9 | attached to them. 10 | """ 11 | 12 | def __init__(self) -> None: 13 | self._events_data: Dict[str, List[Dict]] = {} 14 | 15 | @property 16 | def events(self) -> List[str]: 17 | """Get the names of every registered event.""" 18 | return list(self._events_data.keys()) 19 | 20 | def on(self, event: str, listener: Callable) -> List[Dict]: 21 | """Register a new listener to an event. 22 | 23 | If the event does not exist, it will be created. 24 | 25 | Arguments: 26 | event (str): The name of the event to register a listener to. 27 | listener (Callable): The function to run when the event is emitted. 28 | 29 | Returns: 30 | list: The list of listeners for the event. 31 | 32 | Example: 33 | 34 | >>> def my_event_function(emitter): 35 | >>> pass 36 | >>> 37 | >>> my_field = Field() 38 | >>> my_field.on('before', my_event_function) 39 | 40 | """ 41 | if event not in self._events_data: 42 | self._events_data[event] = [] 43 | 44 | listener_data = {'listener': listener} 45 | self._events_data[event].append(listener_data) 46 | 47 | return self._events_data[event] 48 | 49 | def emit(self, event: str) -> None: 50 | """Emit an event. 51 | 52 | Every listener registered to the event will be called with the 53 | emitter class as the first argument. 54 | 55 | Listeners are called in the order of registration. 56 | 57 | Arguments: 58 | event (str): The name of the event to emit. 59 | 60 | Raises: 61 | ValueError: If the event has not been registered. 62 | """ 63 | event_data = self._events_data.get(event) 64 | if event_data is None: 65 | raise ValueError(f'{event} is not a registered Event.') 66 | 67 | for ev in event_data: 68 | ev['listener'](self) 69 | -------------------------------------------------------------------------------- /stere/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from stere import Stere 2 | 3 | from .field import Field 4 | from .generic.root import Root 5 | from .generic.text import Text 6 | 7 | __all__ = [ 8 | 'Field', 9 | 'Root', 10 | 'Text', 11 | ] 12 | 13 | if Stere.library == 'appium': 14 | from .appium.button import Button 15 | from .appium.input import Input 16 | 17 | __all__ += [ 18 | 'Button', 19 | 'Input', 20 | ] 21 | 22 | elif Stere.library == 'splinter': 23 | from .splinter.button import Button 24 | from .splinter.checkbox import Checkbox 25 | from .splinter.dropdown import Dropdown 26 | from .splinter.input import Input 27 | from .splinter.link import Link 28 | from .splinter.money import Money 29 | from .splinter.shadow_root import ShadowRoot 30 | 31 | __all__ += [ 32 | 'Button', 33 | 'Checkbox', 34 | 'Dropdown', 35 | 'Input', 36 | 'Link', 37 | 'Money', 38 | 'ShadowRoot', 39 | ] 40 | -------------------------------------------------------------------------------- /stere/fields/appium/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/stere/fields/appium/__init__.py -------------------------------------------------------------------------------- /stere/fields/appium/button.py: -------------------------------------------------------------------------------- 1 | from ..decorators import stere_performer, use_after, use_before 2 | from ..field import Field 3 | 4 | 5 | @stere_performer('click', consumes_arg=False) 6 | class Button(Field): 7 | """Convenience Class on top of Field. 8 | 9 | Implements `click()` as its performer. 10 | """ 11 | 12 | @use_after 13 | @use_before 14 | def click(self) -> None: 15 | """Use Appium's click method. 16 | 17 | Example: 18 | 19 | >>> purchase = Button('id', 'buy_button') 20 | >>> purchase.click() 21 | 22 | """ 23 | self.find().click() 24 | -------------------------------------------------------------------------------- /stere/fields/appium/input.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..decorators import stere_performer, use_after, use_before 4 | from ..field import Field 5 | 6 | 7 | @stere_performer('send_keys', consumes_arg=True) 8 | class Input(Field): 9 | """Convenience Class on top of Field. 10 | 11 | Uses Appium's send_keys method. 12 | 13 | Arguments: 14 | default_value (str): When Input.send_keys() is called with no 15 | arguments, this value will be used instead. 16 | """ 17 | 18 | def __init__(self, *args, default_value: Optional[str] = None, **kwargs): 19 | super().__init__(*args, **kwargs) 20 | 21 | self.default_value = default_value 22 | 23 | @use_after 24 | @use_before 25 | def send_keys(self, value: Optional[str] = None) -> None: 26 | """Use Appium's fill method. 27 | 28 | Arguments: 29 | value (str): The text to enter into the input. 30 | 31 | Example: 32 | 33 | >>> first_name = Input('id', 'fillme') 34 | >>> first_name.send_keys('Joseph') 35 | 36 | """ 37 | if value is None and self.default_value: 38 | value = self.default_value 39 | self.find().send_keys(value) 40 | -------------------------------------------------------------------------------- /stere/fields/build_element.py: -------------------------------------------------------------------------------- 1 | from stere.strategy import strategies 2 | 3 | 4 | def build_element(desired_strategy: str, locator: str, parent_locator=None): 5 | """Get an element strategy instance.""" 6 | known = strategies.keys() 7 | 8 | if desired_strategy in known: 9 | element_class = strategies[desired_strategy] 10 | return element_class(desired_strategy, locator, parent_locator) 11 | 12 | raise ValueError( 13 | f'The strategy "{desired_strategy}" is not in {list(known)}.') 14 | -------------------------------------------------------------------------------- /stere/fields/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Callable, Optional, Type, TypeVar, cast 3 | 4 | 5 | T = TypeVar('T') 6 | F = TypeVar('F', bound=Callable[..., Any]) 7 | 8 | 9 | def stere_performer( 10 | method_name: str, 11 | consumes_arg: bool = False, 12 | ) -> Callable[[Type[T]], Type[T]]: 13 | """Wrap a class to associate method_name with the perform() method. 14 | 15 | Associating a method with perform allows the class to be fully used 16 | by Area objects via Area.perform(). 17 | 18 | Arguments: 19 | method_name (str): The name of the method to perform 20 | consumes_args (bool): True if the method takes an argument, else False 21 | 22 | In the following example, when ``Philosophers().diogenes_area.perform()`` 23 | is called, ``DiogenesButton.philosophize()`` is called. 24 | 25 | Example: 26 | 27 | >>> @stere_performer('philosophize', consumes_arg=False) 28 | >>> class DiogenesButton(Field): 29 | >>> def philosophize(self): 30 | >>> print("As a matter of self-preservation, ") 31 | >>> print("a man needs good friends or ardent enemies, ") 32 | >>> print("for the former instruct him and the latter ") 33 | >>> print("take him to task.") 34 | >>> 35 | >>> 36 | >>> class Philosophers(Page): 37 | >>> def __init__(self): 38 | >>> self.diogenes_area = Area( 39 | >>> quote_button=DiogenesButton('id', 'idDio'), 40 | >>> next_button=Button('id', 'idNext'), 41 | >>> ) 42 | >>> 43 | >>> 44 | >>> Philosophers().diogenes_area.perform() 45 | """ 46 | def wrapper(cls: Type[T]) -> Type[T]: 47 | class Performer(cls): # type: ignore 48 | def perform(self, value: Optional[Any] = None) -> Any: 49 | """Run the method designated as the performer""" 50 | performer = getattr(self, method_name) 51 | if consumes_arg: 52 | performer(value) 53 | else: 54 | performer() 55 | return self.returns 56 | 57 | # Preserve original class name and doc 58 | Performer.__name__ = cls.__name__ 59 | Performer.__doc__ = cls.__doc__ 60 | Performer.consumes_arg = consumes_arg 61 | return Performer 62 | return wrapper 63 | 64 | 65 | def use_before(func: F) -> F: 66 | """When used on a method in a Field, the following will occur before 67 | the decorated method is called: 68 | - The Field's before() method will be called. 69 | - Any listeners registered to the 'before' event will be called. 70 | 71 | Example: 72 | 73 | >>> class TransformingButton(Field): 74 | >>> def before(self): 75 | >>> print('Autobots! Transform and...') 76 | >>> 77 | >>> @use_before 78 | >>> def roll_out(self): 79 | >>> print('roll out!') 80 | >>> 81 | >>> tf = TransformingButton() 82 | >>> tf.roll_out() 83 | >>> 84 | >>> "Autobots! Transform and..." 85 | >>> "roll out!" 86 | """ 87 | @wraps(func) 88 | def wrapper(cls: Type[T], *inner_args: Any, **inner_kwargs: Any) -> Any: 89 | cls.before() 90 | cls.emit('before') 91 | return func(cls, *inner_args, **inner_kwargs) 92 | return cast(F, wrapper) 93 | 94 | 95 | def use_after(func: F) -> F: 96 | """When used on a method in a Field, the following will occur after 97 | the decorated method is called: 98 | - The Field's after() method will be called. 99 | - Any listeners registered to the 'after' event will be called. 100 | 101 | Example: 102 | 103 | >>> class TransformingButton(Field): 104 | >>> def after(self): 105 | >>> print('rise up!') 106 | >>> 107 | >>> @use_after 108 | >>> def transform_and(self): 109 | >>> print('Decepticons, transform and...') 110 | >>> 111 | >>> tf = TransformingButton() 112 | >>> tf.transform_and() 113 | >>> 114 | >>> "Decepticons, transform and..." 115 | >>> "rise up!" 116 | """ 117 | @wraps(func) 118 | def wrapper(cls: Type[T], *inner_args: Any, **inner_kwargs: Any) -> Any: 119 | result = func(cls, *inner_args, **inner_kwargs) 120 | cls.after() 121 | cls.emit('after') 122 | return result 123 | return cast(F, wrapper) 124 | -------------------------------------------------------------------------------- /stere/fields/field.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import splinter 4 | 5 | from stere import Stere 6 | 7 | from .build_element import build_element 8 | from .decorators import stere_performer 9 | from ..event_emitter import EventEmitter 10 | from ..utils import _retry 11 | from ..value_comparator import ValueComparator 12 | 13 | 14 | @stere_performer('null_action', consumes_arg=False) 15 | class Field(EventEmitter): 16 | """The representation of individual page components. 17 | 18 | Conceptually, they're modelled after general behaviours, not specific 19 | HTML tags. 20 | 21 | Arguments: 22 | strategy (str): The type of strategy to use when locating an element. 23 | locator (str): The locator for the strategy. 24 | workflows (list): Any workflows the Field should be included with. 25 | 26 | Example: 27 | 28 | >>> from stere.fields import Field 29 | >>> my_field = Field('xpath', '//*[@id="js-link-box-pt"]/small/span') 30 | 31 | Attributes: 32 | element: The result of a search operation on the page. 33 | All attribute calls on the Field that fail are then tried on the 34 | element. 35 | 36 | This allows classes inheriting from Field to act as a proxy to the 37 | underlying automation library. 38 | 39 | Using Splinter's `visible` attribute as an example, the following 40 | methods are analogous: 41 | 42 | >>> Field.visible == Field.find().visible == Field.element.visible 43 | 44 | """ 45 | 46 | def __init__(self, strategy: str, locator: str, *args, **kwargs): 47 | super().__init__() 48 | 49 | # Ensure before and after events are valid. 50 | self._events_data['before'] = [] 51 | self._events_data['after'] = [] 52 | 53 | self.strategy = strategy 54 | self.locator = locator 55 | self._element = build_element(strategy, locator) 56 | 57 | self.workflows = kwargs.get('workflows') or [] 58 | self.returns = kwargs.get('returns') or None 59 | 60 | def __call__(self, *args, **kwargs): 61 | """When a Field instance is called, run the perform() method.""" 62 | return self.perform(*args, **kwargs) 63 | 64 | def __getattr__(self, val: str): 65 | """If an attribute doesn't exist, try getting it from the element. 66 | 67 | If it still doesn't exist, do a find() on the element and see if the 68 | attribute exists there. 69 | """ 70 | element = super().__getattribute__('_element') 71 | try: 72 | return getattr(element, val) 73 | except AttributeError: 74 | # Allows deepcopy not to get into infinite recursion. 75 | if val in ['__deepcopy__', '__getstate__']: 76 | raise AttributeError 77 | 78 | # Try getting the attribute from the found element. 79 | try: 80 | elem = self.find(Stere.retry_time) 81 | except splinter.exceptions.ElementDoesNotExist as e: 82 | msg = ( 83 | 'Failed to get element attribute.' 84 | f'Could not find element with {self.strategy}: {self.locator}' # NOQA E501 85 | ) 86 | raise AttributeError(msg) from e 87 | 88 | return getattr(elem, val) 89 | 90 | def __repr__(self) -> str: 91 | """Provide a string representation of this class.""" 92 | return ( 93 | f'{self.__class__.__name__} - ' 94 | f'Strategy: {self.strategy}, Locator: {self.locator}') 95 | 96 | def _set_parent_locator(self, element) -> None: 97 | """Set the parent locator of this Field's element.""" 98 | self._element.parent_locator = element 99 | 100 | @property 101 | def element(self): 102 | """Tries to find the element, then returns the results.""" 103 | return self._element.find() 104 | 105 | def null_action(self) -> None: 106 | """Empty method used as the performer for Field. 107 | 108 | Allows the base Field object to be used in an Area. 109 | """ 110 | pass 111 | 112 | def before(self) -> None: 113 | """Called automatically before methods with the `@use_before` 114 | decorator are called. 115 | 116 | Performer methods are decorated with @use_before. 117 | 118 | By default it does nothing. Override this method if an action must be 119 | taken before a method is called. 120 | 121 | In the following example, Dropdown has been subclassed to hover over 122 | the element before clicking. 123 | 124 | Example: 125 | 126 | >>> from stere.fields import Dropdown 127 | >>> 128 | >>> class CSSDropdown(Dropdown): 129 | >>> def before(self): 130 | >>> self.element.mouse_over() 131 | """ 132 | pass 133 | 134 | def after(self) -> None: 135 | """Called automatically before methods with the `@use_after` 136 | decorator are called. 137 | 138 | Performer methods are decorated with @use_after. 139 | 140 | By default it does nothing. Override this method if an action must be 141 | taken after the method has been called. 142 | """ 143 | pass 144 | 145 | def includes(self, value: str): 146 | """Will search every element found by the Field for a value property 147 | that matches the given value. 148 | If an element with a matching value is found, it's then returned. 149 | 150 | Useful for when you have non-unique elements and know a value is in 151 | one of the elements, but don't know which one. 152 | 153 | Arguments: 154 | value (str): A text string inside an element you want to find. 155 | 156 | Returns: 157 | element 158 | 159 | Example: 160 | 161 | >>> class PetStore(Page): 162 | >>> def __init__(self): 163 | >>> self.inventory = Link('xpath', '//li[@class="inv"]') 164 | >>> 165 | >>> pet_store = PetStore() 166 | >>> pet_store.inventory_list.includes("Kittens").click() 167 | 168 | """ 169 | for item in self.element: 170 | if item.value == value: 171 | return item 172 | 173 | def value_contains( 174 | self, expected: str, wait_time: Optional[int] = None, 175 | ) -> ValueComparator: 176 | """Check if the value of the Field contains an expected value. 177 | 178 | Arguments: 179 | expected (str): The expected value of the Field 180 | wait_time (int): The number of seconds to search. 181 | Default is Stere.retry_time. 182 | 183 | Returns: 184 | ValueComparator: Object with the boolean result of the comparison. 185 | 186 | Example: 187 | 188 | >>> class PetStore(Page): 189 | >>> def __init__(self): 190 | >>> self.price = Link('xpath', '//li[@class="price"]') 191 | >>> 192 | >>> pet_store = PetStore() 193 | >>> assert pet_store.price.value_contains("19.19", wait_time=6) 194 | 195 | """ 196 | result = _retry( 197 | lambda: expected in self.value, 198 | retry_time=wait_time, 199 | ) 200 | 201 | value = ValueComparator(result, expected=expected, actual=self.value) 202 | return value 203 | 204 | def value_equals( 205 | self, expected: str, wait_time: Optional[int] = None, 206 | ) -> ValueComparator: 207 | """Check if the value of the Field equals an expected value. 208 | 209 | Arguments: 210 | expected (str): The expected value of the Field 211 | wait_time (int): The number of seconds to search. 212 | Default is Stere.retry_time. 213 | 214 | Returns: 215 | ValueComparator: Object with the boolean result of the comparison. 216 | 217 | Example: 218 | 219 | >>> class PetStore(Page): 220 | >>> def __init__(self): 221 | >>> self.price = Link('xpath', '//li[@class="price"]') 222 | >>> 223 | >>> pet_store = PetStore() 224 | >>> assert pet_store.price.value_equals("$19.19", wait_time=6) 225 | 226 | """ 227 | result = _retry( 228 | lambda: expected == self.value, 229 | retry_time=wait_time, 230 | ) 231 | 232 | value = ValueComparator(result, expected=expected, actual=self.value) 233 | return value 234 | 235 | def find(self, wait_time: Optional[int] = None): 236 | """Find the first matching element. 237 | 238 | Returns: 239 | Element 240 | 241 | Raises: 242 | ValueError - If more than one element is found. 243 | 244 | """ 245 | found_elements = self.find_all(wait_time) 246 | if len(found_elements) >= 2: 247 | raise ValueError("Expected one element, found multiple") 248 | return found_elements[0] 249 | 250 | def find_all(self, wait_time: Optional[int] = None): 251 | """Find all matching elements. 252 | 253 | Returns: 254 | list 255 | 256 | """ 257 | return self._element.find(wait_time) 258 | -------------------------------------------------------------------------------- /stere/fields/generic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/stere/fields/generic/__init__.py -------------------------------------------------------------------------------- /stere/fields/generic/root.py: -------------------------------------------------------------------------------- 1 | from ..decorators import stere_performer 2 | from ..field import Field 3 | 4 | 5 | @stere_performer('null_action', consumes_arg=False) 6 | class Root(Field): 7 | """A simple wrapper over Field, it does not implement a performer method. 8 | Although Root has no specific behaviour, it can be useful when declaring a 9 | root for an Area or RepeatingArea. 10 | 11 | Example: 12 | 13 | >>> from stere.areas import RepeatingArea 14 | >>> from stere.fields import Root 15 | >>> 16 | >>> 17 | >>> collections = RepeatingArea( 18 | >>> root=Root('xpath', '//table/tr'), 19 | >>> quantity=Text('css', '.collection_qty'), 20 | >>> ) 21 | """ 22 | 23 | pass 24 | -------------------------------------------------------------------------------- /stere/fields/generic/text.py: -------------------------------------------------------------------------------- 1 | from ..decorators import stere_performer 2 | from ..field import Field 3 | 4 | 5 | @stere_performer('null_action', consumes_arg=False) 6 | class Text(Field): 7 | """A simple wrapper over Field, it does not implement a performer method. 8 | Although Text has no specific behaviour, it can be useful when declaring 9 | that a Field should just be static Text. 10 | 11 | Example: 12 | 13 | >>> from stere.fields import Text 14 | >>> 15 | >>> 16 | >>> self.price = Text('id', 'item_price') 17 | """ 18 | 19 | pass 20 | -------------------------------------------------------------------------------- /stere/fields/splinter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/stere/fields/splinter/__init__.py -------------------------------------------------------------------------------- /stere/fields/splinter/button.py: -------------------------------------------------------------------------------- 1 | from .clickable import Clickable 2 | from ..decorators import stere_performer 3 | 4 | 5 | @stere_performer('click', consumes_arg=False) 6 | class Button(Clickable): 7 | """Convenience Class on top of Field. 8 | 9 | Implements `click()` as its performer. 10 | 11 | Example: 12 | 13 | >>> purchase = Button('id', 'buy_button') 14 | >>> purchase.click() 15 | 16 | """ 17 | -------------------------------------------------------------------------------- /stere/fields/splinter/checkbox.py: -------------------------------------------------------------------------------- 1 | from ..decorators import stere_performer, use_after, use_before 2 | from ..field import Field 3 | 4 | 5 | @stere_performer('opposite', consumes_arg=False) 6 | class Checkbox(Field): 7 | """Class with specific methods for handling checkboxes.""" 8 | 9 | def __init__(self, *args, default_checked: bool = False, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | 12 | self.default_checked = default_checked 13 | 14 | def set_to(self, state: bool) -> None: 15 | """Set a checkbox to the desired state. 16 | 17 | Arguments: 18 | state (bool): True for check, False for uncheck 19 | 20 | Example: 21 | 22 | >>> confirm = Checkbox('id', 'selectme') 23 | >>> confirm.set_to(True) 24 | 25 | """ 26 | if state: 27 | self.check() 28 | else: 29 | self.uncheck() 30 | 31 | def toggle(self) -> None: 32 | """If the checkbox is checked, uncheck it. 33 | If the checkbox is unchecked, check it. 34 | 35 | >>> confirm = Checkbox('id', 'selectme') 36 | >>> confirm.toggle() 37 | 38 | """ 39 | if self.checked: 40 | self.uncheck() 41 | else: 42 | self.check() 43 | 44 | @use_after 45 | @use_before 46 | def check(self) -> None: 47 | """Use Splinter's check method.""" 48 | self.find().check() 49 | 50 | @use_after 51 | @use_before 52 | def uncheck(self) -> None: 53 | """Use Splinter's uncheck method.""" 54 | self.find().uncheck() 55 | 56 | def opposite(self) -> bool: 57 | """Switches the checkbox to the opposite of its default state. 58 | Uses the `default_checked` attribute to decide this. 59 | 60 | >>> confirm = Checkbox('id', 'selectme') 61 | >>> confirm.opposite() 62 | 63 | """ 64 | if not self.default_checked: 65 | self.check() 66 | else: 67 | self.uncheck() 68 | return False 69 | -------------------------------------------------------------------------------- /stere/fields/splinter/clickable.py: -------------------------------------------------------------------------------- 1 | from ..decorators import stere_performer, use_after, use_before 2 | from ..field import Field 3 | 4 | 5 | @stere_performer('click', consumes_arg=False) 6 | class Clickable(Field): 7 | """Convenience Class on top of Field. 8 | 9 | Implements `click()` as its performer. 10 | """ 11 | 12 | @use_after 13 | @use_before 14 | def click(self) -> None: 15 | """Use Splinter's click method. 16 | 17 | Example: 18 | 19 | >>> purchase = Clickable('id', 'buy_button') 20 | >>> purchase.click() 21 | 22 | """ 23 | self.is_visible() 24 | self.is_clickable() 25 | self.find().click() 26 | -------------------------------------------------------------------------------- /stere/fields/splinter/dropdown.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List, Optional 3 | 4 | from stere import Stere 5 | 6 | from .button import Button 7 | from ..decorators import stere_performer, use_after, use_before 8 | from ..field import Field 9 | 10 | 11 | @stere_performer('select', consumes_arg=True) 12 | class Dropdown(Field): 13 | """Represents a dropdown menu. 14 | If the "option" argument is provided with a field, 15 | use that as the dropdown item. 16 | Else, assume a standard HTML Dropdown and use the option tag. 17 | """ 18 | 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | # If no option arg is given, assume Dropdown is a standard HTML one. 23 | if kwargs.get('option') is None: 24 | self.option = Button('tag', 'option') 25 | else: 26 | self.option = kwargs.get('option') 27 | 28 | def __getitem__(self, index: int) -> None: 29 | """Accessing by index will select the option matching the index.""" 30 | return self.options[index].click() 31 | 32 | @property 33 | def options(self) -> List: 34 | """Search for all the elements that are an option in the dropdown. 35 | 36 | Returns: 37 | list 38 | 39 | """ 40 | self.option._element.parent_locator = self.find() 41 | return list(self.option.find_all()) 42 | 43 | @use_after 44 | @use_before 45 | def select(self, value: str, retry_time: Optional[int] = None) -> None: 46 | """Search for an option by its html content, then clicks the one 47 | that matches. 48 | 49 | Arguments: 50 | value (str): The option value to select. 51 | retry_time (int): The amount of time to try to find the value. 52 | Default is Stere.retry_time. 53 | 54 | Raises: 55 | ValueError: The provided value could not be found in the dropdown. 56 | 57 | """ 58 | retry_time = retry_time or Stere.retry_time 59 | end_time = time.time() + retry_time 60 | 61 | found_options = [] 62 | 63 | while time.time() < end_time: 64 | found_options = self._select(value) 65 | if not len(found_options): 66 | return 67 | 68 | raise ValueError( 69 | f'{value} was not found. Found values are: {found_options}') 70 | 71 | def _select(self, value: str) -> List[str]: 72 | found_options = [] 73 | 74 | for option in self.options: 75 | found_options.append(option.html) 76 | if option.html == value: 77 | option.click() 78 | return [] 79 | 80 | return found_options 81 | -------------------------------------------------------------------------------- /stere/fields/splinter/input.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from typing import Optional 3 | 4 | from selenium.webdriver.common.keys import Keys 5 | 6 | from ..decorators import stere_performer, use_after, use_before 7 | from ..field import Field 8 | 9 | 10 | @stere_performer('fill', consumes_arg=True) 11 | class Input(Field): 12 | """Convenience Class on top of Field. 13 | 14 | Uses Splinter's input method. 15 | 16 | Arguments: 17 | default_value (str): When Input.fill() is called with no arguments, 18 | this value will be used instead. 19 | """ 20 | 21 | def __init__(self, *args, default_value: Optional[str] = None, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | 24 | self.default_value = default_value 25 | 26 | @use_after 27 | @use_before 28 | def fill(self, value: Optional[str] = None) -> None: 29 | """Use Splinter's fill method. 30 | 31 | Arguments: 32 | value (str): The text to enter into the input. 33 | 34 | Example: 35 | 36 | >>> first_name = Input('id', 'fillme') 37 | >>> first_name.fill('Joseph') 38 | 39 | """ 40 | if value is None and self.default_value: 41 | value = self.default_value 42 | self.find().fill(value) 43 | 44 | def highlight(self) -> None: 45 | """Highlight the text content in an input element.""" 46 | system = platform.system() 47 | 48 | if system == 'Darwin': # OSX 49 | value = Keys.COMMAND + 'a' 50 | else: 51 | value = Keys.CONTROL + 'a' 52 | 53 | self.type(value) 54 | -------------------------------------------------------------------------------- /stere/fields/splinter/link.py: -------------------------------------------------------------------------------- 1 | from .clickable import Clickable 2 | from ..decorators import stere_performer 3 | 4 | 5 | @stere_performer('click', consumes_arg=False) 6 | class Link(Clickable): 7 | """Convenience Class on top of Field. 8 | 9 | Implements `click()` as its performer. 10 | 11 | Example: 12 | 13 | >>> purchase = Link('id', 'buy_link') 14 | >>> purchase.click() 15 | 16 | """ 17 | -------------------------------------------------------------------------------- /stere/fields/splinter/money.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from moneyed import Money as PyMoney 4 | 5 | from ..decorators import stere_performer 6 | from ..field import Field 7 | 8 | 9 | @stere_performer('null_action', consumes_arg=False) 10 | class Money(Field): 11 | """A simple wrapper over Field, it does not implement a performer method. 12 | 13 | Money has methods for handling Fields where the text is a form of currency. 14 | 15 | Example: 16 | 17 | >>> from stere.fields import Money 18 | >>> 19 | >>> 20 | >>> self.price = Money('id', 'item_price') 21 | """ 22 | 23 | number_regex = r'[^0-9\.]+' 24 | 25 | def money(self, currency: str = 'USD') -> PyMoney: 26 | """Create a Money object from the Field's text. 27 | 28 | The returned object is an instance of moneyed.Money. 29 | See: `py-moneyed `_ 30 | 31 | Arguments: 32 | currency (str): Name of the currency to use 33 | 34 | Returns: 35 | moneyed.Money 36 | 37 | """ 38 | return PyMoney(amount=self.number, currency=currency) 39 | 40 | @property 41 | def number(self) -> str: 42 | """The Field's text, normalized to look like a number.""" 43 | m = re.compile(self.number_regex, re.IGNORECASE) 44 | return m.sub('', self.text) 45 | -------------------------------------------------------------------------------- /stere/fields/splinter/shadow_root.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from splinter.element_list import ElementList 4 | 5 | from ..decorators import stere_performer 6 | from ..field import Field 7 | 8 | 9 | @stere_performer('null_action', consumes_arg=False) 10 | class ShadowRoot(Field): 11 | """Field that uses the shadow-root of an element. 12 | 13 | Only useful as the root of an Area. 14 | 15 | Example: 16 | >>> address_form = Area( 17 | >>> root=ShadowRoot('css', '#addressFormBlock'), 18 | >>> address=Input('css', '#userAddress'), 19 | >>> ) 20 | 21 | """ 22 | 23 | def find_all(self, wait_time: Optional[int] = None) -> ElementList: 24 | """Get the shadowRoot element for any found elements. 25 | 26 | Returns: 27 | ElementList 28 | """ 29 | found_elements = self._element.find(wait_time) 30 | 31 | shadow_roots = [] 32 | for elem in found_elements: 33 | shadow_roots.append(elem.shadow_root) 34 | 35 | return ElementList( 36 | shadow_roots, find_by=self.strategy, query=self.locator, 37 | ) 38 | -------------------------------------------------------------------------------- /stere/page.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | from typing import TypeVar 3 | 4 | from .browser_spy import FetchSpy, XHRSpy 5 | from .browserenabled import BrowserEnabled 6 | 7 | 8 | T = TypeVar('T', bound='Page') 9 | 10 | 11 | class Page(BrowserEnabled): 12 | """Represents a single page in an application. 13 | The Page class is the base which all Page Objects should inherit from. 14 | 15 | Inheriting from Page is not required for Fields or Areas to work. 16 | 17 | All attribute calls that fail are then tried on the browser attribute. 18 | This allows classes inheriting from Page to act as a proxy to 19 | whichever browser/driver is being used. 20 | 21 | Using Splinter's browser.url method as an example, the following methods 22 | are analogous: 23 | 24 | >>> MyPage.url == MyPage.browser.url == browser.url 25 | 26 | The choice of which syntax to use depends on how you want to write your 27 | test suite. 28 | """ 29 | 30 | # Allows network requests to be spied on 31 | fetch_spy = FetchSpy() 32 | xhr_spy = XHRSpy() 33 | 34 | def __getattr__(self, val): 35 | """If an attribute doesn't exist, try getting it from the browser.""" 36 | return getattr(self.browser, val) 37 | 38 | def __enter__(self: T) -> T: 39 | """Page Objects can be used as context managers.""" 40 | return self 41 | 42 | def __exit__(self, *args): 43 | """Page Objects can be used as context managers.""" 44 | pass 45 | 46 | @property 47 | def page_url(self) -> str: 48 | """Get a full URL from Stere's base_url and a Page's url_suffix. 49 | 50 | Uses urllib.parse.urljoin to combine the two. 51 | """ 52 | return urllib.parse.urljoin(self.base_url, self.url_suffix) 53 | 54 | def navigate(self: T) -> T: 55 | """When the base Stere object has been given the `url_navigator` 56 | attribute, the base_url attribute, and a Page Object 57 | has a `url_suffix` attribute, the `navigate()` method can be called. 58 | 59 | This method will call the method defined in `url_navigator`, 60 | with `page_url` as the first parameter. 61 | 62 | Returns: 63 | Page: The instance where navigate() was called from. 64 | 65 | Example: 66 | 67 | >>> from splinter import Browser 68 | >>> from stere import Page 69 | >>> 70 | >>> 71 | >>> class GuiltyGear(Page): 72 | >>> def __init__(self): 73 | >>> self.url_suffix = 'Guilty_Gear' 74 | >>> 75 | >>> 76 | >>> Stere.browser = Browser() 77 | >>> Stere.url_navigator = 'visit' 78 | >>> Stere.base_url = 'https://en.wikipedia.org/' 79 | >>> 80 | >>> guilty_gear_page = GuiltyGear() 81 | >>> guilty_gear_page.navigate() 82 | 83 | """ 84 | getattr(self.browser, self.url_navigator)(self.page_url) 85 | return self 86 | -------------------------------------------------------------------------------- /stere/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/stere/py.typed -------------------------------------------------------------------------------- /stere/strategy/__init__.py: -------------------------------------------------------------------------------- 1 | from stere import Stere 2 | 3 | from .strategy import strategies 4 | from .strategy import strategy 5 | 6 | 7 | __all__ = [ 8 | 'strategy', 9 | 'strategies', 10 | ] 11 | 12 | if Stere.library == 'appium': 13 | from .appium import FindByAccessibilityId 14 | from .appium import FindByAndroidUIAutomator 15 | from .appium import FindByIOSClassChain 16 | from .appium import FindByIOSUIPredicate 17 | from .appium import FindByIOSUIAutomation 18 | 19 | __all__ += [ 20 | 'FindByAccessibilityId', 21 | 'FindByAndroidUIAutomator', 22 | 'FindByIOSClassChain', 23 | 'FindByIOSUIPredicate', 24 | 'FindByIOSUIAutomation', 25 | ] 26 | 27 | elif Stere.library == 'splinter': 28 | from .splinter import FindByCss 29 | from .splinter import FindById 30 | from .splinter import FindByName 31 | from .splinter import FindByTag 32 | from .splinter import FindByText 33 | from .splinter import FindByValue 34 | from .splinter import FindByXPath 35 | from .splinter import add_data_star_strategy 36 | 37 | __all__ += [ 38 | 'FindByCss', 39 | 'FindByXPath', 40 | 'FindByTag', 41 | 'FindByName', 42 | 'FindByText', 43 | 'FindById', 44 | 'FindByValue', 45 | 'add_data_star_strategy', 46 | ] 47 | -------------------------------------------------------------------------------- /stere/strategy/appium.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .element_strategy import ElementStrategy 4 | from .strategy import strategy 5 | 6 | 7 | class AppiumBase(ElementStrategy): 8 | def _find_all(self, wait_time: typing.Optional[int] = None): 9 | """Find from inside a parent element.""" 10 | parent = self.parent_locator or self.browser 11 | func = getattr( 12 | parent, f'find_elements_by_{self.strategy}') 13 | return func(self.locator) 14 | 15 | 16 | @strategy('accessibility_id') 17 | class FindByAccessibilityId(AppiumBase): 18 | strategy = 'accessibility_id' 19 | 20 | 21 | @strategy('android_uiautomator') 22 | class FindByAndroidUIAutomator(AppiumBase): 23 | strategy = 'android_uiautomator' 24 | 25 | 26 | @strategy('ios_class_chain') 27 | class FindByIOSClassChain(AppiumBase): 28 | strategy = 'ios_class_chain' 29 | 30 | 31 | @strategy('ios_predicate') 32 | class FindByIOSUIPredicate(AppiumBase): 33 | strategy = 'ios_predicate' 34 | 35 | 36 | @strategy('ios_uiautomation') 37 | class FindByIOSUIAutomation(AppiumBase): 38 | strategy = 'ios_uiautomation' 39 | -------------------------------------------------------------------------------- /stere/strategy/element_strategy.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from stere import Stere 4 | 5 | 6 | class ElementStrategy(Stere): 7 | """Base class for a Strategy. 8 | 9 | Elements are used by Fields as the source of what finds the DOM content. 10 | 11 | Arguments: 12 | strategy (str): The type of strategy to use when locating an element. 13 | locator (str): The locator for the strategy. 14 | parent_locator: A parent object to search from. If None, 15 | search will occur from top of the DOM. 16 | """ 17 | 18 | def __init__( 19 | self, strategy: str, 20 | locator: str, 21 | parent_locator: Optional[Any] = None, 22 | ): 23 | self.strategy = strategy 24 | self.locator = locator 25 | self.parent_locator = parent_locator 26 | 27 | # A Field that should be searched for and whose element should be set 28 | # as the parent, but only when .find() is called. 29 | self.root: Optional[Any] = None 30 | 31 | def _find_all(self, wait_time: Optional[int] = None): 32 | """Find from inside a parent element.""" 33 | raise NotImplementedError 34 | 35 | def find(self, wait_time: Optional[int] = None): 36 | """Use ElementStrategy._find_all() to find an element. 37 | 38 | If a root has been set, it will set the parent_locator attribute 39 | before searching. 40 | """ 41 | if self.root: 42 | self.parent_locator = self.root.find(wait_time=wait_time) 43 | return self._find_all(wait_time=wait_time) 44 | -------------------------------------------------------------------------------- /stere/strategy/splinter.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import typing 3 | 4 | from selenium.common.exceptions import StaleElementReferenceException 5 | 6 | from .element_strategy import ElementStrategy 7 | from .strategy import strategy 8 | from ..utils import _retry 9 | 10 | 11 | class SplinterBase(ElementStrategy): 12 | def is_clickable(self, wait_time: typing.Optional[int] = None) -> bool: 13 | """Check if an element is present in the DOM and clickable. 14 | 15 | Arguments: 16 | wait_time (int): The number of seconds to wait. If not specified, 17 | Stere.retry_time will be used. 18 | """ 19 | return _retry( 20 | lambda: self.find() and self.find()._element.is_enabled(), 21 | wait_time, 22 | ) 23 | 24 | def is_not_clickable(self, wait_time: typing.Optional[int] = None) -> bool: 25 | """Check if an element is not clickable in the DOM. 26 | 27 | Arguments: 28 | wait_time (int): The number of seconds to wait. If not specified, 29 | Stere.retry_time will be used. 30 | """ 31 | def search() -> bool: 32 | result = self.find(wait_time=0) 33 | if not result: 34 | return True 35 | if result and not result._element.is_enabled(): 36 | return True 37 | return False 38 | 39 | return _retry(search, wait_time) 40 | 41 | def is_present(self, wait_time: typing.Optional[int] = None) -> bool: 42 | """Check if an element is present in the DOM. 43 | 44 | Arguments: 45 | wait_time (int): The number of seconds to wait. If not specified, 46 | Stere.retry_time will be used. 47 | """ 48 | return _retry( 49 | lambda: self.find(), 50 | wait_time, 51 | ) 52 | 53 | def is_not_present(self, wait_time: typing.Optional[int] = None) -> bool: 54 | """Check if an element is not present in the DOM. 55 | 56 | Arguments: 57 | wait_time (int): The number of seconds to wait. If not specified, 58 | Stere.retry_time will be used. 59 | """ 60 | return _retry( 61 | lambda: not self.find(wait_time=0), 62 | wait_time, 63 | ) 64 | 65 | def is_visible(self, wait_time: typing.Optional[int] = None) -> bool: 66 | """Check if an element is present in the DOM and visible. 67 | 68 | Arguments: 69 | wait_time (int): The number of seconds to wait. If not specified, 70 | Stere.retry_time will be used. 71 | """ 72 | def search() -> bool: 73 | elem = self.find() 74 | if elem: 75 | try: 76 | result = elem.visible 77 | # StaleElementReferenceException occurs if element is found 78 | # but changes before visible is checked 79 | except StaleElementReferenceException: 80 | return False 81 | 82 | if result: 83 | return True 84 | 85 | return False 86 | 87 | return _retry(search, wait_time) 88 | 89 | def is_not_visible(self, wait_time: typing.Optional[int] = None) -> bool: 90 | """Check if an element is not visible in the DOM. 91 | 92 | Arguments: 93 | wait_time (int): The number of seconds to wait. If not specified, 94 | Stere.retry_time will be used. 95 | """ 96 | def search(): 97 | elem = self.find(wait_time=0) 98 | if elem: 99 | try: 100 | result = elem.visible 101 | # StaleElementReferenceException occurs if element is found 102 | # but changes before visible is checked 103 | except StaleElementReferenceException: 104 | return False 105 | 106 | if not result: 107 | return True 108 | else: 109 | return True 110 | 111 | return False 112 | 113 | return _retry(search, wait_time) 114 | 115 | def _find_all(self, wait_time: typing.Optional[int] = None): 116 | """Find from inside a parent element.""" 117 | parent = self.parent_locator or self.browser 118 | func = getattr(parent, f'find_by_{self.strategy}') 119 | return func(self.locator, wait_time=wait_time) 120 | 121 | 122 | @strategy('css') 123 | class FindByCss(SplinterBase): 124 | strategy = 'css' 125 | 126 | 127 | @strategy('xpath') 128 | class FindByXPath(SplinterBase): 129 | strategy = 'xpath' 130 | 131 | 132 | @strategy('tag') 133 | class FindByTag(SplinterBase): 134 | strategy = 'tag' 135 | 136 | 137 | @strategy('name') 138 | class FindByName(SplinterBase): 139 | strategy = 'name' 140 | 141 | 142 | @strategy('text') 143 | class FindByText(SplinterBase): 144 | strategy = 'text' 145 | 146 | 147 | @strategy('id') 148 | class FindById(SplinterBase): 149 | strategy = 'id' 150 | 151 | 152 | @strategy('value') 153 | class FindByValue(SplinterBase): 154 | strategy = 'value' 155 | 156 | 157 | class FindByAttribute(SplinterBase): 158 | """Strategy to find an element by an arbitrary attribute.""" 159 | 160 | _attribute = '' 161 | 162 | def _find_all(self, wait_time: typing.Optional[int] = None): 163 | """Find from inside parent element.""" 164 | parent = self.parent_locator or self.browser 165 | return parent.find_by_css( 166 | f'[{self._attribute}="{self.locator}"]', wait_time=wait_time, 167 | ) 168 | 169 | 170 | def add_data_star_strategy(data_star_attribute: str): 171 | """Add a new splinter strategy that finds by data_star_attribute. 172 | 173 | Arguments: 174 | data_star_attribute (str): The data-* attribute to use in the new 175 | strategy. 176 | """ 177 | find_by_data_star = copy.deepcopy(FindByAttribute) 178 | find_by_data_star._attribute = data_star_attribute 179 | return strategy(data_star_attribute)(find_by_data_star) 180 | -------------------------------------------------------------------------------- /stere/strategy/strategy.py: -------------------------------------------------------------------------------- 1 | strategies = {} 2 | 3 | 4 | def strategy(strategy_name: str): 5 | """Register a strategy name and strategy Class. 6 | 7 | Use as a decorator. 8 | 9 | Example: 10 | @strategy('id') 11 | class FindById: 12 | ... 13 | 14 | Strategy Classes are used to build Elements Objects. 15 | 16 | Arguments: 17 | strategy_name (str): Name of the strategy to be registered. 18 | """ 19 | def wrapper(finder_class): 20 | global strategies 21 | strategies[strategy_name] = finder_class 22 | return finder_class 23 | return wrapper 24 | -------------------------------------------------------------------------------- /stere/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from functools import reduce 3 | from typing import Callable, Optional 4 | 5 | from stere import Stere 6 | 7 | 8 | def rgetattr(obj, attr, *args): 9 | """A nested getattr""" 10 | def _getattr(obj, attr): 11 | return getattr(obj, attr, *args) 12 | return reduce(_getattr, [obj] + attr.split('.')) 13 | 14 | 15 | def _retry( 16 | fn: Callable[[], bool], retry_time: Optional[int] = None, 17 | ) -> bool: 18 | """Retry a function for a specific amount of time. 19 | 20 | Returns: 21 | True if the function returns a truthy value, else False 22 | 23 | Arguments: 24 | fn (function): Function to retry 25 | retry_time: Number of seconds to retry. If not specified, 26 | Stere.retry_time will be used. 27 | 28 | """ 29 | retry_time = retry_time or Stere.retry_time 30 | end_time = time.time() + retry_time 31 | 32 | while time.time() < end_time: 33 | if fn(): 34 | return True 35 | return False 36 | -------------------------------------------------------------------------------- /stere/value_comparator.py: -------------------------------------------------------------------------------- 1 | class ValueComparator: 2 | """Store a boolean result, along with the expected and actual value. 3 | 4 | For equality checks, the value of `result` will be used. 5 | 6 | This object is used to get more robust reporting from Field.value_contains 7 | and Field.value_equals when used with assertions. 8 | 9 | Arguments: 10 | result (bool): The boolean result of the comparison 11 | expected (object): The expected value 12 | actual (object): The actual value 13 | """ 14 | 15 | def __init__( 16 | self, 17 | result: bool, 18 | expected: object = None, 19 | actual: object = None, 20 | ): 21 | self.result = result 22 | self.expected = expected 23 | self.actual = actual 24 | 25 | def __repr__(self) -> str: 26 | """Get a useful representation of this object.""" 27 | return str(self) 28 | 29 | def __str__(self) -> str: 30 | """Get a string representation of this object.""" 31 | rv = ( 32 | f"{self.result}. " 33 | f"Expected: {self.expected}, Actual: {self.actual}" 34 | ) 35 | return rv 36 | 37 | def __eq__(self, other: object) -> bool: 38 | """Check if other equals self.result.""" 39 | if other == self.result: 40 | return True 41 | return False 42 | 43 | def __bool__(self) -> bool: 44 | """Boolean comparison uses self.result.""" 45 | return self.result 46 | -------------------------------------------------------------------------------- /tests/appium/conftest.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | from appium import webdriver 5 | 6 | import pytest 7 | 8 | 9 | def pytest_addoption(parser): 10 | parser.addoption( 11 | "--browser-name", 12 | action="store", 13 | default="", 14 | help="Name of the browser used", 15 | ) 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def appium_capabilities(request): 20 | """Desired capabilities to use with Appium.""" 21 | browser_name = request.config.option.browser_name 22 | 23 | # Set the Sauce Labs job name 24 | github_run_id = os.getenv('GITHUB_RUN_ID') 25 | testrun_name = f"{github_run_id}: {browser_name}" 26 | 27 | sauce_options = { 28 | 'appiumVersion': '1.22.3', 29 | 'name': testrun_name, 30 | } 31 | 32 | desired_caps = { 33 | 'browserName': '', 34 | 'deviceOrientation': 'portrait', 35 | 'app': 'storage:filename=stere_ios_test_app.zip', 36 | "sauce:options": sauce_options, 37 | } 38 | 39 | if browser_name == 'ios': 40 | platform_caps = { 41 | 'deviceName': 'iPhone X Simulator', 42 | 'platformVersion': '15.4', 43 | 'platformName': 'iOS', 44 | } 45 | elif browser_name == 'android': 46 | platform_caps = { 47 | 'deviceName': 'Android Emulator', 48 | 'platformVersion': '12.0', 49 | 'platformName': 'Android', 50 | } 51 | else: 52 | raise ValueError(f'{browser_name} is not a valid browser name') 53 | return {**desired_caps, **platform_caps} 54 | 55 | 56 | @pytest.fixture(scope='session', autouse=True) 57 | def appium_temp_ini(request): 58 | """Write an appium config file.""" 59 | def fin(): 60 | os.remove('stere.ini') 61 | 62 | request.addfinalizer(fin) 63 | 64 | parser = configparser.ConfigParser() 65 | parser['stere'] = {'library': 'appium'} 66 | with open('stere.ini', 'w') as config_file: 67 | parser.write(config_file) 68 | 69 | return parser 70 | 71 | 72 | @pytest.fixture(scope='function', autouse=True) 73 | def setup_stere(request, appium_capabilities): 74 | from stere import Stere # Place here to avoid conflcts with writing ini 75 | url = request.config.option.sauce_remote_url 76 | Stere.browser = webdriver.Remote(url, appium_capabilities) 77 | 78 | 79 | @pytest.fixture(scope='function') 80 | def test_app_main_page(): 81 | from pages import app_main # Place here to avoid conflcts with writing ini 82 | return app_main.AppMain() 83 | -------------------------------------------------------------------------------- /tests/appium/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/tests/appium/pages/__init__.py -------------------------------------------------------------------------------- /tests/appium/pages/app_main.py: -------------------------------------------------------------------------------- 1 | from stere import Page 2 | from stere.fields import ( 3 | Button, 4 | Input, 5 | Text, 6 | ) 7 | 8 | 9 | class AppMain(Page): 10 | """Represents the appium test page.""" 11 | 12 | def __init__(self): 13 | self.build_gear = Button('accessibility_id', 'build_gear_button') 14 | self.number_of_gears = Text('accessibility_id', 'number_of_gears') 15 | self.build_robot_input = Input('accessibility_id', 'build_robot_input') 16 | self.build_robot = Button('accessibility_id', 'build_robot_button') 17 | self.number_of_robots = Text('accessibility_id', 'number_of_robots') 18 | -------------------------------------------------------------------------------- /tests/appium/test_appium.py: -------------------------------------------------------------------------------- 1 | def test_button(test_app_main_page): 2 | """When I click a button to add a gear, 3 | Then a gear is added 4 | """ 5 | test_app_main_page.build_gear.click() 6 | 7 | number_of_gears = test_app_main_page.number_of_gears 8 | 9 | assert "Number of Gears: 1" == number_of_gears.text 10 | 11 | 12 | def test_input(test_app_main_page): 13 | """Given I have 10 gears 14 | When I try to build 1 robot 15 | Then the robot is built 16 | """ 17 | for _ in range(10): 18 | test_app_main_page.build_gear.click() 19 | 20 | test_app_main_page.build_robot_input.send_keys('1') 21 | 22 | test_app_main_page.build_robot.click() 23 | 24 | assert 'Number of Robots: 1' == test_app_main_page.number_of_robots.text 25 | 26 | 27 | def test_input_default_value(test_app_main_page): 28 | """Given I have an Input with a default value 29 | When I call Input.send_keys() with no arguments 30 | Then the default value is filled in 31 | """ 32 | from stere.fields import Input 33 | i = Input('accessibility_id', 'build_robot_input', default_value='5') 34 | i.send_keys() 35 | 36 | assert '05' == i.text 37 | -------------------------------------------------------------------------------- /tests/config/test_config_file.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def invalid_ini(): 9 | """Write an invalid config file.""" 10 | parser = configparser.ConfigParser() 11 | parser['stere'] = {'library': 'invalid'} 12 | with open('stere.ini', 'w') as config_file: 13 | parser.write(config_file) 14 | 15 | return parser 16 | 17 | 18 | def test_field_with_invalid_config(request, py_version, invalid_ini): 19 | """Ensure library specific Fields don't work with a different library.""" 20 | def fin(): 21 | os.remove('stere.ini') 22 | 23 | request.addfinalizer(fin) 24 | 25 | with pytest.raises(ImportError) as e: 26 | from stere.fields import Button # NOQA: F401 27 | 28 | # ImportError message is different between py36 and py37 29 | if py_version.minor == 6: 30 | msg = "cannot import name 'Button'" 31 | 32 | else: 33 | msg = "cannot import name 'Button' from 'stere.fields'" 34 | assert msg in str(e.value) 35 | 36 | 37 | def test_stategy_with_invalid_config(request, py_version, invalid_ini): 38 | """Library specific stategies shouldn't work with a different library.""" 39 | def fin(): 40 | os.remove('stere.ini') 41 | 42 | request.addfinalizer(fin) 43 | 44 | with pytest.raises(ImportError) as e: 45 | from stere.strategy import FindByCss # NOQA: F401 46 | 47 | # ImportError message is different between py36 and py37 48 | if py_version.minor == 6: 49 | msg = "cannot import name 'FindByCss'" 50 | 51 | else: 52 | msg = "cannot import name 'FindByCss' from 'stere.strategy'" 53 | assert msg in str(e.value) 54 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | 6 | def pytest_addoption(parser): 7 | # Added to prevent errors when passing arguments from .travis.yml through 8 | # to tox.ini 9 | parser.addoption( 10 | "--sauce-remote-url", 11 | action="store", 12 | default="", 13 | help="Remote URL for Sauce Labs", 14 | ) 15 | 16 | 17 | @pytest.fixture(scope='session') 18 | def py_version(): 19 | return sys.version_info 20 | -------------------------------------------------------------------------------- /tests/splinter/browser_spy/test_fetch_spy.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from pages import spy_dummy 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def fetch_test_page(): 10 | return spy_dummy.FetchDummyPage() 11 | 12 | 13 | def test_fetch_spy(fetch_test_page): 14 | fetch_test_page.navigate() 15 | fetch_test_page.fetch_spy.add() 16 | 17 | # We know the page waits 3 seconds before making the request 18 | time.sleep(4) 19 | 20 | fetch_test_page.fetch_spy.wait_for_no_activity() 21 | 22 | assert "Hello World" == fetch_test_page.filled_text.text 23 | 24 | 25 | def test_fetch_total_is_accurate(fetch_test_page): 26 | """ 27 | When I check the total number of xhr requests 28 | Then the number is accurate. 29 | """ 30 | fetch_test_page.navigate() 31 | fetch_test_page.fetch_spy.add() 32 | 33 | # We know the page waits 3 seconds before making the request 34 | time.sleep(4) 35 | 36 | assert 2 == fetch_test_page.fetch_spy.total 37 | 38 | 39 | def test_fetch_spy_not_added(fetch_test_page): 40 | """ 41 | When I wait for no activity without having added the spy 42 | Then a TimeoutError should be raised 43 | """ 44 | fetch_test_page.navigate() 45 | 46 | with pytest.raises(TimeoutError): 47 | fetch_test_page.fetch_spy.wait_for_no_activity() 48 | -------------------------------------------------------------------------------- /tests/splinter/browser_spy/test_xhr_spy.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from pages import spy_dummy 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def xhr_test_page(): 10 | return spy_dummy.XHRDummyPage() 11 | 12 | 13 | def test_xhr_spy(xhr_test_page): 14 | xhr_test_page.navigate() 15 | xhr_test_page.xhr_spy.add() 16 | 17 | # We know the page waits 3 seconds before making the request 18 | time.sleep(4) 19 | 20 | xhr_test_page.xhr_spy.wait_for_no_activity() 21 | 22 | assert "Hello World" == xhr_test_page.filled_text.text 23 | 24 | 25 | def test_xhr_spy_total_is_accurate(xhr_test_page): 26 | """ 27 | When I check the total number of xhr requests 28 | Then the number is accurate. 29 | """ 30 | xhr_test_page.navigate() 31 | xhr_test_page.xhr_spy.add() 32 | 33 | # We know the page waits 3 seconds before making the request 34 | time.sleep(4) 35 | 36 | assert 2 == xhr_test_page.xhr_spy.total 37 | 38 | 39 | def test_xhr_spy_not_added(xhr_test_page): 40 | """ 41 | When I wait for no activity without having added the spy 42 | Then a TimeoutError should be raised 43 | """ 44 | xhr_test_page.navigate() 45 | 46 | with pytest.raises(TimeoutError): 47 | xhr_test_page.xhr_spy.wait_for_no_activity() 48 | 49 | 50 | def test_xhr_spy_multiple_add(xhr_test_page): 51 | """ 52 | When I add the XHR spy to the page multiple times 53 | Then the number of total requests is still accurate. 54 | """ 55 | xhr_test_page.navigate() 56 | for _ in range(5): 57 | xhr_test_page.xhr_spy.add() 58 | 59 | # We know the page waits 3 seconds before making the request 60 | time.sleep(4) 61 | 62 | assert 2 == xhr_test_page.xhr_spy.total 63 | 64 | 65 | def test_xhr_spy_multiple_add_in_progress(xhr_test_page): 66 | """ 67 | When I add the XHR spy to the page multiple times 68 | And a request is in progress 69 | Then the number of total requests is still accurate. 70 | """ 71 | xhr_test_page.navigate() 72 | for _ in range(2): 73 | xhr_test_page.xhr_spy.add() 74 | 75 | # We know the page waits 3 seconds before making the request 76 | time.sleep(4) 77 | 78 | for _ in range(2): 79 | xhr_test_page.xhr_spy.add() 80 | 81 | assert 2 == xhr_test_page.xhr_spy.total 82 | -------------------------------------------------------------------------------- /tests/splinter/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pages import dummy 4 | 5 | import pytest 6 | 7 | from stere import Stere 8 | from stere.strategy import add_data_star_strategy 9 | 10 | 11 | add_data_star_strategy('data-test-id') 12 | 13 | 14 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 15 | def pytest_runtest_makereport(item, call): 16 | # execute all other hooks to obtain the report object 17 | outcome = yield 18 | rep = outcome.get_result() 19 | 20 | # set a report attribute for each phase of a call, which can 21 | # be "setup", "call", "teardown" 22 | 23 | setattr(item, "rep_" + rep.when, rep) 24 | 25 | 26 | @pytest.fixture(autouse=True) 27 | def after(request, browser): 28 | 29 | def fin(): 30 | # Send result to Sauce labs 31 | try: 32 | name = request.node.name 33 | browser.execute_script("sauce:job-name={}".format(name)) 34 | 35 | res = str(not request.node.rep_call.failed).lower() 36 | browser.execute_script("sauce:job-result={}".format(res)) 37 | except AttributeError: 38 | pass 39 | 40 | if os.getenv('USE_SAUCE_LABS') == "True": 41 | request.addfinalizer(fin) 42 | 43 | 44 | @pytest.fixture(scope='session') 45 | def splinter_remote_url(request): 46 | return request.config.option.sauce_remote_url 47 | 48 | 49 | @pytest.fixture(scope='session') 50 | def browser_name(request, splinter_webdriver) -> str: 51 | """Get the name of the web browser used.""" 52 | name = splinter_webdriver 53 | if name == 'remote': 54 | name = request.config.option.splinter_remote_name 55 | 56 | return name 57 | 58 | 59 | @pytest.fixture(scope='session') 60 | def current_options(chrome_options, firefox_options, browser_name): 61 | if browser_name == "chrome": 62 | return chrome_options 63 | 64 | return firefox_options 65 | 66 | 67 | @pytest.fixture(scope='session') 68 | def splinter_driver_kwargs( 69 | splinter_webdriver, request, browser_name, current_options, 70 | ): 71 | """Webdriver kwargs.""" 72 | browser_versions = { 73 | 'chrome': 'latest-1', 74 | 'firefox': 'latest-1', 75 | } 76 | 77 | version = browser_versions.get(browser_name) 78 | if version is None: 79 | raise ValueError('Unknown browser_name provided') 80 | 81 | # Set the Sauce Labs job name 82 | github_run_id = os.getenv('GITHUB_RUN_ID') 83 | testrun_name = f"{github_run_id}: {browser_name}" 84 | 85 | if os.environ.get('USE_SAUCE_LABS') == "True": 86 | # Sauce Labs settings 87 | current_options.browser_version = version 88 | current_options.platform_name = "Windows 10" 89 | 90 | sauce_options = { 91 | "name": testrun_name, 92 | "tunnelIdentifier": "github-action-tunnel", 93 | "seleniumVersion": "4.1.0", 94 | } 95 | current_options.set_capability("sauce:options", sauce_options) 96 | 97 | # Weird sauce labs issue 98 | if browser_name == 'chrome': 99 | sauce_options["browserName"] = "chrome" 100 | return {"desired_capabilities": sauce_options} 101 | 102 | return {} 103 | 104 | 105 | @pytest.fixture(scope='function', autouse=True) 106 | def setup_stere(browser): 107 | Stere.browser = browser 108 | Stere.url_navigator = "visit" 109 | Stere.base_url = 'https://jsfehler.github.io/stere/' 110 | 111 | 112 | @pytest.fixture(scope='function') 113 | def test_page(): 114 | return dummy.DummyPage() 115 | -------------------------------------------------------------------------------- /tests/splinter/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/stere/f364fa60c7f755f2c0ea3a47c42bb3f7566c484f/tests/splinter/pages/__init__.py -------------------------------------------------------------------------------- /tests/splinter/pages/dummy.py: -------------------------------------------------------------------------------- 1 | from stere import Page 2 | from stere.areas import Area, Repeating, RepeatingArea 3 | from stere.fields import ( 4 | Button, 5 | Checkbox, 6 | Dropdown, 7 | Field, 8 | Input, 9 | Link, 10 | Money, 11 | Root, 12 | ShadowRoot, 13 | Text, 14 | ) 15 | 16 | 17 | class CSSDropdown(Dropdown): 18 | """A Dropdown that's customized to hover over the element before attempting 19 | a select. 20 | """ 21 | 22 | def before(self): 23 | self.element.mouse_over() 24 | 25 | 26 | class DummyPage(Page): 27 | """Represents the test page.""" 28 | 29 | def __init__(self): 30 | self.url_suffix = 'test_page/test_page.html' 31 | 32 | self.button_container = Root('id', 'test_button_div') 33 | self.button = Button('id', 'test_button') 34 | 35 | # Field for something that isn't on the page. 36 | self.missing_button = Button('data-test-id', 'not_on_the_page') 37 | 38 | # Targets the same button as above, but using a different strategy. 39 | self.button_alt_strategy = Button('data-test-id', 'Stere_test_button') 40 | 41 | self.input_area = Area( 42 | input=Input('id', 'test_input'), 43 | submit_button=Button('id', 'test_input_submit'), 44 | ) 45 | self.link = Link('id', 'test_link') 46 | self.dropdown_area = Area( 47 | dropdown=Dropdown('id', 'test_dropdown'), 48 | submit=Button('id', 'test_dropdown_submit'), 49 | ) 50 | 51 | # Override the option Field with a bad locator. 52 | self.dropdown_with_override_option = Dropdown( 53 | 'id', 'test_dropdown', option=Field('id', 'foobar'), 54 | ) 55 | 56 | self.css_dropdown = CSSDropdown( 57 | 'id', 58 | 'test_css_dropdown', 59 | option=Link('css', 'a'), 60 | ) 61 | 62 | self.repeating_area = RepeatingArea( 63 | root=Root('css', '.test_repeating_area_root_a'), 64 | link=Link('xpath', './a'), 65 | text=Text('css', '.test_repeating_area_test'), 66 | nested=Area( 67 | root=Root('css', '.test_repeating_area_nested'), 68 | ax=Field('css', '.ax'), 69 | bx=Field('css', '.bx'), 70 | ), 71 | ) 72 | 73 | # Identical to the above RepeatingArea, but now the Area has no root. 74 | self.repeating_area_area_no_root = RepeatingArea( 75 | root=Root('css', '.test_repeating_area_root_a'), 76 | link=Link('xpath', './a'), 77 | text=Text('css', '.test_repeating_area_test'), 78 | nested=Area( 79 | ax=Field('css', '.test_repeating_area_nested .ax'), 80 | bx=Field('css', '.test_repeating_area_nested .bx'), 81 | ), 82 | ) 83 | 84 | # A Repeating Area that won't find anything. 85 | self.repeating_area_missing = RepeatingArea( 86 | root=Root('css', '.test_repeating_area_root_invalid'), 87 | link=Link('xpath', '//h4'), 88 | ) 89 | 90 | # A Repeating Area that repeats 91 | self.repeating = Repeating( 92 | root=Root('css', '.repeatingRepeating'), 93 | repeater=RepeatingArea( 94 | root=Root('css', '.test_repeating_area_root'), 95 | link=Link('xpath', './/a'), 96 | text=Text('css', '.test_repeating_area_test'), 97 | ), 98 | ) 99 | 100 | # Functionally identical to RepeatingArea, a Repeating with an Area 101 | self.repeating_with_area = Repeating( 102 | root=Root('css', '.test_repeating_area_root_a'), 103 | repeater=Area( 104 | link=Link('xpath', './a'), 105 | text=Text('css', '.test_repeating_area_test'), 106 | ), 107 | ) 108 | 109 | # Repeating with an Area with a RepeatingArea with no root 110 | self.repeating_area_repeatingarea = Repeating( 111 | root=Root('css', '.repeatingRepeating'), 112 | repeater=Area( 113 | it_repeats=RepeatingArea( 114 | root=Root('css', '.test_repeating_area_root'), 115 | text=Text('css', '.test_repeating_area_test'), 116 | ), 117 | ), 118 | ) 119 | 120 | # Area with a RepeatingArea inside 121 | self.area_repeating_area = Area( 122 | root=Root('xpath', '/html/body/div[10]'), 123 | it_repeats=RepeatingArea( 124 | root=Root('css', '.test_repeating_area_root_a'), 125 | link=Link('xpath', './a'), 126 | text=Text('css', '.test_repeating_area_test'), 127 | ), 128 | ) 129 | 130 | # Area with an Area inside 131 | self.area_in_area = Area( 132 | root=Root('xpath', '/html/body/div[9]'), 133 | inner_area=Area( 134 | root=Root('id', 'area_root'), 135 | link=Link('xpath', './a'), 136 | 137 | ), 138 | ) 139 | 140 | # Area with an Area inside, no root 141 | self.area_in_area_no_root = Area( 142 | root=Root('xpath', 'html/body/div[9]'), 143 | inner_area=Area( 144 | link=Link('xpath', './/div[@id="area_root"]/a'), 145 | ), 146 | ) 147 | 148 | # Will only be visible on the page after 10 seconds 149 | self.added_container_by_id = Field('id', 'added_container') 150 | 151 | # Same Field, different selectors 152 | self.added_container_by_xpath = Field( 153 | 'xpath', '//div[@id="added_container"]') 154 | self.added_container_by_css = Field('css', '#added_container') 155 | 156 | # Will be removed from the page after 10 seconds 157 | self.removed_container_by_id = Field('id', 'removed_container') 158 | 159 | # Same Field, different selectors 160 | self.removed_container_by_xpath = Field( 161 | 'xpath', '//div[@id="removed_container"]', 162 | ) 163 | self.removed_container_by_css = Field('css', '#removed_container') 164 | 165 | # Will be hidden after 10 seconds 166 | self.to_hide_container_by_id = Field('id', 'to_hide_container') 167 | self.to_hide_container_by_xpath = Field( 168 | 'xpath', 169 | '//div[@id="to_hide_container"]', 170 | ) 171 | self.to_hide_container_by_css = Field('css', '#to_hide_container') 172 | 173 | self.area_with_root = Area( 174 | root=Root('id', 'area_root'), 175 | link=Link('xpath', './a'), 176 | ) 177 | 178 | self.area_with_root_alt_strategy = Area( 179 | root=Root('data-test-id', 'Stere_area_root'), 180 | link=Link('data-test-id', 'Stere_area_root_link'), 181 | ) 182 | 183 | self.many_input_area = Area( 184 | first_name=Input( 185 | 'id', 186 | 'test_input_first_name', 187 | workflows=['workflow_test'], 188 | ), 189 | last_name=Input('id', 'test_input_last_name'), 190 | email=Input('id', 'test_input_email'), 191 | age=Input('id', 'test_input_age'), 192 | submit=Button( 193 | 'id', 194 | 'test_many_input_submit', 195 | workflows=['workflow_test'], 196 | ), 197 | ) 198 | self.many_input_result = Text('id', 'many_input_result') 199 | 200 | self.checkbox = Checkbox('id', 'test_checkbox') 201 | self.checkbox_checked = Checkbox( 202 | 'id', 'test_checkbox_checked', default_checked=True, 203 | ) 204 | 205 | # Field for something on the page we know isn't unique. 206 | self.purposefully_non_unique_field = Field( 207 | 'css', '.test_repeating_area_root', 208 | ) 209 | 210 | self.money_field = Money('id', 'moneyMoney') 211 | 212 | self.shadow_root_area = Area( 213 | root=ShadowRoot('css', '#has_shadow_root'), 214 | data=Text('css', '#text_in_shadow_root'), 215 | ) 216 | -------------------------------------------------------------------------------- /tests/splinter/pages/dummy_invalid.py: -------------------------------------------------------------------------------- 1 | from stere import Page 2 | from stere.areas import RepeatingArea 3 | from stere.fields import Root 4 | 5 | 6 | class InvalidDummyPageC(Page): 7 | """Represents a page that shouldn't work.""" 8 | 9 | def __init__(self): 10 | self.non_field_kwargs = RepeatingArea( 11 | root=Root('css', '.test_repeating_area_root'), 12 | link="Foobar", 13 | ) 14 | -------------------------------------------------------------------------------- /tests/splinter/pages/spy_dummy.py: -------------------------------------------------------------------------------- 1 | from stere import Page 2 | from stere.fields import Text 3 | 4 | 5 | class XHRDummyPage(Page): 6 | """Represents the XHR test page.""" 7 | 8 | def __init__(self): 9 | self.url_suffix = 'test_page/xhr_test_page.html' 10 | 11 | self.filled_text = Text('id', 'filledText') 12 | 13 | 14 | class FetchDummyPage(Page): 15 | """Represents the Fetch test page.""" 16 | 17 | def __init__(self): 18 | self.url_suffix = 'test_page/fetch_test_page.html' 19 | 20 | self.filled_text = Text('id', 'filledText') 21 | -------------------------------------------------------------------------------- /tests/splinter/repeating_area/test_areas_integration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from selenium.webdriver.remote.remote_connection import LOGGER 6 | 7 | from stere.areas import Areas 8 | 9 | 10 | LOGGER.setLevel(logging.WARNING) 11 | 12 | 13 | def test_areas_containing_type(test_page): 14 | """Ensure Areas.containing() returns an Areas object.""" 15 | test_page.navigate() 16 | 17 | found_areas = test_page.repeating_area.areas.containing( 18 | 'link', 'Repeating Link 2', 19 | ) 20 | 21 | assert isinstance(found_areas, Areas) 22 | 23 | 24 | def test_areas_containing(test_page): 25 | """Ensure Areas.containing() returns valid results.""" 26 | test_page.navigate() 27 | 28 | found_areas = test_page.repeating_area.areas.containing( 29 | 'link', 'Repeating Link 2', 30 | ) 31 | 32 | assert found_areas[0].text.value == 'Repeating Area 2' 33 | 34 | 35 | def test_areas_containing_nested_attr(test_page): 36 | """Ensure Areas.containing() handles dot attrs.""" 37 | test_page.navigate() 38 | 39 | found_areas = test_page.repeating_area.areas.containing( 40 | 'nested.ax', 'AX1', 41 | ) 42 | 43 | assert found_areas[0].nested.ax.value == 'AX1' 44 | 45 | 46 | def test_areas_containing_invalid_field_name(test_page): 47 | test_page.navigate() 48 | 49 | with pytest.raises(AttributeError) as e: 50 | test_page.repeating_area.areas.containing( 51 | 'lunk', 'Repeating Link 2') 52 | 53 | assert str(e.value) == "'Area' object has no attribute 'lunk'" 54 | 55 | 56 | def test_areas_containing_nested_attr_invalid_field_name(test_page): 57 | test_page.navigate() 58 | 59 | with pytest.raises(AttributeError) as e: 60 | test_page.repeating_area.areas.containing( 61 | 'nested.cx', 'CX1') 62 | 63 | assert str(e.value) == "'Area' object has no attribute 'cx'" 64 | 65 | 66 | def test_areas_contain(test_page): 67 | """Ensure Areas.contain() returns True when a result is found.""" 68 | test_page.navigate() 69 | 70 | assert test_page.repeating_area.areas.contain("link", "Repeating Link 1") 71 | 72 | 73 | def test_areas_contain_not_found(test_page): 74 | """Ensure Areas.contain() returns False when a result is not found.""" 75 | test_page.navigate() 76 | 77 | assert not test_page.repeating_area.areas.contain( 78 | "link", "Repeating Link 666", 79 | ) 80 | -------------------------------------------------------------------------------- /tests/splinter/repeating_area/test_repeating_area.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pages import dummy_invalid 4 | 5 | import pytest 6 | 7 | from selenium.webdriver.remote.remote_connection import LOGGER 8 | 9 | 10 | LOGGER.setLevel(logging.WARNING) 11 | 12 | 13 | def test_non_field_kwarg(): 14 | """When an object that does not inherit from Field is used to instantiate 15 | a RepeatingArea 16 | Then a ValueError is thrown 17 | And it should inform the user that only Field objects can be used 18 | """ 19 | expected_message = 'RepeatingArea arguments can only be a Field or Area.' 20 | 21 | with pytest.raises(ValueError) as e: 22 | dummy_invalid.InvalidDummyPageC() 23 | 24 | assert str(e.value) == expected_message 25 | 26 | 27 | def test_repeating_area_areas_no_areas_found(test_page): 28 | """Given I have a RepeatingArea that finds no Areas on the page, 29 | When I call RepeatingArea.areas(), 30 | Then I should be informed that no Area were found. 31 | """ 32 | test_page.navigate() 33 | with pytest.raises(ValueError) as e: 34 | test_page.repeating_area_missing.areas 35 | 36 | assert str(e.value) == ( 37 | "Could not find any Area using the root: " 38 | ".test_repeating_area_root_invalid" 39 | ) 40 | 41 | 42 | def test_len(test_page): 43 | """When I call len() on a RepeatingArea 44 | Then it should report back how many Areas were found. 45 | """ 46 | test_page.navigate() 47 | assert 2 == len(test_page.repeating_area) 48 | 49 | 50 | def test_repeating_area(test_page): 51 | test_page.navigate() 52 | 53 | listings = test_page.repeating_area.areas 54 | assert listings[0].link.text == "Repeating Link 1" 55 | assert listings[1].link.text == "Repeating Link 2" 56 | 57 | 58 | def test_repeating_area_with_area(test_page): 59 | """When a RepeatingArea has an Area inside it 60 | Then the Area should have the correct root 61 | """ 62 | test_page.navigate() 63 | 64 | listings = test_page.repeating_area.areas 65 | assert listings[0].nested.ax.text == "AX1" 66 | assert listings[0].nested.bx.text == "BX1" 67 | assert listings[1].nested.bx.text == "BX2" 68 | assert listings[1].nested.ax.text == "AX2" 69 | 70 | 71 | def test_repeating_area_with_area_no_root(test_page): 72 | """When a RepeatingArea has an Area inside it 73 | And the Area has no root 74 | Then the found Area's Fields get a parent_locator set by the RepeatingArea 75 | """ 76 | test_page.navigate() 77 | 78 | areas = test_page.repeating_area_area_no_root.areas 79 | assert areas[0].nested.ax.parent_locator is not None 80 | 81 | 82 | @pytest.mark.skip 83 | def test_repeating_area_includes(test_page): 84 | test_page.navigate() 85 | elem = test_page.repeating_area.links.includes("Repeating Link 1") 86 | assert elem.value == "Repeating Link 1" 87 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_button.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.fields import Button 4 | 5 | 6 | button_before_str = 'before' 7 | button_after_str = 'after' 8 | 9 | 10 | @pytest.fixture(scope='session') 11 | def dummy_button(): 12 | class Dummy(Button): 13 | def before(self): 14 | global button_before_str 15 | button_before_str = 'foo' 16 | 17 | def after(self): 18 | global button_after_str 19 | button_after_str = 'bar' 20 | 21 | return Dummy('id', 'test_button') 22 | 23 | 24 | def test_button_call(browser, request, browser_name, test_page): 25 | """When a Button is called 26 | Then the Button's perform method is called 27 | """ 28 | test_page.navigate() 29 | test_page.button() 30 | 31 | # Clicking changes the button's container background colour 32 | browsers = { 33 | 'firefox': 'rgb(255, 0, 0)', 34 | 'chrome': 'rgba(255, 0, 0, 1)', 35 | } 36 | 37 | # This works because value_of_css_property is gotten from splinter, 38 | # which gets it from Selenium 39 | actual = test_page.button_container.find()._element.value_of_css_property( 40 | 'background-color') 41 | 42 | assert browsers[browser_name] == actual 43 | 44 | 45 | def test_button(browser, request, browser_name, test_page): 46 | """When a Button is clicked 47 | Then the correct action is sent to Splinter 48 | """ 49 | test_page.navigate() 50 | test_page.button.click() 51 | 52 | # Clicking changes the button's container background colour 53 | browsers = { 54 | 'firefox': 'rgb(255, 0, 0)', 55 | 'chrome': 'rgba(255, 0, 0, 1)', 56 | } 57 | 58 | # This works because value_of_css_property is gotten from splinter, 59 | # which gets it from Selenium 60 | actual = test_page.button_container.find()._element.value_of_css_property( 61 | 'background-color') 62 | 63 | assert browsers[browser_name] == actual 64 | 65 | 66 | def test_before_click(test_page, dummy_button): 67 | """Given I have an Button with a before method defined 68 | When I call Button.click() 69 | Then Button.before() is called first 70 | """ 71 | test_page.navigate() 72 | dummy_button.click() 73 | assert 'foo' == button_before_str 74 | 75 | 76 | def test_after_click(test_page, dummy_button): 77 | """Given I have an Button with an after method defined 78 | When I call Button.click() 79 | Then Button.after() is called after 80 | """ 81 | test_page.navigate() 82 | dummy_button.click() 83 | 84 | assert 'bar' == button_after_str 85 | 86 | 87 | def test_performer_returns_attribute_not_present(test_page): 88 | """Given a Button has a returns attribute set 89 | When the Button's performer method is called 90 | Then it returns the returns attribute 91 | """ 92 | harpoon = Button('id', 'test_button') 93 | 94 | test_page.navigate() 95 | result = harpoon.perform() 96 | 97 | assert result is None 98 | 99 | 100 | def test_performer_returns_attribute_present(test_page): 101 | """Given a Button has a returns attribute set 102 | When the Button's performer method is called 103 | Then it returns the returns attribute 104 | """ 105 | class GetOverHere: 106 | def __init__(self, target): 107 | self.target = target 108 | 109 | harpoon = Button('id', 'test_button', returns=GetOverHere('sub-zero')) 110 | 111 | test_page.navigate() 112 | result = harpoon.perform() 113 | 114 | assert result.target == 'sub-zero' 115 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_dropdown.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | 5 | import pytest 6 | 7 | from selenium.webdriver.remote.remote_connection import LOGGER 8 | 9 | LOGGER.setLevel(logging.WARNING) 10 | 11 | 12 | def test_html_dropdown_default_option_field(test_page): 13 | """Given the option argument was provided for a Dropdown 14 | Then the option attribute should match the argument 15 | """ 16 | assert test_page.dropdown_with_override_option.option.locator == 'foobar' 17 | assert test_page.dropdown_with_override_option.option.strategy == 'id' 18 | 19 | 20 | def test_html_dropdown_perform_return_value(test_page): 21 | """When Dropdown's perform() method is called 22 | And Dropdown consumes an argument 23 | And Dropdown does not return anything 24 | Then Dropdown's performer method should return None 25 | """ 26 | test_page.navigate() 27 | res = test_page.dropdown_area.dropdown.perform('Banana') 28 | 29 | assert res is None 30 | 31 | 32 | def test_html_dropdown(browser, test_page): 33 | test_page.navigate() 34 | test_page.dropdown_area.dropdown.select('Banana') 35 | test_page.dropdown_area.submit.click() 36 | 37 | time.sleep(2) 38 | 39 | # The result of clicking should land the user on google.ca 40 | assert 'search?q=banana' in str.lower(browser.url) 41 | 42 | 43 | @pytest.mark.skipif(os.environ.get('USE_SAUCE_LABS', "True")) 44 | def test_css_dropdown(browser, test_page): 45 | # Can't be run on Remote Firefox. mouse_over isn't supported. 46 | # BUG: Supported in Remote Chrome, but: 47 | # https://github.com/cobrateam/splinter/pull/423 48 | 49 | test_page.navigate() 50 | test_page.css_dropdown.select('Dog') 51 | 52 | time.sleep(2) 53 | 54 | assert 'test_page.html#dog' in browser.url 55 | 56 | 57 | def test_dropdown_invalid(test_page): 58 | test_page.navigate() 59 | 60 | with pytest.raises(ValueError) as e: 61 | test_page.dropdown_area.dropdown.select('Grape') 62 | 63 | contents = ["Apple", "Banana", "Cranberry"] 64 | expected_message = f'Grape was not found. Found values are: {contents}' 65 | assert expected_message == str(e.value) 66 | 67 | 68 | def test_dropdown_getitem(browser, test_page): 69 | """When I index a dropdown 70 | Then I get the dropdown item in the desired position 71 | """ 72 | test_page.navigate() 73 | 74 | test_page.dropdown_area.dropdown[1] 75 | test_page.dropdown_area.submit.click() 76 | 77 | time.sleep(2) 78 | 79 | # The result of clicking should land the user on google.ca 80 | assert 'search?q=banana' in str.lower(browser.url) 81 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_input.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from selenium.webdriver.common.keys import Keys 4 | 5 | from stere.fields import Input 6 | 7 | 8 | input_before_str = 'before' 9 | input_after_str = 'after' 10 | 11 | 12 | @pytest.fixture(scope='session') 13 | def dummy_input(): 14 | class Dummy(Input): 15 | def before(self): 16 | global input_before_str 17 | input_before_str = 'foo' 18 | 19 | def after(self): 20 | global input_after_str 21 | input_after_str = 'bar' 22 | 23 | return Dummy('id', 'test_input_first_name') 24 | 25 | 26 | def test_input_implicit_call(test_page): 27 | """When an Input is called 28 | Then the Input's perform method is called 29 | """ 30 | test_page.navigate() 31 | test_page.input_area.input('Winamp') 32 | 33 | 34 | def test_input(test_page): 35 | """When an input is filled with the text 'Winamp' 36 | Then the text in the input should be 'Winamp' 37 | """ 38 | test_page.navigate() 39 | test_page.input_area.input.fill('Winamp') 40 | 41 | assert 'Winamp' == test_page.input_area.input.element.value 42 | 43 | 44 | def test_before_fill(test_page, dummy_input): 45 | """Given I have an Input with a before method defined 46 | When I call Input.fill() 47 | Then Input.before() is called first 48 | """ 49 | test_page.navigate() 50 | dummy_input.fill('Input this') 51 | assert 'foo' == input_before_str 52 | 53 | 54 | def test_input_default_value(test_page): 55 | """Given I have an Input with a default value 56 | When I call Input.fill() with no arguments 57 | Then the default value is filled in 58 | """ 59 | test_page.navigate() 60 | 61 | i = Input('id', 'test_input_first_name', default_value='Ampwin') 62 | i.fill() 63 | 64 | assert 'Ampwin' == i.value 65 | 66 | 67 | def test_after_fill(test_page, dummy_input): 68 | """Given I have an Input with an after method defined 69 | When I call Input.fill() 70 | Then Input.after() is called after 71 | """ 72 | test_page.navigate() 73 | dummy_input.fill('Input this') 74 | 75 | assert 'bar' == input_after_str 76 | 77 | 78 | def test_input_call(test_page): 79 | test_page.navigate() 80 | 81 | i = Input('id', 'test_input_first_name', returns=10) 82 | 83 | assert 10 == i.perform('alpha') 84 | 85 | 86 | def test_input_highlight(test_page): 87 | test_page.navigate() 88 | 89 | test_page.input_area.input.fill('highlight me') 90 | assert test_page.input_area.input.value == 'highlight me' 91 | 92 | test_page.input_area.input.highlight() 93 | 94 | # Clear text content with delete. 95 | # Since it's highlighted, all of it should be removed. 96 | test_page.input_area.input.fill(Keys.DELETE) 97 | 98 | assert test_page.input_area.input.value == '' 99 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_link.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from stere.fields import Link 6 | 7 | 8 | link_before_str = 'before' 9 | link_after_str = 'after' 10 | 11 | 12 | @pytest.fixture(scope='session') 13 | def dummy_link(): 14 | class Dummy(Link): 15 | def before(self): 16 | global link_before_str 17 | link_before_str = 'foo' 18 | 19 | def after(self): 20 | global link_after_str 21 | link_after_str = 'bar' 22 | 23 | return Dummy('id', 'test_link') 24 | 25 | 26 | def test_link_call(browser, test_page): 27 | """When a Link is called 28 | Then the Link's perform method is called 29 | """ 30 | test_page.navigate() 31 | test_page.link() 32 | 33 | time.sleep(2) 34 | 35 | # The result of clicking should land the user on google.ca 36 | assert 'https://www.google.ca' in browser.url 37 | 38 | 39 | def test_link(browser, test_page): 40 | """When a link is clicked 41 | Then the link's action occurs 42 | """ 43 | test_page.navigate() 44 | test_page.link.click() 45 | 46 | time.sleep(2) 47 | 48 | # The result of clicking should land the user on google.ca 49 | assert 'https://www.google.ca' in browser.url 50 | 51 | 52 | def test_perform_return_value(test_page): 53 | """When Link's perform() method is called 54 | And Link does not consume an argument 55 | Then Link's performer method should return False 56 | """ 57 | test_page.navigate() 58 | res = test_page.link.perform() 59 | 60 | assert not res 61 | 62 | 63 | def test_before_fill(test_page, dummy_link): 64 | """Given I have an Link with a before method defined 65 | When I call Link.click() 66 | Then Link.before() is called first 67 | """ 68 | test_page.navigate() 69 | dummy_link.click() 70 | assert 'foo' == link_before_str 71 | 72 | 73 | def test_after_fill(test_page, dummy_link): 74 | """Given I have an Link with an after method defined 75 | When I call Link.click() 76 | Then Link.after() is called after 77 | """ 78 | test_page.navigate() 79 | dummy_link.click() 80 | 81 | assert 'bar' == link_after_str 82 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_money.py: -------------------------------------------------------------------------------- 1 | from moneyed import Money 2 | 3 | 4 | def test_money_money_type(test_page): 5 | """Given I have a field with money in it 6 | When I call the money method of the field 7 | Then the object returns is a Money object 8 | """ 9 | test_page.navigate() 10 | m = test_page.money_field.money() 11 | assert isinstance(m, Money) 12 | 13 | 14 | def test_money_number(test_page): 15 | """Given I have a field with money in it 16 | When I call the number attribute of the field 17 | Then the text in the field is normalized to a number 18 | """ 19 | test_page.navigate() 20 | m = test_page.money_field.number 21 | assert m == '9001' 22 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_shadow_root.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def skip_by_browser(request, browser_name): 6 | marker = request.node.get_closest_marker('skip_if_browser') 7 | 8 | if marker.args[0] == browser_name: 9 | pytest.skip(marker.args[1]) 10 | 11 | 12 | @pytest.mark.skip_if_browser('firefox', "Can't get shadowRoot in firefox") 13 | def test_shadow_root_find_all(test_page): 14 | """When I find the shadow root of an element 15 | Then the elements in the shadow root can be found. 16 | """ 17 | test_page.navigate() 18 | assert test_page.shadow_root_area.data.value == 'Inside a shadow root' 19 | -------------------------------------------------------------------------------- /tests/splinter/splinter_fields/test_splinter_fields.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from selenium.webdriver.remote.remote_connection import LOGGER 6 | 7 | LOGGER.setLevel(logging.WARNING) 8 | 9 | 10 | def test_checkbox_set_to_true(test_page): 11 | test_page.navigate() 12 | test_page.checkbox.set_to(True) 13 | 14 | assert test_page.checkbox.checked 15 | 16 | 17 | def test_checkbox_set_to_false(test_page): 18 | test_page.navigate() 19 | test_page.checkbox.check() 20 | 21 | assert test_page.checkbox.checked 22 | 23 | test_page.checkbox.set_to(False) 24 | 25 | assert test_page.checkbox.checked is False 26 | 27 | 28 | def test_checkbox_toggle_on(test_page): 29 | test_page.navigate() 30 | test_page.checkbox.toggle() 31 | 32 | assert test_page.checkbox.checked 33 | 34 | 35 | def test_checkbox_toggle_off(test_page): 36 | test_page.navigate() 37 | test_page.checkbox.toggle() 38 | test_page.checkbox.toggle() 39 | 40 | assert test_page.checkbox.checked is False 41 | 42 | 43 | def test_checkbox_default_checked(test_page): 44 | test_page.navigate() 45 | test_page.checkbox.perform() 46 | 47 | assert test_page.checkbox.checked 48 | 49 | 50 | def test_checkbox_opposite_default_unchecked(test_page): 51 | test_page.navigate() 52 | test_page.checkbox_checked.opposite() 53 | 54 | assert test_page.checkbox.checked is False 55 | 56 | 57 | def test_field_name(test_page): 58 | """Fields should report their intended class name, not 'Performer'.""" 59 | with pytest.raises(TypeError) as e: 60 | test_page.button[0] 61 | 62 | assert 'Button' in str(e.value) 63 | 64 | with pytest.raises(TypeError) as e: 65 | test_page.input_area.input[0] 66 | 67 | assert 'Input' in str(e.value) 68 | 69 | with pytest.raises(TypeError) as e: 70 | test_page.link[0] 71 | 72 | assert 'Link' in str(e.value) 73 | -------------------------------------------------------------------------------- /tests/splinter/test_area.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from selenium.webdriver.remote.remote_connection import LOGGER 5 | 6 | LOGGER.setLevel(logging.WARNING) 7 | 8 | 9 | def test_area_root_available(test_page): 10 | """Given an area has a root Field set, 11 | Then the root should be accessible. 12 | """ 13 | test_page.navigate() 14 | assert test_page.area_with_root.root is not None 15 | 16 | 17 | def test_area_with_root(test_page): 18 | test_page.navigate() 19 | test_page.area_with_root.link.click() 20 | 21 | 22 | def test_area_with_root_alt_strategy(test_page): 23 | test_page.navigate() 24 | test_page.area_with_root_alt_strategy.link.click() 25 | 26 | 27 | def test_area_items(browser, test_page): 28 | """ 29 | When an area is created 30 | Then the items in the area can be accessed with dot notation 31 | """ 32 | test_page.navigate() 33 | test_page.input_area.input.fill('Winamp') 34 | test_page.input_area.submit_button.click() 35 | 36 | time.sleep(2) 37 | 38 | # The result of the perform should land the user on google.ca 39 | assert 'https://www.google.' in browser.url 40 | 41 | 42 | def test_area_perform(browser, test_page): 43 | """ 44 | When an area is performed 45 | Then each of the Fields inside it is used 46 | """ 47 | test_page.navigate() 48 | test_page.input_area.perform('Winamp') 49 | 50 | time.sleep(2) 51 | 52 | # The result of the perform should land the user on google.ca 53 | assert 'https://www.google.' in browser.url 54 | 55 | 56 | def test_area_perform_multiple_args(test_page): 57 | test_page.navigate() 58 | test_page.many_input_area.perform( 59 | 'Fooman', 60 | 'Barson', 61 | 'foobar@binbaz.net', 62 | '99', 63 | ) 64 | 65 | time.sleep(2) 66 | 67 | expected = 'Fooman, Barson, foobar@binbaz.net, 99,' 68 | assert expected == test_page.many_input_result.text 69 | 70 | 71 | def test_area_perform_kwargs(test_page): 72 | """When perform is called with kwargs, parameters should be respected.""" 73 | test_page.navigate() 74 | test_page.many_input_area.perform( 75 | first_name='Fooman', 76 | last_name='Barson', 77 | email='foobar@binbaz.net', 78 | age='99', 79 | ) 80 | 81 | time.sleep(2) 82 | 83 | expected = 'Fooman, Barson, foobar@binbaz.net, 99,' 84 | assert expected == test_page.many_input_result.text 85 | 86 | 87 | def test_area_use_workflow(test_page): 88 | test_page.navigate() 89 | test_page.many_input_area.workflow('workflow_test').perform('Fooman') 90 | 91 | time.sleep(2) 92 | 93 | expected = 'Fooman, , , ,' 94 | assert expected == test_page.many_input_result.text 95 | 96 | 97 | def test_area_with_repeating_area(test_page): 98 | """When RepeatingArea is inside an Area, Area root is inherited.""" 99 | test_page.navigate() 100 | 101 | listings = test_page.area_repeating_area.it_repeats.areas 102 | assert listings[0].link.text == "Repeating Link 1" 103 | assert listings[1].link.text == "Repeating Link 2" 104 | 105 | 106 | def test_area_with_area(test_page): 107 | """Area inside Area""" 108 | test_page.navigate() 109 | 110 | t = test_page.area_in_area.inner_area.link.text 111 | assert "I'm just a link in a div." == t 112 | 113 | 114 | def test_area_with_area_no_root(test_page): 115 | """Area inside Area""" 116 | test_page.navigate() 117 | 118 | t = test_page.area_in_area_no_root.inner_area.link.text 119 | assert "I'm just a link in a div." == t 120 | 121 | 122 | def test_text_to_dict_area(test_page): 123 | test_page.navigate() 124 | 125 | t = test_page.area_in_area.text_to_dict() 126 | 127 | assert t == { 128 | 'inner_area': {'link': "I'm just a link in a div."}, 129 | } 130 | 131 | 132 | def test_text_to_dict_area_repeating_area(test_page): 133 | test_page.navigate() 134 | 135 | t = test_page.area_repeating_area.text_to_dict() 136 | 137 | assert t == { 138 | 'it_repeats': [ 139 | {'link': 'Repeating Link 1', 'text': 'Repeating Area 1'}, 140 | {'link': 'Repeating Link 2', 'text': 'Repeating Area 2'}, 141 | ], 142 | } 143 | -------------------------------------------------------------------------------- /tests/splinter/test_field.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from selenium.webdriver.remote.remote_connection import LOGGER 6 | 7 | 8 | LOGGER.setLevel(logging.WARNING) 9 | 10 | 11 | def test_value_equals(test_page): 12 | test_page.navigate() 13 | 14 | assert not test_page.many_input_area.first_name.value_equals('aabbaa') 15 | 16 | test_page.many_input_area.perform( 17 | "aabbaa", 18 | "bbccbb", 19 | "ccddcc", 20 | "ddeedd", 21 | ) 22 | 23 | assert test_page.many_input_area.first_name.value_equals('aabbaa') 24 | 25 | 26 | def test_value_contains(test_page): 27 | test_page.navigate() 28 | 29 | assert not test_page.many_input_area.first_name.value_contains('bbaa') 30 | 31 | test_page.many_input_area.perform( 32 | "aabbaa", 33 | "bbccbb", 34 | "ccddcc", 35 | "ddeedd", 36 | ) 37 | assert test_page.many_input_area.first_name.value_contains('bbaa') 38 | 39 | 40 | def test_field_getattr(test_page): 41 | """ 42 | When I try to access an element attribute from a Field directly 43 | Then the attribute is fetched 44 | """ 45 | test_page.navigate() 46 | 47 | # The is_present method belongs to the element, not the Field directly. 48 | assert test_page.button.is_present() 49 | 50 | 51 | def test_field_getattr_should_not_exist(test_page): 52 | """ 53 | When I try to access an attribute that does not exist from a Field directly 54 | Then the attribute is not fetched 55 | """ 56 | test_page.navigate() 57 | 58 | with pytest.raises(AttributeError): 59 | assert test_page.button.foobar() 60 | 61 | 62 | def test_field_getattr_find_fails(test_page): 63 | """ 64 | When I try to access an attribute that does not exist from a Field 65 | And the element is not found 66 | Then an error is raised 67 | And the correct error message is displayed 68 | """ 69 | test_page.navigate() 70 | 71 | with pytest.raises(AttributeError) as e: 72 | test_page.missing_button.does_not_exist 73 | 74 | msg = ( 75 | 'Failed to get element attribute.' 76 | 'Could not find element with data-test-id: not_on_the_page' 77 | ) 78 | 79 | assert msg == str(e.value) 80 | 81 | 82 | def test_non_unique_field_find(test_page): 83 | """ 84 | When I try to use find() on a Field that is found multiple times 85 | on the page 86 | Then a ValueError is thrown 87 | """ 88 | test_page.navigate() 89 | with pytest.raises(ValueError) as e: 90 | test_page.purposefully_non_unique_field.find() 91 | 92 | assert "Expected one element, found multiple" == str(e.value) 93 | -------------------------------------------------------------------------------- /tests/splinter/test_page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from selenium.webdriver.remote.remote_connection import LOGGER 6 | 7 | LOGGER.setLevel(logging.WARNING) 8 | 9 | 10 | def test_page_navigate_return_value(test_page): 11 | """ 12 | When I use Page.navigate() 13 | Then the returned value from the method is the Page instance 14 | """ 15 | rv = test_page.navigate() 16 | 17 | assert rv == test_page 18 | 19 | 20 | def test_page_getattr(test_page): 21 | """ 22 | When I try to access a browser attribute from a Page directly 23 | Then the attribute is fetched 24 | """ 25 | test_page.navigate() 26 | 27 | expected = 'https://jsfehler.github.io/stere/test_page/test_page.html' 28 | # The url attribute belongs to the browser, not Page directly. 29 | assert expected == test_page.url 30 | 31 | 32 | def test_page_getattr_should_not_exist(test_page): 33 | """ 34 | When I try to access an attribute that does not exist from a Page directly 35 | Then the attribute is not fetched 36 | """ 37 | test_page.navigate() 38 | 39 | with pytest.raises(AttributeError): 40 | assert test_page.foobar() 41 | 42 | 43 | def test_page_context_manager(test_page): 44 | with test_page as t: 45 | assert t == test_page 46 | 47 | 48 | def test_page_base_url(test_page): 49 | assert test_page.base_url == 'https://jsfehler.github.io/stere/' 50 | 51 | 52 | def test_page_url_suffix(test_page): 53 | assert test_page.url_suffix == 'test_page/test_page.html' 54 | 55 | 56 | def test_page_page_url(test_page): 57 | expected = 'https://jsfehler.github.io/stere/test_page/test_page.html' 58 | assert test_page.page_url == expected 59 | -------------------------------------------------------------------------------- /tests/splinter/test_repeating.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from selenium.webdriver.remote.remote_connection import LOGGER 6 | 7 | from stere.areas import Repeating, RepeatingArea 8 | from stere.fields import Root, Text 9 | 10 | LOGGER.setLevel(logging.WARNING) 11 | 12 | 13 | def test_all_roots(test_page): 14 | test_page.navigate() 15 | r = test_page.repeating 16 | all_roots = r._all_roots() 17 | assert 2 == len(all_roots) 18 | 19 | 20 | def test_children(test_page): 21 | """Given I have a Repeating RepeatingArea 22 | Then I can search for content inside the Repeating's children. 23 | """ 24 | test_page.navigate() 25 | r = test_page.repeating 26 | children = r.children() 27 | assert 2 == len(children) 28 | 29 | first_repeating_area = children[0] 30 | assert 2 == len(first_repeating_area) 31 | assert first_repeating_area.areas.contain('text', 'Repeating Area A1') 32 | 33 | second_repeating_area = children[1] 34 | assert 2 == len(second_repeating_area) 35 | assert second_repeating_area.areas.contain('text', 'Repeating Area B1') 36 | 37 | 38 | def test_all_roots_not_found(test_page): 39 | test_page.navigate() 40 | 41 | r = Repeating( 42 | root=Root('id', 'notFound'), 43 | repeater=RepeatingArea( 44 | root=Root('id', 'alsoNotFound'), 45 | text=Text('id', 'neverFound'), 46 | ), 47 | ) 48 | 49 | with pytest.raises(ValueError) as e: 50 | r._all_roots() 51 | assert str(e.value) == ( 52 | 'Could not find any RepeatingArea using the root: ' 53 | '.repeatingRepeating', 54 | ) 55 | 56 | 57 | def test_has_children(test_page): 58 | test_page.navigate() 59 | 60 | r = test_page.repeating 61 | assert r.has_children() 62 | 63 | 64 | def test_has_children_minimum(test_page): 65 | test_page.navigate() 66 | 67 | r = test_page.repeating 68 | assert r.has_children(minimum=2) 69 | 70 | 71 | def test_repeating_plus_area(test_page): 72 | """Repeating with an Area as the repeater. 73 | Should function identically to a RepeatingArea. 74 | """ 75 | test_page.navigate() 76 | 77 | listings = test_page.repeating_with_area.children() 78 | assert listings[0].link.text == "Repeating Link 1" 79 | assert listings[1].link.text == "Repeating Link 2" 80 | 81 | 82 | def test_repeating_plus_area_with_repeatingarea(test_page): 83 | """When Repeating has an Area as the repeater 84 | And the Area has no root 85 | And the Area has a RepeatingArea 86 | Then the RepetingArea should get the root from the Repeating 87 | """ 88 | test_page.navigate() 89 | 90 | listings = test_page.repeating_area_repeatingarea.children() 91 | assert listings[0].it_repeats.areas[0].text.text == "Repeating Area A1" 92 | 93 | 94 | def test_text_to_dict_repeating(test_page): 95 | test_page.navigate() 96 | 97 | t = test_page.repeating.text_to_dict() 98 | 99 | assert t == [ 100 | [ 101 | {'link': 'Repeating Link A1', 'text': 'Repeating Area A1'}, 102 | {'link': 'Repeating Link A2', 'text': 'Repeating Area A2'}, 103 | ], 104 | [ 105 | {'link': 'Repeating Link B1', 'text': 'Repeating Area B1'}, 106 | {'link': 'Repeating Link B2', 'text': 'Repeating Area B2'}, 107 | ], 108 | ] 109 | -------------------------------------------------------------------------------- /tests/splinter/test_strategy_methods.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from selenium.webdriver.remote.remote_connection import LOGGER 4 | 5 | 6 | LOGGER.setLevel(logging.WARNING) 7 | 8 | 9 | def test_is_visible(test_page): 10 | """When I wait for something to be visible on the page 11 | Then is_visible() returns True if it becomes visible. 12 | """ 13 | test_page.navigate() 14 | assert test_page.added_container_by_id.is_visible(wait_time=10) 15 | 16 | 17 | def test_is_visible_by_xpath(test_page): 18 | test_page.navigate() 19 | assert test_page.added_container_by_xpath.is_visible(wait_time=10) 20 | 21 | 22 | def test_is_visible_by_css(test_page): 23 | test_page.navigate() 24 | assert test_page.added_container_by_css.is_visible(wait_time=10) 25 | 26 | 27 | def test_is_not_visible(test_page): 28 | test_page.navigate() 29 | assert test_page.to_hide_container_by_id.is_not_visible(wait_time=12) 30 | 31 | 32 | def test_is_not_visible_by_xpath(test_page): 33 | test_page.navigate() 34 | assert test_page.to_hide_container_by_xpath.is_not_visible(wait_time=12) 35 | 36 | 37 | def test_is_not_visible_by_css(test_page): 38 | test_page.navigate() 39 | assert test_page.to_hide_container_by_css.is_not_visible(wait_time=12) 40 | 41 | 42 | def test_is_visible_fails(test_page): 43 | """When I check if something is visible when it is not, 44 | Then it should not be found 45 | """ 46 | test_page.navigate() 47 | assert not test_page.added_container_by_id.is_visible(wait_time=1) 48 | 49 | 50 | def test_is_not_visible_fails(test_page): 51 | """When I check if something is not visible when it is, 52 | Then it should be found 53 | """ 54 | test_page.navigate() 55 | assert not test_page.removed_container_by_id.is_not_visible(wait_time=1) 56 | 57 | 58 | def test_is_present_args(test_page): 59 | """ 60 | When I send an argument to is_present 61 | Then it is used by the correct function 62 | """ 63 | test_page.navigate() 64 | assert test_page.added_container_by_id.is_present(wait_time=12) 65 | 66 | 67 | def test_is_present_repeating_area(test_page): 68 | """ 69 | Given I have a Field inside a RepeatingArea 70 | When I send an argument to is_present 71 | Then it is used by the correct function 72 | """ 73 | test_page.navigate() 74 | assert test_page.repeating_area.areas[0].link.is_present() 75 | 76 | 77 | def test_is_not_present_args(test_page): 78 | """ 79 | When I send an argument to is_not_present 80 | Then it is used by the correct function 81 | """ 82 | test_page.navigate() 83 | assert test_page.removed_container_by_id.is_not_present(wait_time=12) 84 | 85 | 86 | def test_button_data_star_strategy(browser, request, browser_name, test_page): 87 | """When I define a Field using a data-* strategy, it is found.""" 88 | test_page.navigate() 89 | test_page.button_alt_strategy.click() 90 | 91 | # Clicking changes the button's container background colour 92 | browsers = { 93 | 'firefox': 'rgb(255, 0, 0)', 94 | 'chrome': 'rgba(255, 0, 0, 1)', 95 | } 96 | 97 | # This works because value_of_css_property is gotten from splinter, 98 | # which gets it from Selenium 99 | actual = test_page.button_container.find()._element.value_of_css_property( 100 | 'background-color') 101 | 102 | assert browsers[browser_name] == actual 103 | 104 | 105 | def test_data_star_staregy_is_present(browser, test_page): 106 | test_page.navigate() 107 | assert test_page.button_alt_strategy.is_present(wait_time=3) 108 | 109 | 110 | def test_data_star_staregy_is_not_present(browser, test_page): 111 | test_page.navigate() 112 | assert test_page.missing_button.is_not_present(wait_time=3) 113 | 114 | 115 | def test_is_clickable(test_page): 116 | """ 117 | When I wait for something to be click on the page 118 | Then is_clickable() returns True if it becomes visible. 119 | """ 120 | test_page.navigate() 121 | assert test_page.button.is_clickable(wait_time=1) 122 | 123 | 124 | def test_is_clickable_element_not_found(test_page): 125 | """ 126 | When I wait for something to be click on the page 127 | Then is_clickable() returns True if it becomes visible. 128 | """ 129 | test_page.navigate() 130 | assert not test_page.missing_button.is_clickable(wait_time=1) 131 | 132 | 133 | def test_is_not_clickable(test_page): 134 | """ 135 | When I wait for something to be click on the page 136 | Then is_clickable() returns True if it becomes visible. 137 | """ 138 | test_page.navigate() 139 | assert test_page.missing_button.is_not_clickable(wait_time=1) 140 | 141 | 142 | def test_is_not_clickable_element_found(test_page): 143 | """ 144 | When I wait for something to be click on the page 145 | Then is_clickable() returns True if it becomes visible. 146 | """ 147 | test_page.navigate() 148 | assert not test_page.button.is_not_clickable(wait_time=1) 149 | -------------------------------------------------------------------------------- /tests/stere/splinter/test_element_builder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.fields.build_element import build_element 4 | 5 | 6 | def test_build_element_invalid_key(): 7 | """ 8 | When an invalid strategy is used 9 | Then a ValueError should be thrown 10 | """ 11 | strategies = [ 12 | 'css', 'xpath', 'tag', 'name', 'text', 'id', 'value', 13 | ] 14 | expected_message = ( 15 | f'The strategy "invalid_strategy" is not in {strategies}.' 16 | ) 17 | 18 | with pytest.raises(ValueError) as e: 19 | build_element("invalid_strategy", "invalid_locator") 20 | 21 | assert str(e.value) == expected_message 22 | -------------------------------------------------------------------------------- /tests/stere/splinter/test_splinter_strategies.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.fields import Field 4 | from stere.strategy.splinter import ( 5 | FindByCss, 6 | FindById, 7 | FindByName, 8 | FindByTag, 9 | FindByText, 10 | FindByValue, 11 | FindByXPath, 12 | ) 13 | from stere.strategy.strategy import strategies 14 | 15 | 16 | def test_unregistered_strategy(): 17 | """When an unregistered strategy is used 18 | Then a ValueError should be thrown 19 | """ 20 | strategies = [ 21 | 'css', 'xpath', 'tag', 'name', 'text', 'id', 'value', 22 | ] 23 | 24 | with pytest.raises(ValueError) as e: 25 | Field('fail', 'foobar') 26 | 27 | expected_message = f'The strategy "fail" is not in {strategies}.' 28 | assert expected_message == str(e.value) 29 | 30 | 31 | def test_unexpected_strategy(): 32 | """Given Stere's default splinter strategies 33 | When an unexpected strategy is found 34 | Then this test should fail 35 | """ 36 | assert strategies == { 37 | 'css': FindByCss, 38 | 'xpath': FindByXPath, 39 | 'tag': FindByTag, 40 | 'name': FindByName, 41 | 'text': FindByText, 42 | 'id': FindById, 43 | 'value': FindByValue, 44 | } 45 | 46 | 47 | def test_strategy_attribute_correct(): 48 | """The strategy attribute on each Strategy class should be correct.""" 49 | assert 'css' == FindByCss.strategy 50 | assert 'id' == FindById.strategy 51 | assert 'name' == FindByName.strategy 52 | assert 'tag' == FindByTag.strategy 53 | assert 'text' == FindByText.strategy 54 | assert 'value' == FindByValue.strategy 55 | assert 'xpath' == FindByXPath.strategy 56 | -------------------------------------------------------------------------------- /tests/stere/test_area.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.areas import Area 4 | from stere.fields import Root 5 | 6 | 7 | def test_area_non_field_kwarg(): 8 | expected_message = ( 9 | 'Areas must only be initialized with: Field, Area, Repeating types' 10 | ) 11 | 12 | with pytest.raises(ValueError) as e: 13 | Area( 14 | root=Root('css', '.test_repeating_area_root'), 15 | link="Foobar", 16 | ) 17 | 18 | assert str(e.value) == expected_message 19 | 20 | 21 | def test_area_set_workflow(): 22 | area = Area( 23 | root=Root('css', '.test_repeating_area_root'), 24 | ) 25 | 26 | area.workflow('Foobar') 27 | assert 'Foobar' == area._workflow 28 | -------------------------------------------------------------------------------- /tests/stere/test_areas.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.areas import Area, Areas 4 | 5 | 6 | def test_areas_append_wrong_type(): 7 | """Ensure a TypeError is raised when non-Area objects are appended 8 | to an Areas. 9 | """ 10 | a = Areas() 11 | with pytest.raises(TypeError) as e: 12 | a.append('1') 13 | 14 | assert str(e.value) == ( 15 | '1 is not an Area. Only Area objects can be inside Areas.' 16 | ) 17 | 18 | 19 | def test_areas_append(): 20 | """Ensure Area objects can be appended to an Areas.""" 21 | a = Areas() 22 | 23 | area = Area() 24 | a.append(area) 25 | 26 | assert 1 == len(a) 27 | 28 | 29 | def test_areas_remove(): 30 | """Ensure Areas.remove() behaves like list.remove().""" 31 | a = Areas() 32 | 33 | area = Area() 34 | a.append(area) 35 | a.remove(area) 36 | 37 | assert 0 == len(a) 38 | 39 | 40 | def test_areas_len(): 41 | """Ensure Areas reports length correctly.""" 42 | a = Areas(['1', '2', '3']) 43 | assert 3 == len(a) 44 | -------------------------------------------------------------------------------- /tests/stere/test_browser_enabled.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.browserenabled import BrowserEnabled 4 | 5 | 6 | @pytest.fixture 7 | def browser_enabled(): 8 | return BrowserEnabled() 9 | 10 | 11 | def test_browserenabled_default_browser(browser_enabled): 12 | """Given stere has not been configured at all 13 | Then the browser attribute should be None. 14 | """ 15 | assert browser_enabled.browser is None 16 | 17 | 18 | def test_browserenabled_base_url(browser_enabled): 19 | """Given stere has not been configured at all 20 | Then the url_suffix attribute should be None. 21 | """ 22 | assert browser_enabled.base_url == '' 23 | 24 | 25 | def test_browserenabled_url_suffix(browser_enabled): 26 | """Given stere has not been configured at all 27 | Then the url_suffix attribute should be None. 28 | """ 29 | assert browser_enabled.url_suffix == '' 30 | 31 | 32 | def test_browserenabled_default_url_navigator(browser_enabled): 33 | """Given stere has not been configured at all 34 | Then the url_navigator attribute should be the default for splinter. 35 | """ 36 | assert browser_enabled.url_navigator == 'visit' 37 | 38 | 39 | def test_browserenabled_default_retry_time(browser_enabled): 40 | """Given stere has not been configured at all 41 | Then the retry_time attribute should have a default value of 5. 42 | """ 43 | assert browser_enabled.retry_time == 5 44 | -------------------------------------------------------------------------------- /tests/stere/test_element_strategy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.strategy.element_strategy import ElementStrategy 4 | 5 | 6 | def test_element_strategy_find(): 7 | elem = ElementStrategy('dummy', '//fake') 8 | 9 | with pytest.raises(NotImplementedError): 10 | elem._find_all() 11 | -------------------------------------------------------------------------------- /tests/stere/test_event_emitter.py: -------------------------------------------------------------------------------- 1 | import types 2 | 3 | import pytest 4 | 5 | from stere.event_emitter import EventEmitter 6 | 7 | 8 | def test_event_emitter_register(): 9 | """When I register a new event with the on() method 10 | Then the return value is a list of all the data for the event. 11 | """ 12 | emitter = EventEmitter() 13 | 14 | def event_function(instance): 15 | pass 16 | 17 | listeners = emitter.on('dummy_event', event_function) 18 | 19 | assert listeners == [{'listener': event_function}] 20 | 21 | 22 | def test_event_emitter_events(): 23 | """When I register a new event 24 | Then the event is added to the EventEmitter's list of known events 25 | """ 26 | emitter = EventEmitter() 27 | 28 | def event_function(instance): 29 | pass 30 | 31 | emitter.on('dummy_event', event_function) 32 | 33 | assert emitter.events == ['dummy_event'] 34 | 35 | emitter.on('another_dummy_event', event_function) 36 | 37 | assert emitter.events == ['dummy_event', 'another_dummy_event'] 38 | 39 | 40 | def test_event_emitter_instance(): 41 | """When I register a new event 42 | And emit the new event 43 | Then the listener function is called 44 | And the listener's argument is the instance linked to the EventEmitter 45 | """ 46 | emitter = EventEmitter() 47 | 48 | x = types.SimpleNamespace() 49 | x.event_called = False 50 | 51 | def event_function(instance): 52 | assert isinstance(instance, EventEmitter) 53 | x.event_called = True 54 | 55 | emitter.on('dummy_event', event_function) 56 | 57 | emitter.emit('dummy_event') 58 | 59 | assert x.event_called 60 | 61 | 62 | def test_event_emitter_invalid_event(): 63 | """When I register a new event 64 | And emit the new event 65 | Then the listener function is called 66 | And the listener's argument is the instance linked to the EventEmitter 67 | """ 68 | emitter = EventEmitter() 69 | 70 | def event_function(instance): 71 | assert isinstance(instance, EventEmitter) 72 | 73 | emitter.on('dummy_event', event_function) 74 | 75 | with pytest.raises(ValueError) as e: 76 | emitter.emit('invalid_event') 77 | 78 | assert str(e.value) == 'invalid_event is not a registered Event.' 79 | -------------------------------------------------------------------------------- /tests/stere/test_event_emitter_integration.py: -------------------------------------------------------------------------------- 1 | import types 2 | 3 | from stere.fields import Field 4 | from stere.fields.decorators import use_after, use_before 5 | 6 | 7 | class MockField(Field): 8 | @use_before 9 | @use_after 10 | def click(self): 11 | """Dummy method for EventEmitter testing.""" 12 | pass 13 | 14 | 15 | class MockPage: 16 | def __init__(self): 17 | self.field = MockField('xpath', '//div') 18 | 19 | self.dummy_namespace = types.SimpleNamespace() 20 | self.dummy_namespace.after_event_called = False 21 | self.dummy_namespace.before_event_called = False 22 | 23 | def event_function_after(instance): 24 | """Dummy event function.""" 25 | self.dummy_namespace.after_event_called = True 26 | 27 | def event_function_before(instance): 28 | """Dummy event function.""" 29 | self.dummy_namespace.before_event_called = True 30 | 31 | self.field.on('after', event_function_after) 32 | self.field.on('before', event_function_before) 33 | 34 | 35 | def test_event_emitter_field_events(): 36 | """When a performer method is called 37 | Then the correct events should be emitted. 38 | """ 39 | mock_page = MockPage() 40 | 41 | mock_page.field.click() 42 | 43 | assert mock_page.dummy_namespace.after_event_called 44 | assert mock_page.dummy_namespace.before_event_called 45 | -------------------------------------------------------------------------------- /tests/stere/test_field.py: -------------------------------------------------------------------------------- 1 | from stere.fields import Field 2 | 3 | 4 | def test_field_repr(): 5 | """Fields should have a useful __repr__ method.""" 6 | field = Field('id', 'foobar') 7 | 8 | assert "Field - Strategy: id, Locator: foobar" == str(field) 9 | 10 | 11 | def test_field_empty_perform(): 12 | """The default implementation of Field.perform() should return None.""" 13 | f = Field('id', 'foobar') 14 | assert f.perform() is None 15 | 16 | 17 | def test_call(): 18 | """When a Field instance is called 19 | Then the Field's perform method is executed 20 | """ 21 | f = Field('id', 'foobar') 22 | assert f() is None 23 | -------------------------------------------------------------------------------- /tests/stere/test_repeating.py: -------------------------------------------------------------------------------- 1 | from stere.areas import Repeating, RepeatingArea 2 | from stere.fields import Field, Root 3 | 4 | 5 | def test_repeater_name(): 6 | """The repeater_name attribute should be the class name of the repeater.""" 7 | repeating = Repeating( 8 | root=Root('css', '.repeatingRepeating'), 9 | repeater=RepeatingArea( 10 | root=Root('css', '.test_repeating_area_root'), 11 | link=Field('xpath', './/a'), 12 | ), 13 | ) 14 | 15 | assert type(repeating.repeater).__name__ == repeating.repeater_name 16 | -------------------------------------------------------------------------------- /tests/stere/test_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from stere.utils import _retry 4 | 5 | 6 | def test_retry(): 7 | """When I call _retry 8 | Then a function is called until it returns a True value 9 | """ 10 | now = time.time() 11 | 12 | result = _retry( 13 | lambda: True if time.time() >= (now + 6) else False, 14 | retry_time=8, 15 | ) 16 | 17 | assert result 18 | 19 | 20 | def test_retry_fails(): 21 | """When I call _retry 22 | And the timeout is hit 23 | Then it returns False 24 | """ 25 | now = time.time() 26 | 27 | result = _retry( 28 | lambda: True if time.time() == (now + 6) else False, 29 | retry_time=4, 30 | ) 31 | 32 | assert not result 33 | -------------------------------------------------------------------------------- /tests/stere/test_value_comparator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stere.value_comparator import ValueComparator 4 | 5 | 6 | def test_value_comparator_str(): 7 | a = ValueComparator(True, "A", "A") 8 | 9 | assert str(a) == "True. Expected: A, Actual: A" 10 | 11 | 12 | def test_value_comparator_eq(): 13 | a = ValueComparator(True, "A", "A") 14 | 15 | b = True 16 | 17 | assert b == a 18 | 19 | 20 | def test_value_comparator_ne(): 21 | a = ValueComparator(False, "A", "AAA") 22 | 23 | assert True != a # NOQA E712 24 | 25 | 26 | def test_value_comparator_error_msg(): 27 | a = ValueComparator(False, 100, 101) 28 | 29 | with pytest.raises(AssertionError) as e: 30 | assert True == a # NOQA E712 31 | 32 | result = str(e.value) 33 | expected = "assert True == False. Expected: 100, Actual: 101" 34 | assert expected == result 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | skip_if_browser 4 | 5 | [flake8] 6 | ignore = D100,D101,D104,D107,D205,D400,D401,D412 7 | per-file-ignores = 8 | tests/*:D102,D103 9 | 10 | [coverage:paths] 11 | source = 12 | stere/ 13 | */.tox/*/lib/python*/site-packages/stere 14 | 15 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 16 | [tox] 17 | envlist = 18 | {py38,py39,py310,py311}-stere, 19 | {py38,py39,py310,py311}-splinter, 20 | {py38,py39,py310,py311}-appium, 21 | flake8 22 | 23 | # Tests for basic Stere config 24 | [testenv:{py38, py39, py310, py311}-stere] 25 | package=wheel 26 | deps = -rrequirements/tests.txt 27 | commands = 28 | py.test -s -vv {posargs} --cov={envsitepackagesdir}/stere --cov-append tests/config 29 | py.test -s -vv {posargs} --cov={envsitepackagesdir}/stere --cov-append tests/stere 30 | 31 | # Tests for splinter implementation 32 | [testenv:{py38, py39, py310, py311}-splinter] 33 | package=wheel 34 | passenv = USE_SAUCE_LABS,GITHUB_RUN_ID 35 | deps = -rrequirements/tests.txt 36 | commands = 37 | py.test -s -vv -n 4 {posargs} --splinter-remote-name=firefox --cov={envsitepackagesdir}/stere --cov-append tests/splinter 38 | py.test -s -vv -n 4 {posargs} --splinter-remote-name=chrome --cov={envsitepackagesdir}/stere --cov-append tests/splinter 39 | 40 | # Tests for appium implementation 41 | [testenv:{py38, py39, py310, py311}-appium] 42 | package=wheel 43 | passenv = USE_SAUCE_LABS,GITHUB_RUN_ID 44 | deps = -rrequirements/tests.txt 45 | commands = 46 | py.test -s -vv {posargs} --browser-name=ios --cov={envsitepackagesdir}/stere --cov-append tests/appium 47 | 48 | # Lint code style 49 | [testenv:lint] 50 | skip_install = true 51 | deps = -rrequirements/lint.txt 52 | changedir = . 53 | commands = flake8 stere tests scripts 54 | --------------------------------------------------------------------------------