diff --git a/.github/workflows/build-and-validate.yml b/.github/workflows/build-and-validate.yml index ff33978..4b23433 100644 --- a/.github/workflows/build-and-validate.yml +++ b/.github/workflows/build-and-validate.yml @@ -11,15 +11,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - uses: abatilo/actions-poetry@v2.0.0 - - uses: nanasess/setup-chromedriver@v1.0.5 - - run: poetry install - - run: poetry run tox + - uses: abatilo/actions-poetry@v2.1.4 + - uses: nanasess/setup-chromedriver@v1 + - run: poetry install --no-interaction + - run: ./validate-strict.sh + id: tox - if: always() + id: check-artifacts + run: | + ls -alh + if [[ -f "./webdriver-report/report.json" ]] + then + echo "::set-output name=upload-webdriver-report::true" + fi + if [[ -f "./htmlcov/index.html" ]] + then + echo "::set-output name=upload-coverage-report::true" + fi + - if: always() && steps.check-artifacts.outputs.upload-webdriver-report + uses: actions/upload-artifact@v2 + with: + name: web test storyboards for for ${{ github.sha }} + path: ./webdriver-report + - if: always() && steps.check-artifacts.outputs.upload-coverage-report uses: actions/upload-artifact@v2 with: - name: Test reports for ${{ github.sha }} - path: ./webdriver-report/index.html + name: coverage report for ${{ github.sha }} + path: ./htmlcov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74b37dd..6b3a10d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: from a ${{ github.event_name }} at <${{ env.commit_url }} | commit ${{ steps.configure.outputs.short-sha }}> - - run: poetry run tox + - run: ./validate-strict.sh id: run-tests - if: always() diff --git a/.gitignore b/.gitignore index 825a13d..6582d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ webdriver-report/ +.docker # Byte-compiled / optimized / DLL files __pycache__/ @@ -74,4 +75,3 @@ target/ # pyenv .python-version - diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b2b9468..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python -python: ["3.6"] -addons: - chrome: stable -install: - - CHROMEDRIVER_DIR=/usr/local/bin CHROMEDRIVER_DIST=linux64 sudo ./bootstrap_chromedriver.sh - - pip install -U pip setuptools tox - - git describe --tags > webdriver_recorder/VERSION -script: - - tox # This runs tests, runs flake8, and generates a coverage report both in html and in the terminal. -deploy: - skip_cleanup: true - provider: script - script: pip install twine && python setup.py sdist && twine upload dist/* - on: - tags: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6cbbd64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM ghcr.io/uwit-iam/poetry:latest AS env-base +RUN apt-get update && apt-get install -y curl jq + +FROM env-base AS poetry-base +WORKDIR /webdriver +COPY pyproject.toml poetry.lock ./ +RUN poetry install --no-root --no-interaction + +FROM poetry-base as webdriver-source +WORKDIR /webdriver +COPY ./webdriver_recorder ./webdriver_recorder +ENV PYTHONPATH="/webdriver" +COPY ./entrypoint.sh ./ +ENTRYPOINT ["/webdriver/entrypoint.sh"] + +FROM poetry-base AS webdriver-native +WORKDIR /webdriver +COPY ./webdriver_recorder ./webdriver_recorder +COPY ./entrypoint.sh ./ +RUN poetry install --no-interaction && rm pyproject.toml poetry.lock diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 3ca95bd..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include webdriver_recorder/report.template.html -inlcude webdriver_recorder/VERSION diff --git a/README.md b/README.md index cc37ccc..89c1a71 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,68 @@ version of webdriver-recorder to `<4.0.0` ``` # Using pip -pip install 'uw-webdriver-recorder>=4.0.0,<5.0.0' +pip install 'uw-webdriver-recorder' # Using poetry -poetry add 'uw-webdriver-recorder>=4.0.0,<5.0.0' +poetry add 'uw-webdriver-recorder' ``` ## Requirements -`chromedriver` must be discoverable on your test environment PATH. See [Google's -documentation](https://chromedriver.chromium.org/). +The following table illustrates the compatible versions between +this (webdriver-recorder), python, and selenium: + +| webdriver-recorder version | python version(s) | selenium version(s) +| --- | --- | --- | +| <4.0 | 3+ | <=3.141.59 +| 4.0 | 3.6+ | <=3.141.59 | +| 5.0+ | 3.7+ | \>=4.1 | + + +If running without docker, `chromedriver` must be +discoverable on your test environment PATH. +See [Google's documentation](https://chromedriver.chromium.org/). + +For convenience, you can use `./bootstrap_chromedriver.sh`. + +## Running the examples with docker-compose + +The provided [docker-compose.yml](docker-compose.yml) should work out of the box to +run simple tests. + +The following code should work as-is (note: the result should be a failure!): + +```bash +test_dir=$(pwd)/examples docker-compose up --build --exit-code-from test-runner +``` + +After, you can view the results by opening `./webdriver-report/index.html` in your +browser of choice. + +Note: if you are doing this for the first time, the initial build may take a couple +of minutes. + +If using the provided [docker-compose.yml](docker-compose.yml) to run +tests, you can change the number of nodes of your selenium grid by editing the +`SE_NODE_MAX_SESSIONS` environment variable. This handle is provided by +the Selenium maintainers. + ## Pytest Arguments ### `--report-dir` +Also as environment variable: `REPORT_DIR` + (Optional). If provided, will override the default (`./webdriver-report`). This is the directory where worker locks and report artifacts will be stored. -Your report will be saved here as `index.html`. +Your report will be saved here as `index.html` and `report.json`. ### `--jinja-template` (Optional). If provided, will override the default included with this package. -This must be the absolute path to your report template. +This must be the absolute path to your report template. For more information +on creating or updating templates, see [docs/templating](docs/templating.md). ### `--report-title` @@ -42,9 +81,9 @@ test fixture. See [report_title](#report_title) ### `--selenium-server` -(Optional). Defaults to the `SELENIUM_SERVER` environment variable value, which may -be blank. If not blank, a `Remote` instance will be created instead that will -connect to the server provided. +(Optional). Defaults to the `REMOTE_SELENIUM` environment variable value, +which may be blank. If provided, a `Remote` instance +will be created instead that will connect to the server provided. ## Browser/WebDriver Fixtures @@ -52,13 +91,26 @@ This plugin makes several fixtures available to you: ### `session_browser` -A session-scoped browser instance. +A session-scoped browser instance. By default, this is always invoked, +which may pose runtime errors (like stuck tests) if you have a constrained +selenium grid. You can disable this default behavior by +setting `disable_session_browser=1` in your environment. + +Note that you may still invoke the session_browser fixture with this option, +but it will not automatically be used. ``` def test_a_thing(session_browser): session_browser.get('https://www.example.com') + +def test_another_thing(session_browser): + # The page remains loaded from the previous test. + assert session_browser.wait_for_tag('h1', 'welcome to example.com') ``` + +See also [browser](#browser). + ### `class_browser` A class-scoped browser instance that preserves the state for @@ -66,34 +118,57 @@ the entire test class. The `class_browser` will always be open to a new, clean tab, which will be closed when all tests in the class have run. + +If you run with `disable_session_browser`, the class_browser will be a fresh +instance of the browser for each class where it is used. + ``` @pytest.mark.usefixtures('class_browser') class TestCollection: + @pytest.fixture(autouse=True) + def initialize_collection(class_browser): + self.browser = class_browser + def test_a_thing(self): self.browser.get('https://www.example.com') + + def test_another_thing(self): + assert self.browser.wait_for_tag('h1', 'welcome to example.com') ``` ### `browser` -A function-scoped browser instance. Each test function -that uses this fixture will have a fresh, clean tab, which -will be closed when the test function has completed. +A function-scoped browser tab that automatically cleans up after itself before the +tab is closed by deleting browser cookies from its last visited domain. + +If running with `disable_session_browser`, a new instance will be created for each +browser instead. This has significant performance impacts\*, but also guarantees the +"cleanest" browser experience. + +\* see [Performance](#performance) ### `browser_context` If you do not want to use one of the above scopes, you can use the `browser_context` fixture directly, which -creates and cleans up a tab for your scope. +creates and cleans up a tab for the browser instance you provide. When the scope exits, the tab's cookies are deleted, and the tab is closed. You can optionally supply a list of additional urls to visit and clear cookies using -the `cookie_urls` parameter. The default behavior (from Selenium) is to only delete -the cookies of the _current_ domain. +the `cookie_urls` parameter. (The default browser behavior is to only delete +the cookies of the _current_ domain.) ``` -def test_a_thing(browser_context): - with browser_context(cookie_urls=['https://www.example.com/']) as browser: +def test_a_thing(browser_context, chrome_options): + # Let's create a custom instance of Chrome + options.add_argument('--hide-scrollbars') + browser = Chrome(options=chrome_options) + + with browser_context( + browser, + cookie_urls=['https://www.example.com/'] + ) as browser: browser.get('https://www.example.com/') browser.add_cookie({'name': 'foo', 'value': 'bar'}) browser.get('https://www.uw.edu/') @@ -105,17 +180,6 @@ def test_a_thing(browser_context): assert not browser.get_all_cookies() ``` -Calling `browser_context()` without arguments will use the `session_browser` -by default; you may pass in your own instance if you choose to. - -``` -def test_a_thing(browser_context): - fresh = webdriver_recorder.Chrome() - with browser_context(fresh) as superfresh: - superfresh.get('https://www.example.com') -``` - - ## Settings Fixtures You can fine-tune certain configuration by overriding fixtures. @@ -142,8 +206,8 @@ def chrome_options(chrome_options): ### `report_title` Use this to change the title of your report. This is a better option -than the pytest argument (above) for cases where the title isn't -expected to change much. +than the pytest argument (above) for cases where you want to +programmatically assemble the title during test setup. ``` @pytest.fixture(scope='session') @@ -157,38 +221,25 @@ def report_title(): ### First-time developer setup - Install [poetry](https://python-poetry.org) (if not already done) +- `poetry env use /path/to/python3.7+` - `poetry install` -- `poetry env use /path/to/python3.6+` +- Run `./bootstrap_chromedriver.sh` -- doing this _after_ poetry setup will + automatically install to your poetry environment. It is **highly recommended** that you use a [pyenv](https://github.com/pyenv/pyenv) version, e.g.: -`poetry env use ~/.pyenv/versions/3.7.7/bin/python` - -- Set your chromedriver directory: - `export CHROMEDRIVER_DIR="$(poetry env list --full-path | cut -f1 -d' ')/bin"` -- Bootstrap chromedriver: - - On **MacOS**: `CHROMEDRIVER_DIST=mac64 ./bootstrap_chromedriver.sh` - - On **Linux**: `./bootstrap_chromedriver.sh` - - On **Windows**: _Feel free to submit a pull request!_ - -If your system has chrome installed somewhere undiscoverable, you can explicitly provide the correct path by -setting the CHROME_BIN environment variable: - -``` -CHROME_BIN="/path/to/google-chrome-stable" pytest -``` +`poetry env use ~/.pyenv/versions/3.8.8/bin/python` ### Periodic Setup #### Updating Chromedriver -Once in a while you will need to re-run the -`Set your chromedriver directory` and `Bootstrap chromedriver` -steps above, because chromedriver will fall out of date -with the Chrome binary. +- When? If you see a message that chromedriver is out of date +- What do I do? `./bootstrap_chromedriver.sh` #### Patch dependencies -`poetry update && poetry lock && poetry run tox` +- When? Whenever you need the latest release of something +- What do I do? `poetry update && poetry lock && poetry run tox` ### Releasing Changes @@ -207,7 +258,7 @@ If the dry run succeeded, validate the generated version number is what you expe and, if so, repeat steps 1–3 above, but change the `dry-run` option to `false`. **This means you can create prereleases for any branch you're working on to test it -with another package, before merging into `master`!** +with another package, before merging into `main`!** #### Manual Release @@ -220,20 +271,31 @@ Release changes using poetry: - `poetry update` - `poetry lock` - `poetry version [patch|minor|major|prerelease]` -- `tox` +- `poetry run tox` - `poetry publish --build` - username: `uw-it-iam` - - password: Ask @tomthorogood! + - password: Ask @tomthorogood! (goodtom@uw.edu) ### Testing Changes `poetry run tox` (or simply `tox` if you are already in the `poetry shell`) -### Submitting Pull Requests -- (Recommended) Run [black](https://github.com/psf/black) -- this will - be automated in the future: `black webdriver_recorder/*.py tests/*.py` -- Run validations before submitting using `tox`; this will prevent unnecessary churn in your pull request. +### Submitting Pull Requests +- Run validations before submitting using `poetry run tox`; this will prevent +unnecessary churn in your pull request. [release workflow ui]: https://github.com/UWIT-IAM/webdriver-recorder/actions/workflows/release.yml + +## Performance + +Creating browser instances is very inefficient. It is recommended that you +use the default behavior that configures a single browser instance +to use for all tests, that comes with an auto-managed context +for the `browser` fixture. + +In our own `tox` tests, you can observe the performance impact +directly. The `disable_session_browser` tox environment typically +takes more than double the amount of time to run than the same tests +using the default behavior. diff --git a/bootstrap_chromedriver.sh b/bootstrap_chromedriver.sh index 197bcdb..b3b3212 100755 --- a/bootstrap_chromedriver.sh +++ b/bootstrap_chromedriver.sh @@ -16,19 +16,130 @@ # To install in /usr/local/bin on linux (a VM/container use case): # CHROMEDRIVER_DIR=/usr/local/bin CHROMEDRIVER_DIST=linux64 sudo ./bootstrap_chromedriver.sh +function print_help { + cat < /dev/null + then + echo "No --dest provided, and no poetry installation detected." + return 1 + fi + path=$(poetry env list --full-path | tail -n 1 | cut -f1 -d' ') + if [[ "$?" != "0" ]] || [[ -z "${path}" ]] + then + echo "No --dest provided, and no poetry environment exists." + return 1 + fi + echo "Installing to poetry environment." + dest="${path}/bin" + fi + if ! [[ -d "${dest}" ]] + then + echo "Creating destination: ${dest}" + mkdir -p ${dest} + fi +} + +function configure_dist { + if [[ -z "${dist}" ]] + then + if [[ "$(uname)" == "Darwin" ]] + then + dist=mac64 + else + dist=linux64 + fi + fi +} + +function chromedriver_version { + if [[ -z "${CHROMEDRIVER_VERSION}" ]] + then + export CHROMEDRIVER_VERSION=$(curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE) + fi + echo "${CHROMEDRIVER_VERSION}" +} + +function chromedriver_url { + local version="$(chromedriver_version)" + local filename="chromedriver_${dist}.zip" + echo "https://chromedriver.storage.googleapis.com/${version}/${filename}" +} + + +function install_chromedriver { + local dest_filename="${dest}/chromedriver" + local tmp_filename="/tmp/chromedriver.zip" + + echo "Installing chromedriver $(chromedriver_version) for ${dist} to ${dest}" + curl -s "$(chromedriver_url)" > "${tmp_filename}" + if rm "${dest_filename}" > /dev/null + then + echo "Removed previous installation of chromedriver from destination" + fi + unzip "${tmp_filename}" -d "${dest}" > /dev/null + chmod 755 "${dest_filename}" + rm "${tmp_filename}" +} + + set -e -CHROMEDRIVER_DIR=${CHROMEDRIVER_DIR:-env/bin} -CHROMEDRIVER_DIST=${CHROMEDRIVER_DIST:-linux64} -export CHROMEDRIVER_BIN="${CHROMEDRIVER_DIR}/chromedriver" -export CHROMEDRIVER_VERSION=$(curl https://chromedriver.storage.googleapis.com/LATEST_RELEASE) -CHROMEDRIVER_URL="https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_${CHROMEDRIVER_DIST}.zip" - -# Create the destination dir if it does not already exist, and remove any existing chromedriver binaries -test -d "${CHROMEDRIVER_DIR}" || mkdir -p "${CHROMEDRIVER_DIR}" -test -f "${CHROMEDRIVER_BIN}" && rm "${CHROMEDRIVER_BIN}" - -echo "Installing chromedriver ${CHROMEDRIVER_VERSION} for ${CHROMEDRIVER_DIST} to ${CHROMEDRIVER_DIR}" -curl "${CHROMEDRIVER_URL}" > /tmp/chromedriver.zip -unzip /tmp/chromedriver.zip -d "${CHROMEDRIVER_DIR}" -chmod 755 "${CHROMEDRIVER_DIR}/chromedriver" -export PATH="${PATH}:${CHROMEDRIVER_DIR}" +parse_args $@ +configure_dest +configure_dist +install_chromedriver diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d855697 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# You can use this composition to run tests +# backed by webdriver-recorder. This is helpful if your +# test suite is simple and has no additional dependencies. +# TEST_DIR=/path/to/your/tests docker-compose up --build. +version: '3.1' +services: + selenium: + image: selenium/standalone-chrome:4.1 + environment: + SE_NODE_MAX_SESSIONS: 2 # Allows up to two concurrent browser instances to use + # the node. + START_XFVB: "false" # Prevents some expensive overhead we don't need + ports: + - "4444:4444" # This is the port where you can access the selenium + # dashboard + - "7900:7900" + logging: + driver: "none" + volumes: + # We want for test files to available on the selenium + # container because tests may have files the test browser + # needs to serve. + - ${test_dir}:/tests + + test-runner: + build: + dockerfile: Dockerfile + target: webdriver-source + context: . + environment: + REMOTE_SELENIUM: selenium:4444 + TZ: America/Los_Angeles + test_dir: ${test_dir} + REPORT_DIR: /webdriver-report + COVERAGE_FILE: /coverage/.coverage + disable_session_browser: ${disable_session_browser} + pytest_args: ${pytest_args} + pytest_log_level: ${pytest_log_level} + volumes: + - ./webdriver-report:/webdriver-report + - ./coverage:/coverage + - ${test_dir}:/tests + depends_on: + - selenium + entrypoint: ./entrypoint.sh + command: > + coverage run -a --source=webdriver_recorder + -m pytest + --tb short + -p 'webdriver_recorder.plugin' + -o log_cli=true -o log_cli_level=${pytest_log_level:-error} + ${pytest_args:-/tests} diff --git a/docs/migrating-to-5.0.md b/docs/migrating-to-5.0.md new file mode 100644 index 0000000..739da36 --- /dev/null +++ b/docs/migrating-to-5.0.md @@ -0,0 +1,117 @@ +# Migrating to Webdriver Recorder 5.0 + +This guide is for people who maintain packages that depend on +Webdriver Recorder 4.0 and want to upgrade to this latest +version of Webdriver Recorder. + +Webdriver Recorder 5.0 integrates with Selenium 4, in addition to providing +first-class support for running inside Docker containers and some +streamlined environment configuration. + +Many efforts were made to keep the interfaces the same +between the two versions, however, 5.0 is not strictly backwards compatible, +so dependents who wish to upgrade should follow this guide. The high-level migration +checklist below should suffice for most dependents. + + +## High-level Migration Checklist + +- [ ] Remove empty uses of `with browser_context()`; now, this always requires + an argument: the browser with which you wish to create a new context. + Instead, use: `with browser_context(browser)` +- [ ] Rename `SearchMethod` with `By`, which even more closely apes Selenium. +- [ ] Replace `click_button(..., wait=False)` with `click_button(..., timeout=0)`. + This is not specific to `click_button` but applies to all `wait_for` and + `click_` methods provided by the `BrowserRecorder` class +- [ ] Ensure any artifacts you upload now upload the entire output directory, + not just the `index.html`; the new template results in static asset generation, + instead of embedding all assets directly into the html file. +- [ ] If your test suite needs to create a new browser instance for each test (not + recommended for performance reasons), you must set the `DISABLE_SESSION_BROWSER=true` + environment variable. + +## What changed? + +### Bugfixes + +- Screenshots no longer grow by 120px for each successive test within a given test case +- Errors during test setup are now more likely to show up on the report artifact +- Timeouts are now consistently applied + +### New template features + +- The generated template now saves images separately, instead of embedding them into + the HTML. This results in a drastic reduction in load time and file size. +- Uses [Twitter bootstrap 5.0](https://www.getbootstrap.com) for styling and + components, to provide an easier to use experience. +- Test results are now collapsed directly under the test name, making it easier + to navigate to, link to, compare, and view test results. +- The report now has an option to only view failed tests, to make it easier to debug + failures +- Python errors and browser console errors are now displayed in modals, as they are + often unhelpful in debugging, and can be confusing +- Screenshots are more easily viewed, and can be linked to +- Captions whose screenshots are marked with `is_error=True` will show up in red. +- The HTML report directly links to the JSON report + + +### New library features + +- Updates Selenium dependencies from 3.141.x to 4.0.x +- Generated screenshots are SHA-256 fingerprinted, meaning two screenshots with the + same contents will have the same filename. This results in a reduction of overall + artifact size, as we no longer save duplicate images. +- Reports are saved not only as HTML but also JSON. +- `click_on`, `get`, and `wait_for` events now automatically caption the resulting + screenshot, if no caption is provided. +- You can explicitly caption any of the above methods, as well as `snap()` by + providing a `caption` keyword argument: + `browser.get('https://www.uw.edu', caption="Load the UW home page")`. +- You may now provide the `is_error: bool` argument when calling `snap()`, + which sets a flag on the image metadata and can be used in templating +- Directly linking to a test case will auto-expand its storyboard +- A new "Help" modal describes other front-end features. +- Timeouts are much easier to trace and understand now. If `timeout=None`, the + browser default (5s) will be used; you can change this by setting the `default_wait` + argument when creating a browser instance. If `timeout=0`, there will be no delay. + While this was the _intended_ behavior before, there were a few different + implementations in the package, making it hard to tell what should be happening. +- Calling `click` now returns the element that was clicked; this cascades to any other + `click_*` methods provided by the `BrowserRecorder` class. +- The `Chrome` and `Remote` classes no longer house any additional configuration, + meaning they both simply inherit from `BrowserRecorder`, + making it easier to use other browser types natively when desired. +- The `DISABLE_SESSION_BROWSER` environment variable is now the best and only way to + instantiate a new browser for every test; this is not recommended and is therefore + not the default. +- Default `chrome_options` have been updated to work more predictably with Selenium 4. +- The `build_browser` fixture now will create new browser instances based on + configured defaults. +- The `report_test` fixture now handles errors better, and can now provide some + limited failure information in the report for tests who were unable to start. + (Previously, tests that couldn't start were not present in the report artifact.) +- When a worker completes, the report of its tests are dumped into JSON; the root worker + will consume other worker results and aggregate them into a single report when all + tests are complete. +- Dropped support for the `wait` argument; use `timeout=0` instead. +- `BrowserErrors` now swallow the exception chains, making output easier to read + when things go wrong. + +### Better Building and Testing of this library + +- `./bootstrap_chromedriver.sh` will now attempt to automatically detect a valid + destination and distribution for the chromedriver, making it easier for developers + to set up and get going with this package. +- `poetry run tox -e bootstrap` will set up your poetry environment and update + chromedriver. Poetry must already be installed. +- Automated tests in Github Actions will now directly host the coverage report + and browser report. + [See Example](https://github.com/UWIT-IAM/webdriver-recorder/actions/runs/1627465302) +- `poetry run tox` will now: + - Test with and without a single-browser-instance session + - Test with and without a docker container + - Blacken all code +- `./validate-strict.sh` will run `tox` with a slightly different build variant that + does not re-format your code, but will error if it is not properly formatted. + (This is used in CI/CD for this package). Additionally, this method will skip some + environment setup, assuming a clean environment. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..2a5805b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +cmd="$@" + +function get_selenium_status { + resp=$(curl -sSL "http://selenium:4444/wd/hub/status") + if [[ "$?" -gt 0 ]] + then + return 1 + fi + ready=$(echo "$resp" | jq -r '.value.ready') + test "${ready}" == "true" +} + + +while ! get_selenium_status +do + echo "Waiting for the grid..." + sleep 2 +done + +>&2 echo "Selenium Grid is up - executing tests" +set -e +exec $cmd diff --git a/examples/test_examples.py b/examples/test_examples.py new file mode 100644 index 0000000..b1e68f4 --- /dev/null +++ b/examples/test_examples.py @@ -0,0 +1,26 @@ +import pytest +from webdriver_recorder.browser import By, Locator + + +class Locators: + about_the_uw = Locator(search_method=By.ID, search_value='about-the-uw') + + +def test_visit_uw(browser): + """ + Visits the UW home page. Note that if the home page changes, + this test might fail! That's OK for this example. + """ + browser.get('https://www.uw.edu', snap=True, caption="Be boundless.") + browser.click_tag('a', 'About') + browser.wait_for_tag('h2', 'About the UW', caption="🐺") + + +def test_force_failure(browser): + browser.get('https://directory.uw.edu', snap=True) + if not browser.find_elements(By.ID, 'does-not-exist'): + browser.snap( + caption="Manual error capture, but the test continues.", + is_error=True + ) + browser.wait_for(Locator(search_method=By.ID, search_value="does-not-exist")) diff --git a/poetry.lock b/poetry.lock index 89c1737..3fb7925 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,10 @@ [[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +category = "main" optional = false -python-versions = "*" +python-versions = ">=3.5" [[package]] name = "atomicwrites" @@ -23,10 +23,83 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] + +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.1" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + +[[package]] +name = "black" +version = "21.12b0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" +tomli = ">=0.2.6,<2.0.0" +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -44,12 +117,34 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +[package.dependencies] +toml = {version = "*", optional = true, markers = "extra == \"toml\""} + [package.extras] toml = ["toml"] +[[package]] +name = "cryptography" +version = "36.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + [[package]] name = "distlib" -version = "0.3.2" +version = "0.3.4" description = "Distribution utilities" category = "dev" optional = false @@ -57,11 +152,15 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.0.12" +version = "3.4.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "flake8" @@ -77,13 +176,29 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "importlib-metadata" -version = "4.6.0" +version = "4.10.0" description = "Read metadata from Python packages" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} @@ -91,8 +206,8 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -104,7 +219,7 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.3" description = "A very fast and expressive template engine." category = "main" optional = false @@ -132,38 +247,78 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "outcome" +version = "1.1.0" +description = "Capture the outcome of Python function calls." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +attrs = ">=19.2.0" + [[package]] name = "packaging" -version = "20.9" +version = "21.3" description = "Core utilities for Python packages" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycodestyle" @@ -173,6 +328,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pydantic" version = "1.8.2" @@ -196,48 +359,66 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pyopenssl" +version = "21.0.0" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + +[package.dependencies] +cryptography = ">=3.3" +six = ">=1.5.2" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "main" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "6.2.4" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" packaging = "*" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" -iniconfig = "*" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -colorama = {version = "*", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" toml = "*" -pluggy = ">=0.12,<1.0.0a1" -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] name = "pytest-cov" -version = "2.12.1" +version = "3.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" -toml = "*" -coverage = ">=5.2.1" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] @@ -266,23 +447,41 @@ pytest-cover = "*" [[package]] name = "selenium" -version = "3.141.0" -description = "Python bindings for Selenium" +version = "4.1.0" +description = "" category = "main" optional = false -python-versions = "*" +python-versions = "~=3.7" [package.dependencies] -urllib3 = "*" +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +urllib3 = {version = ">=1.26,<2.0", extras = ["secure"]} [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "toml" version = "0.10.2" @@ -291,45 +490,97 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "tox" -version = "3.23.1" +version = "3.24.4" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" -packaging = ">=14" +colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} filelock = ">=3.0.0" -py = ">=1.4.17" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} -toml = ">=0.9.4" -six = ">=1.14.0" +packaging = ">=14" pluggy = ">=0.12.0" +py = ">=1.4.17" +six = ">=1.14.0" +toml = ">=0.9.4" +virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] +[[package]] +name = "trio" +version = "0.19.0" +description = "A friendly Python library for async concurrency and I/O" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-generator = ">=1.9" +attrs = ">=19.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = "*" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.9.2" +description = "WebSocket library for Trio" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +async-generator = ">=1.10" +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "typed-ast" +version = "1.5.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "typing-extensions" -version = "3.10.0.0" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.6" +version = "1.26.7" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +[package.dependencies] +certifi = {version = "*", optional = true, markers = "extra == \"secure\""} +cryptography = {version = ">=1.3.4", optional = true, markers = "extra == \"secure\""} +idna = {version = ">=2.0.0", optional = true, markers = "extra == \"secure\""} +pyOpenSSL = {version = ">=0.14", optional = true, markers = "extra == \"secure\""} + [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] @@ -337,26 +588,38 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.10.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" six = ">=1.9.0,<2" -appdirs = ">=1.4.3,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] + +[[package]] +name = "wsproto" +version = "1.0.0" +description = "WebSockets state-machine based protocol implementation" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +h11 = ">=0.9.0,<1" [[package]] name = "zipp" -version = "3.4.1" +version = "3.6.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -364,17 +627,17 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "61fe55244c8c0f01ea2fee2fce2993d7de25e51bf83fb98e5dc305a47f955e52" +content-hash = "39255f2f01a82a02601f228f61d376277f5a2aafa68de4c388590f8fa27a79e5" [metadata.files] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +async-generator = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -384,6 +647,74 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, + {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, +] +black = [ + {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"}, + {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -442,37 +773,83 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +cryptography = [ + {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b"}, + {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2"}, + {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f"}, + {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3"}, + {file = "cryptography-36.0.1-cp36-abi3-win32.whl", hash = "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca"}, + {file = "cryptography-36.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316"}, + {file = "cryptography-36.0.1.tar.gz", hash = "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638"}, +] distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, + {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, + {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] +h11 = [ + {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, + {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] importlib-metadata = [ - {file = "importlib_metadata-4.6.0-py3-none-any.whl", hash = "sha256:c6513572926a96458f8c8f725bf0e00108fba0c9583ade9bd15b869c9d726e33"}, - {file = "importlib_metadata-4.6.0.tar.gz", hash = "sha256:4a5611fea3768d3d967c447ab4e93f567d95db92225b43b7b238dbfb855d70bb"}, + {file = "importlib_metadata-4.10.0-py3-none-any.whl", hash = "sha256:b7cf7d3fef75f1e4c80a96ca660efbd51473d7e8f39b5ab9210febc7809012a4"}, + {file = "importlib_metadata-4.10.0.tar.gz", hash = "sha256:92a8b58ce734b2a4494878e0ecf7d79ccd7a128b5fc6014c401e0b61f006f0f6"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -481,14 +858,27 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -498,6 +888,12 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -506,22 +902,42 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +outcome = [ + {file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"}, + {file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"}, +] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] pydantic = [ {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, @@ -550,17 +966,21 @@ pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] +pyopenssl = [ + {file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"}, + {file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"}, +] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ - {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, - {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-cov = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] pytest-cover = [ {file = "pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4"}, @@ -571,35 +991,78 @@ pytest-coverage = [ {file = "pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368"}, ] selenium = [ - {file = "selenium-3.141.0-py2.py3-none-any.whl", hash = "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c"}, - {file = "selenium-3.141.0.tar.gz", hash = "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d"}, + {file = "selenium-4.1.0-py3-none-any.whl", hash = "sha256:27e7b64df961d609f3d57237caa0df123abbbe22d038f2ec9e332fb90ec1a939"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] tox = [ - {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"}, - {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"}, + {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, + {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, +] +trio = [ + {file = "trio-0.19.0-py3-none-any.whl", hash = "sha256:c27c231e66336183c484fbfe080fa6cc954149366c15dc21db8b7290081ec7b8"}, + {file = "trio-0.19.0.tar.gz", hash = "sha256:895e318e5ec5e8cea9f60b473b6edb95b215e82d99556a03eb2d20c5e027efe1"}, +] +trio-websocket = [ + {file = "trio-websocket-0.9.2.tar.gz", hash = "sha256:a3d34de8fac26023eee701ed1e7bf4da9a8326b61a62934ec9e53b64970fd8fe"}, + {file = "trio_websocket-0.9.2-py3-none-any.whl", hash = "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc"}, +] +typed-ast = [ + {file = "typed_ast-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d8314c92414ce7481eee7ad42b353943679cf6f30237b5ecbf7d835519e1212"}, + {file = "typed_ast-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b53ae5de5500529c76225d18eeb060efbcec90ad5e030713fe8dab0fb4531631"}, + {file = "typed_ast-1.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:24058827d8f5d633f97223f5148a7d22628099a3d2efe06654ce872f46f07cdb"}, + {file = "typed_ast-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a6d495c1ef572519a7bac9534dbf6d94c40e5b6a608ef41136133377bba4aa08"}, + {file = "typed_ast-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:de4ecae89c7d8b56169473e08f6bfd2df7f95015591f43126e4ea7865928677e"}, + {file = "typed_ast-1.5.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:256115a5bc7ea9e665c6314ed6671ee2c08ca380f9d5f130bd4d2c1f5848d695"}, + {file = "typed_ast-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:7c42707ab981b6cf4b73490c16e9d17fcd5227039720ca14abe415d39a173a30"}, + {file = "typed_ast-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:71dcda943a471d826ea930dd449ac7e76db7be778fcd722deb63642bab32ea3f"}, + {file = "typed_ast-1.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4f30a2bcd8e68adbb791ce1567fdb897357506f7ea6716f6bbdd3053ac4d9471"}, + {file = "typed_ast-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ca9e8300d8ba0b66d140820cf463438c8e7b4cdc6fd710c059bfcfb1531d03fb"}, + {file = "typed_ast-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9caaf2b440efb39ecbc45e2fabde809cbe56272719131a6318fd9bf08b58e2cb"}, + {file = "typed_ast-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9bcad65d66d594bffab8575f39420fe0ee96f66e23c4d927ebb4e24354ec1af"}, + {file = "typed_ast-1.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:591bc04e507595887160ed7aa8d6785867fb86c5793911be79ccede61ae96f4d"}, + {file = "typed_ast-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:a80d84f535642420dd17e16ae25bb46c7f4c16ee231105e7f3eb43976a89670a"}, + {file = "typed_ast-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:38cf5c642fa808300bae1281460d4f9b7617cf864d4e383054a5ef336e344d32"}, + {file = "typed_ast-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b6ab14c56bc9c7e3c30228a0a0b54b915b1579613f6e463ba6f4eb1382e7fd4"}, + {file = "typed_ast-1.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2b8d7007f6280e36fa42652df47087ac7b0a7d7f09f9468f07792ba646aac2d"}, + {file = "typed_ast-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:b6d17f37f6edd879141e64a5db17b67488cfeffeedad8c5cec0392305e9bc775"}, + {file = "typed_ast-1.5.1.tar.gz", hash = "sha256:484137cab8ecf47e137260daa20bafbba5f4e3ec7fda1c1e69ab299b75fa81c5"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ - {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, - {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, + {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, +] +wsproto = [ + {file = "wsproto-1.0.0-py3-none-any.whl", hash = "sha256:d8345d1808dd599b5ffb352c25a367adb6157e664e140dbecba3f9bc007edb9f"}, + {file = "wsproto-1.0.0.tar.gz", hash = "sha256:868776f8456997ad0d9720f7322b746bbe9193751b5b290b7f924659377c8c38"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, ] diff --git a/pyproject.toml b/pyproject.toml index a67de51..2801c22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "uw-webdriver-recorder" # This version string is typically managed by the CI workflow, # and is changed anytime `poetry version [new version]` is run. # Do not revert this manually. -version = "4.0.1" +version = "5.0.0-alpha.12" description = "A pytest plugin for recording screenshots of selenium interactions, with other convenient features too." authors = ["Tom Thorogood "] license = "Apache Software License 3.0" @@ -22,7 +22,7 @@ packages = [ python = "^3.7" pytest = "^6.2.4" # NOT a dev dependency, as this is a plugin for pytest! Jinja2 = "^3.0.1" -selenium = "^3.141.0" +selenium = "^4.1.0" pydantic = "^1.8.2" [tool.poetry.dev-dependencies] @@ -30,6 +30,7 @@ tox = "^3.23.1" coverage = "^5.5" pytest-coverage = "^0.0" flake8 = "^3.9.2" +black = "^21.12b0" [build-system] requires = ["poetry-core>=1.0.0"] @@ -37,5 +38,6 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] markers = [ - "external: marks test that call an external endpoint. Deselect with '-m not external'" + "daily: marks tests that can only run once per day unless eval test data is reset.", + "external: marks test that call an external endpoint. Deselect with '-m not external'" ] diff --git a/setup.py b/setup.py deleted file mode 100644 index dbe9716..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python -import setuptools - -if __name__ == "__main__": - setuptools.setup() diff --git a/tests/conftest.py b/tests/conftest.py index 7011aa9..ffe62c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,21 @@ -import pytest import os +import pytest CUR_DIR = os.path.dirname(os.path.abspath(__file__)) -_local_html_path = os.path.join(CUR_DIR, "data", "index.html") @pytest.fixture(scope="session") -def local_html_path(): - return _local_html_path +def local_html_path(selenium_server): + if selenium_server: + root_dir = "/tests" + else: + root_dir = CUR_DIR + return os.path.join(root_dir, "data", "index.html") @pytest.fixture def load_page(browser, local_html_path): + assert os.path.exists(f"{local_html_path}") with browser.autocapture_off(): # Don't generate any PNGs as part of the autoused fixture. browser.get(f"file://{local_html_path}") diff --git a/tests/test_browser.py b/tests/test_browser.py index f93bf86..c0cf72f 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -1,19 +1,17 @@ import json -import os import time from datetime import datetime -from typing import Any, NoReturn +from typing import Any, NoReturn, Optional from unittest import mock import pytest -from selenium.common.exceptions import WebDriverException from selenium.webdriver.remote.command import Command from webdriver_recorder.browser import ( BrowserError, + By, Chrome, Locator, - SearchMethod, XPathWithSubstringLocator, _xpath_contains, logger, @@ -25,9 +23,7 @@ def url(local_html_path): return f"file://{local_html_path}" -def _fill_in_and_wait( - browser: Chrome, value: str, locator: Locator, capture_delay: int = 0 -) -> Any: +def _fill_in_and_wait(browser, value: str, locator: Locator, capture_delay: int = 0) -> Any: browser.send_inputs(value) browser.click_button("update") return browser.wait_for(locator, capture_delay=capture_delay) @@ -52,8 +48,8 @@ def be_boundless_and_wait(browser: Chrome, capture_delay: int = 0) -> NoReturn: "locator", [ XPathWithSubstringLocator(tag="p", displayed_substring="be boundless"), - Locator(search_method=SearchMethod.CSS_SELECTOR, search_value="div#outputDiv"), - Locator(search_method=SearchMethod.ID, search_value="outputDiv"), + Locator(search_method=By.CSS_SELECTOR, search_value="div#outputDiv"), + Locator(search_method=By.ID, search_value="outputDiv"), ], ) def test_wait_for(locator, browser, load_page): @@ -62,26 +58,12 @@ def test_wait_for(locator, browser, load_page): def test_wait_for_tag(browser, load_page): + """Ensure that when wait_for is called using a tag with text, the element is found.""" browser.send_inputs("be boundless") browser.click_button("update") assert browser.wait_for_tag("p", "be boundless") -def test_context_stops_client_on_exit(url, load_page): - class TestChrome(Chrome): - def __init__(self): - self.is_stopped = False - super().__init__() - - def stop_client(self): - self.is_stopped = True - - with TestChrome() as b: - b.get(url) - - assert b.is_stopped - - def test_fill_and_clear(browser, load_page): browser.send_inputs("boundless") assert browser.switch_to.active_element.get_attribute("value") == "boundless" @@ -102,20 +84,36 @@ def test_run_commands(browser, load_page): ) -def test_open_close_tab(browser, load_page): - assert len(browser.window_handles) == 2 +def test_open_close_tab(browser, session_browser_disabled, load_page): + offset = 0 + if session_browser_disabled: + offset = 1 + assert len(browser.window_handles) == 2 - offset browser.open_tab() - assert len(browser.window_handles) == 3 + assert len(browser.window_handles) == 3 - offset browser.close_tab() - assert len(browser.window_handles) == 2 + assert len(browser.window_handles) == 2 - offset + +@pytest.mark.parametrize("default_wait, input_wait, expected", [(1, 1, 1), (1, 2, 2), (1, None, 1), (1, 0, 0)]) +def test_resolve_timeout(browser, default_wait, input_wait, expected): + orig = browser.default_wait + browser.default_wait = default_wait + try: + assert browser._resolve_timeout(input_wait) == expected + finally: + browser.default_wait = orig -def test_tab_context(browser, load_page): + +def test_tab_context(browser, session_browser_disabled, load_page): + offset = 0 + if session_browser_disabled: + offset = 1 with pytest.raises(RuntimeError): with browser.tab_context(): - assert len(browser.window_handles) == 3 + assert len(browser.window_handles) == 3 - offset raise RuntimeError - assert len(browser.window_handles) == 2 + assert len(browser.window_handles) == 2 - offset def test_snap(browser, load_page): @@ -172,6 +170,22 @@ def decrypt(val: str): assert browser.find_element(value="inputField2").get_attribute("value") == "bar" +def test_failure(browser, load_page): + with pytest.raises(BrowserError): + browser.wait_for_tag("a", "does not exist", timeout=0) + + +@pytest.mark.parametrize( + "locator,expected", + [ + (Locator(search_method=By.CSS_SELECTOR, search_value="#hi"), 'css selector whose value is "#hi"'), + (XPathWithSubstringLocator(tag="p", displayed_substring="hi"), 'tag[p] containing the string "hi"'), + ], +) +def test_locator_description(locator, expected): + assert locator.description == expected + + class LogRecorder: def __init__(self): self.messages = [] @@ -219,13 +233,6 @@ def patch_get_log(log_type): assert not log_recorder.messages -def test_incorrect_chrome_bin(): - os.environ["CHROME_BIN"] = "/path/to/chrome" - with pytest.raises(WebDriverException): - browser = Chrome() - del os.environ["CHROME_BIN"] - - def test_incorrect_xpath_contains(): with pytest.raises(ValueError): _xpath_contains(None, '"') @@ -254,33 +261,57 @@ def patch_get_log(log_type): assert log_recorder.messages[0] == "Last HTTP transaction: 'entry #2'" -def test_wrap_exception_no_autocapture(browser): +def test_wrap_exception_no_autocapture(browser, load_page): assert not browser.pngs with pytest.raises(BrowserError): - # We should _always_ capture a snap of the page when something goes wrong - with browser.autocapture_off(): - with browser.wrap_exception("expected exception"): - raise AttributeError("oh noes!") + # We should _always_ capture a snap of the page when something goes wrong, + # but if there is an issue with the capture, don't worry about it. + with browser.wrap_exception("expected exception"): + raise AttributeError("oh noes!") assert len(browser.pngs) == 1 def test_locator_defaults(): - locator = Locator(search_method=SearchMethod.CSS_SELECTOR, search_value="foo") + locator = Locator(search_method=By.CSS_SELECTOR, search_value="foo") assert locator.search_value == "foo" - assert "css" in locator.state_description - assert "foo" in locator.state_description + assert "css" in locator.description + assert "foo" in locator.description -@pytest.mark.parametrize("wait", [True, False]) -def test_click(browser, wait, load_page): +def test_click(browser, load_page): browser.send_inputs("be boundless") - output_locator = Locator( - search_method=SearchMethod.CSS_SELECTOR, search_value="#outputDiv" - ) - button_locator = Locator( - search_method=SearchMethod.CSS_SELECTOR, search_value="#doUpdate" - ) + output_locator = Locator(search_method=By.CSS_SELECTOR, search_value="#outputDiv") + button_locator = Locator(search_method=By.CSS_SELECTOR, search_value="#doUpdate") output_element = browser.find_element(*output_locator.payload) assert not output_element.text - browser.click(button_locator, wait=wait) + browser.click(button_locator) assert output_element.text == "be boundless" + + +@pytest.mark.parametrize( + "snap, caption, expected_caption", + [ + (True, None, "Render file://{local_html_path}"), + (True, "Hello", "Hello"), + ( + False, + None, + None, + ), + ], +) +def test_get_with_snap(browser, local_html_path, snap: bool, caption: Optional[str], expected_caption: Optional[str]): + assert not browser.pngs + browser.get(f"file://{local_html_path}", snap=snap, caption=caption) + if expected_caption: + assert browser.pngs[-1].caption == expected_caption.format_map(dict(local_html_path=local_html_path)) + else: + assert not browser.pngs + + +def test_find_element_by(browser, load_page): + assert browser.find_element(By.ID, "outputDiv") + + +def test_find_elements_by(browser, load_page): + assert browser.find_elements(By.CSS_SELECTOR, "#outputDiv") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..4ccb22c --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,25 @@ +import os +import time +from datetime import timedelta + +from webdriver_recorder.models import Timed + + +def test_save_image_blank(browser, load_page): + browser.snap() + image = browser.pngs[0] + image.base64 = None + image.save("/tmp") + assert not os.path.exists(os.path.join("/tmp", image.url)) + + +def test_timed_duration(): + timed = Timed() + time.sleep(1) + assert timed.duration == "1s" + + +def test_timed_long_duration(): + timed = Timed() + timed.start_time = timed.start_time - timedelta(minutes=2) + assert timed.duration == "2m 0s" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f0a1e8b..57f2d3e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,39 +1,100 @@ -import json +# Enables testing failure cases in plugin logic by dynamically generating +# and running plugin tests. +import glob +import logging import os import shutil +from pathlib import Path +from tempfile import NamedTemporaryFile from unittest import mock import pytest -from webdriver_recorder.browser import Chrome, XPathWithSubstringLocator -from webdriver_recorder.plugin import lettergen - # Enables testing failure cases in plugin logic by dynamically generating # and running plugin tests. +import webdriver_recorder +from webdriver_recorder.browser import XPathWithSubstringLocator +from webdriver_recorder.plugin import EnvSettings + +print(webdriver_recorder.__file__) +from webdriver_recorder.models import Report, Outcome + pytest_plugins = ["pytester"] @pytest.fixture -def chrome(browser) -> Chrome: - yield browser +def report_subdir(report_dir): + subdir = os.path.join(report_dir, "test_plugin") + if os.path.exists(subdir): # From a previous test that errored + shutil.rmtree(subdir, ignore_errors=True) + os.makedirs(subdir, exist_ok=True) + try: + yield subdir + finally: + shutil.rmtree(subdir, ignore_errors=True) + + +@pytest.fixture +def make_test_file(pytester): + def inner(content): + content = f""" + {content} + """ + logging.info("Creating temporary python test file with content:\n" f"{content}") + pytester.makepyfile(content) + return inner -def test_happy_chrome(chrome, load_page): + +@pytest.fixture(scope="session") +def register_plugin() -> bool: + # When running in docker, we don't want to install the plugin, + # because it can wrangle coverage data. Instead, we leave it as + # a source directory (see Dockerfile::webdriver-source). + # Because we have no [need for a] virtualenv in the container, + # we have to explicitly tell pytest that we want to use + # this plugin. + # + # So, when setting up our test session, we check for a file that always + # exists when running inside a docker container. + return os.path.exists("/.dockerenv") + + +@pytest.fixture +def run_pytest(pytester, report_subdir, register_plugin): + def inner(*args, **kwargs): + if "--report-dir" not in args: + args = list(args) + args.extend(["--report-dir", report_subdir]) + + if "plugins" not in kwargs: + kwargs["plugins"] = [] + if register_plugin and "webdriver_recorder.plugin" not in kwargs["plugins"]: + kwargs["plugins"].append("webdriver_recorder.plugin") + return pytester.runpytest(*args, **kwargs) + + return inner + + +def test_happy_chrome(browser, load_page): """ A simple test case to ensure the fixture itself works; the heavy lifting is all tested in the browser tests. """ - _test_happy_case(chrome) + _test_happy_case(browser) def test_browser_context(browser, browser_context): mock_get_patcher = mock.patch.object(browser, "get") mock_get = mock_get_patcher.start() - mock_get = mock.patch.object(browser, "get").start() mock_delete_cookies_patcher = mock.patch.object(browser, "delete_all_cookies") mock_delete_cookies = mock_delete_cookies_patcher.start() try: with browser_context( - cookie_urls=["https://idp.uw.edu/signout", "https://identity.uw.edu/logout"] + browser, + cookie_urls=[ + "https://idp.uw.edu/signout", + "https://identity.uw.edu/logout", + ], ) as browser: browser.get("https://identity.uw.edu") mock_get.assert_called_once_with("https://identity.uw.edu") @@ -61,7 +122,6 @@ def validate_new_tab_state(self): assert self.browser.get_cookie("foo")["value"] == "bar" assert self.browser.current_url == "https://www.example.com/" - @pytest.mark.external def test_browser_new_tab(self): # At the beginning of the test, the "root" tab for the session will be open # as well as the "root" tab for the class. @@ -74,7 +134,6 @@ def test_browser_new_tab(self): self.browser.add_cookie({"name": "foo", "value": "bar"}) self.validate_new_tab_state() - @pytest.mark.external def test_browser_close_tab(self): # Validate that nothing was auto-closed between tests self.validate_new_tab_state() @@ -83,105 +142,233 @@ def test_browser_close_tab(self): assert self.browser.current_url == "https://www.washington.edu/" -def test_browser_error_failure_reporting( - chrome, testdir, local_html_path, report_generator, load_page -): +def test_browser_error_failure_reporting(run_pytest, local_html_path, report_subdir, make_test_file): """ - This uses the pytester plugin to execute ad-hoc tests in a new testing instance. This is the only way to test - the logic of fixtures after their included `yield` statement, and is what the pytester plugin was designed to do. - Here, we are asserting that the right cleanup behavior takes place when a BroswerError is bubbled from within - a test. + Assert that the right cleanup behavior takes place when a BrowserError + is bubbled from within a test. """ - testdir.makepyfile( - f""" + make_test_file( + content=f""" from webdriver_recorder.browser import BrowserError import pytest - def test_force_failure(browser, report_test): + def test_force_failure(browser): browser.get("file://{local_html_path}") - raise BrowserError(browser, 'forced failure') - """ + browser.wait_for_tag('a', 'does-not-exist', timeout=0) + """ ) - result = testdir.runpytest("--report-dir", report_generator) - expected_slug = "test_browser_error_failure_reporting-py--test_force_failure" - result.assert_outcomes(failed=1) - expected_filename = os.path.join(report_generator, f"result.{expected_slug}.html") - with open(expected_filename) as f: - result = json.loads(f.read()) - - assert result["failure"]["message"] == "forced failure" + result = run_pytest() + try: + result.assert_outcomes(failed=1) + except ValueError: + logging.error(f"Could not parse output from pytest.") + if result.outlines: + lines = "\n".join(result.outlines) + logging.error(f"No summary found in:\n {lines}") + else: + logging.error("No STDOUT from test execution") + if result.errlines: + lines = "\n".join(result.errlines) + logging.error(f"Execution resulted in errors\n:{lines}") + raise + expected_filename = os.path.join(report_subdir, f"report.json") + report = Report.parse_file(expected_filename) + assert report.outcome == Outcome.failure + assert "BrowserError" in report.results[0].traceback -def test_failure_reporting( - chrome, testdir, local_html_path, report_generator, load_page -): +def test_failure_reporting(run_pytest, local_html_path, report_subdir, make_test_file): """ Similar to test_browser_error_failure_reporting, but with a generic AssertionError. This is what we would expect to see under most failure circumstances. """ - testdir.makepyfile( + make_test_file( f""" from webdriver_recorder.browser import BrowserError import pytest - def test_force_failure(browser, report_test): - browser.get("file://{local_html_path}") + def test_force_failure(session_browser, report_test): + session_browser.get("file://{local_html_path}") assert False """ ) - result = testdir.runpytest("--report-dir", report_generator) - expected_slug = "test_failure_reporting-py--test_force_failure" + result = run_pytest() result.assert_outcomes(failed=1) - expected_filename = os.path.join(report_generator, f"result.{expected_slug}.html") - with open(expected_filename) as f: - result = json.loads(f.read()) + expected_filename = os.path.join(report_subdir, "report.json") + report = Report.parse_file(expected_filename) + assert report.outcome == Outcome.failure + assert "AssertionError" in report.results[0].traceback - assert "AssertionError" in result["failure"]["message"] +def test_selenium_server_arg(run_pytest, make_test_file): + make_test_file( + f""" + def test_selenium_server(selenium_server): + assert selenium_server == 'foo' + """ + ) -def test_report_generator( - browser, generate_report, testdir, local_html_path, load_page -): - """ - While the report generator could be tested by invoking directly, this test adds an extra layer of ensuring - the correct default behavior is to write the report even if the test itself fails. - """ - report_dir = os.path.join(os.getcwd(), "tmp", "test-report") - os.makedirs(report_dir, exist_ok=True) - try: - expected_filename = os.path.join(report_dir, "index.html") - assert not os.path.exists(expected_filename) - testdir.makepyfile( - f""" - from webdriver_recorder.browser import BrowserError - import pytest + result = run_pytest("--selenium-server", "foo") + result.assert_outcomes(passed=1) + + +def test_worker_results(make_test_file, run_pytest, report_subdir, local_html_path): + def count_files(glob_: str) -> int: + return len([os.path.basename(p) for p in glob.glob(f"{report_subdir}/{glob_}")]) - def test_force_failure(browser, report_test): - browser.get("file://{local_html_path}") - browser.snap() + with NamedTemporaryFile(prefix="worker.", dir=report_subdir): + existing_workers = count_files("worker.*") + existing_results = count_files("*.result.json") + assert existing_workers == 1, "Precondition failed" + assert existing_results == 0, "Precondition failed" + make_test_file( + f""" + def test_a_thing(browser): + browser.get("file://{local_html_path}", snap=True) """ ) - result = testdir.runpytest("--report-dir", report_dir) - expected_filename = os.path.join(report_dir, "index.html") - assert os.path.exists(expected_filename) - finally: - shutil.rmtree(report_dir) + run_pytest() + assert count_files("worker.*") == 1 + assert count_files("*.result.json") == 1 + assert count_files("report.json") == 0 + + +def test_aggregate_worker_reports(run_pytest, report_subdir, make_test_file): + make_test_file( + f""" + def test_a_thing(browser): + pass + """ + ) + run_pytest() + report_json = os.path.join(report_subdir, "report.json") + test_1_rename = os.path.join(report_subdir, "t1.result.json") + assert os.path.exists(report_json) + shutil.move(report_json, test_1_rename) + assert not os.path.exists(report_json) + assert os.path.exists(test_1_rename) + run_pytest() + assert os.path.exists(report_json) + assert not os.path.exists(test_1_rename) + report = Report.parse_file(report_json) + assert len(report.results) == 2 + + +def test_no_outcomes(run_pytest, report_subdir, make_test_file): + make_test_file( + f""" + import pytest + + @pytest.fixture + def bad_fixture(): + raise RuntimeError + + def test_a_thing(bad_fixture, browser): + pass + """ + ) + run_pytest() + logging.error("If you just saw an error message, you can ignore it. " "It was on purpose, and part of a test.") + report_json = os.path.join(report_subdir, "report.json") + report = Report.parse_file(report_json) + assert report.results[0].outcome.value == "never started" + assert report.outcome == Outcome.failure + assert report.results[0].test_name == "test_a_thing" + + +def test_docstring_capture(run_pytest, report_subdir, make_test_file): + make_test_file( + f""" + def test_a_thing(browser): + '''hello''' + pass + """ + ) + run_pytest() + report_json = os.path.join(report_subdir, "report.json") + report = Report.parse_file(report_json) + assert report.results[0].test_description == "hello" + + +def test_call_exception(run_pytest, report_subdir, make_test_file): + make_test_file( + f""" + def test_a_thing(browser): + raise RuntimeError("????") + """ + ) + run_pytest() + report = Report.parse_file(os.path.join(report_subdir, "report.json")) + assert "RuntimeError" in report.results[0].traceback + + +def test_remote_context(run_pytest, make_test_file): + make_test_file( + f""" + def test_selenium_server(selenium_server): + assert selenium_server == 'foo' + + def test_browser_class(browser_class): + assert browser_class.__name__ == "Remote" + + def test_browser_args(browser_args): + assert browser_args['command_executor'] == "http://foo/wd/hub" + """ + ) + + result = run_pytest("--selenium-server", "foo") + result.assert_outcomes(passed=3) -def test_lettergen(): - sequence = list(zip(lettergen(), list(range(1, 73715)))) - assert sequence[0] == ("A", 1) - assert sequence[-1] == ("DEAD", 73714) +@pytest.mark.parametrize( + "env_value, expected", [("", None), ("1", True), ("true", True), ("0", False), ("false", False)] +) +def test_env_settings(env_value, expected): + with mock.patch.dict(os.environ, clear=True) as environ: + environ["disable_session_browser"] = env_value + assert EnvSettings().disable_session_browser == expected + + +def test_report_generator(run_pytest, local_html_path, report_subdir, make_test_file): + """ + While the report generator could be tested by invoking directly, this test adds an extra layer of ensuring + the correct default behavior is to write the report even if the test itself fails. + """ + expected_filename = os.path.join(report_subdir, "index.html") + assert not os.path.exists(expected_filename) + make_test_file( + f""" + import pytest + + def test_force_failure(session_browser): + session_browser.get("file://{local_html_path}") + session_browser.snap() + """ + ) + run_pytest("--report-dir", report_subdir) + assert os.path.exists(expected_filename) # The underscore here keeps pytest from executing this as a test itself. def _test_happy_case(browser): - browser.wait_for( - XPathWithSubstringLocator(tag="button", displayed_substring="update") - ) + browser.wait_for(XPathWithSubstringLocator(tag="button", displayed_substring="update")) browser.send_inputs("boundless") browser.click_button("update") - browser.wait_for( - XPathWithSubstringLocator(tag="p", displayed_substring="boundless") - ) + browser.wait_for(XPathWithSubstringLocator(tag="p", displayed_substring="boundless")) assert len(browser.pngs) == 3 + + +def test_clean_screenshots_on_startup(pytester, make_test_file, run_pytest, report_subdir): + screens_dir = os.path.join(report_subdir, "screenshots") + screenshot = os.path.join(screens_dir, "foo.png") + pytester.mkdir(screens_dir) + Path(screenshot).touch(exist_ok=True) + make_test_file( + f""" + def test_a_thing(browser): + pass + """ + ) + assert os.path.exists(screenshot) + run_pytest() + assert not os.path.exists(screenshot) diff --git a/tox.ini b/tox.ini index 4c118ef..a7450bb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,23 @@ [tox] +envlist = clean,\ + # autoformats code -- because of this, + # you shouldn't use "tox" without "-e" IF you are running + # in a context where the result won't be committed. Instead you + # should use the "black_check" env which checks that the + black,\ + # Run in two modes locally + local_default,local_disable_session_browser, + # Run inside docker + docker_compose, + # Ensure our executable works right + run_report_export, + # Ensure coverage and pep8 + validate_coverage,flake8 +# isolated_build = True is required because poetry already creates the env. +# This doesn't mean "isolate this build," it means "the build is already isolated" isolated_build = True -deps = poetry -install_command = "poetry install" -envlist = clean,py3.7,flake8 - +# Don't install the plugin, because it's harder to track coverage +skip_install = True [pytest] testpaths = tests @@ -12,7 +26,6 @@ testpaths = tests [coverage:report] exclude_lines = pragma: no cover - def __repr__ if self.\.debug @@ -20,25 +33,76 @@ exclude_lines = raise NotImplementedError if __name__ == .__main_.: - [testenv] -deps = coverage -commands = coverage run --source=webdriver_recorder -m pytest -o log_cli=true -o log_cli_level=INFO - coverage html - coverage report --fail-under=100 +setenv = + test_dir = {toxinidir}/tests + REPORT_DIR = {toxinidir}/webdriver-report + +[testenv:bootstrap] +allowlist_externals = poetry + +commands = poetry install --no-interaction --no-root + ./bootstrap_chromedriver.sh + + +[testenv:local_default] +allowlist_externals = pytest + coverage +commands = coverage run -a --source=webdriver_recorder -m pytest -o log_cli=true -o log_cli_level=warning tests/ + coverage report -m + + +[testenv:local_disable_session_browser] +allowlist_externals = pytest + coverage +setenv = + disable_session_browser = true + test_dir = {toxinidir}/tests + REPORT_DIR = {toxinidir}/webdriver-report +commands = coverage run -a --source=webdriver_recorder -m pytest -o log_cli=true -o log_cli_level=warning tests/ + coverage report -m + +[testenv:docker_compose] +allowlist_externals = docker-compose +commands = docker-compose up --build --exit-code-from test-runner + +[testenv:run_report_export] +allowlist_externals = coverage + +commands = coverage run -a --source=webdriver_recorder \ + -m webdriver_recorder.export-report \ + -i {env:REPORT_DIR}/report.json + +[testenv:validate_coverage] +allowlist_externals = coverage +commands = coverage html + coverage report --fail-under 100 -m [testenv:clean] -skip_install = True -deps = coverage +allowlist_externals = coverage + rm commands = coverage erase - + rm -fv {env:REPORT_DIR}/worker.* [testenv:flake8] -skip_install = true -deps = flake8 +allowlist_externals = flake8 commands = flake8 {toxinidir}/webdriver_recorder +[testenv:black] +allowlist_externals = black +commands = black {toxinidir}/webdriver_recorder {toxinidir}/tests --line-length 119 + +[testenv:black_check] +allowlist_externals = black +commands = black {toxinidir}/webdriver_recorder {toxinidir}/tests --line-length 119 --check + +[testenv:dump_strict_envs] +allowlist_externals = tox + sed + paste + +commands: tox -l | sed '/^\\$/d' | sed 's|black|black_check|g' | paste -sd "," - [flake8] -max-line-length = 144 +max-line-length = 119 diff --git a/validate-strict.sh b/validate-strict.sh new file mode 100755 index 0000000..9274f4b --- /dev/null +++ b/validate-strict.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# When validating strictly, we don't want to +# amend the code provided, so instead of running "black" +# we want to run "black --check" which validates formatting +# without changing it. +# This command cuts and re-splices the default tox +# env to replace the "black" env with the "black_check" env. +tox_envs=$(poetry run tox -l | sed '/^\\$/d' | sed 's|black|black_check|g' | paste -sd "," -) +set -x +poetry run tox -e ${tox_envs} diff --git a/webdriver_recorder/VERSION b/webdriver_recorder/VERSION deleted file mode 100644 index 73a02ea..0000000 --- a/webdriver_recorder/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.0.1 # gets overwritten by CI as 'git describe --tags', leave this as is. diff --git a/webdriver_recorder/browser.py b/webdriver_recorder/browser.py index e6ed75a..99daf84 100644 --- a/webdriver_recorder/browser.py +++ b/webdriver_recorder/browser.py @@ -1,24 +1,26 @@ -"""BrowserRecorder class for recording snapshots between waits. -""" import json -import os import pprint +import string import time from contextlib import contextmanager from enum import Enum +from hashlib import sha256 from logging import getLogger -from typing import Optional, List, Tuple, TypeVar +from typing import Any, Callable, List, Optional, Tuple, Union import selenium.webdriver.remote.webdriver from pydantic import BaseModel, Field from selenium import webdriver +from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.common.by import By -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.common.by import By as By_ from selenium.webdriver.common.keys import Keys +from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait +from webdriver_recorder.models import Image + logger = getLogger(__name__) __all__ = [ @@ -29,23 +31,25 @@ "Remote", "Locator", "XPathWithSubstringLocator", - "SearchMethod", + "By", ] +_XPATH_TRANSLATE_CASE = f"translate(., '{string.ascii_uppercase}', '{string.ascii_lowercase}')" + -class SearchMethod(Enum): +class By(Enum): """ An Enum based on selenium's By object, so that values can be explicitly declared. """ - ID = By.ID - XPATH = By.XPATH - LINK_TEXT = By.LINK_TEXT - PARTIAL_LINK_TEXT = By.PARTIAL_LINK_TEXT - NAME = By.NAME - TAG_NAME = By.TAG_NAME - CLASS_NAME = By.CLASS_NAME - CSS_SELECTOR = By.CSS_SELECTOR + ID = By_.ID + XPATH = By_.XPATH + LINK_TEXT = By_.LINK_TEXT + PARTIAL_LINK_TEXT = By_.PARTIAL_LINK_TEXT + NAME = By_.NAME + TAG_NAME = By_.TAG_NAME + CLASS_NAME = By_.CLASS_NAME + CSS_SELECTOR = By_.CSS_SELECTOR class Locator(BaseModel): @@ -61,7 +65,7 @@ class Locator(BaseModel): browser.find_element(*danger_locator.payload) """ - search_method: SearchMethod + search_method: By search_value: Optional[str] @property @@ -73,8 +77,11 @@ def _search_value(self) -> str: return self.search_value or "" @property - def state_description(self) -> str: - return f"wait for {self._search_value} to be visible by method: {self.search_method.value}" + def description(self) -> str: + desc = f"{self.search_method.value}" + if self.search_value: + desc = f'{desc} whose value is "{self.search_value}"' + return desc class XPathWithSubstringLocator(Locator): @@ -86,7 +93,7 @@ class XPathWithSubstringLocator(Locator): locator = XPathWithSubstringLocator(tag='div', displayed_substring='hello') # will match
HELLO
""" - search_method = Field(SearchMethod.XPATH, const=True) + search_method = Field(By.XPATH, const=True) tag: str displayed_substring: str @@ -95,16 +102,11 @@ def _search_value(self) -> str: return _xpath_contains(f"//{self.tag}", self.displayed_substring) @property - def state_description(self) -> str: - return ( - f'wait for {self.tag} with contents "{self.displayed_substring}" ' - f"to be visible via xpath {self._search_value}" - ) - - -WebDriverType = TypeVar( - "WebDriverType", bound=selenium.webdriver.remote.webdriver.WebDriver -) + def description(self) -> str: + desc = f"tag[{self.tag}]" + if self.displayed_substring: + desc = f'{desc} containing the string "{self.displayed_substring}"' + return desc class BrowserRecorder(selenium.webdriver.remote.webdriver.WebDriver): @@ -113,18 +115,13 @@ class BrowserRecorder(selenium.webdriver.remote.webdriver.WebDriver): automatic screenshot capturing. """ - pngs: List[bytes] = [] # store screenshots here. intentionally global + pngs: List[Image] = [] # store screenshots here. intentionally global - def __init__(self, *args, width=400, height=200, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.set_window_size(width=width, height=height) + self.maximize_window() self.autocapture = True # automatically capture screenshots - - def __enter__(self): - return self - - def __exit__(self, *args): - self.quit() + self.default_wait = kwargs.get("default_wait", 5) @contextmanager def autocapture_off(self): @@ -136,38 +133,22 @@ def autocapture_off(self): finally: self.autocapture = previous_autocapture - def get(self, url): - - return super().get(url) - - def delete_all_cookies(self): - pass + def _resolve_timeout(self, timeout_in: Optional[int]): + if timeout_in is None: + return self.default_wait + return timeout_in def clear(self): """Clear the active element.""" self.switch_to.active_element.clear() - def click( - self, - locator: Locator, - wait: bool = True, - timeout: int = 5, - capture_delay: int = 0, - ): - """ - Find tag containing substring and click it. - wait - give it time to show up in the DOM. - """ - with self.wrap_exception(locator.state_description): - if wait and timeout: - wait = Waiter(self, timeout) - element = wait.until( - EC.element_to_be_clickable(locator.payload), - capture_delay=capture_delay, - ) - else: - element = self.find_element(*locator.payload) - element.click() + def click(self, locator: Locator, **kwargs): + if "caption" not in kwargs: + kwargs["caption"] = f"Click on {locator.description}" + + element = self.wait_until(locator, EC.element_to_be_clickable, **kwargs) + element.click() + return element def click_tag(self, tag: str, with_substring: str, **kwargs): """See wait_for_tag; this does the same thing for clicking on random elements.""" @@ -176,23 +157,41 @@ def click_tag(self, tag: str, with_substring: str, **kwargs): **kwargs, ) + def find_element(self, by: Union[By, str] = By_.ID, value: Optional[Any] = None) -> WebElement: + """Overrides the base find_element method to support the 'By' enum""" + if isinstance(by, By): + by = by.value + return super().find_element(by, value) + + def find_elements(self, by: Union[By, str] = By_.ID, value: Optional[Any] = None) -> List[WebElement]: + """Overrides the base find_element method to support the 'By' enum""" + if isinstance(by, By): + by = by.value + return super().find_elements(by, value) + def click_button(self, substring: str = "", **kwargs): """ Wait for a button with substring to become clickable then click it. """ return self.click_tag("button", substring, **kwargs) - def wait_for( - self, locator: Locator, timeout: Optional[int] = None, capture_delay: int = 0 - ): + def wait_for(self, locator: Locator, **kwargs): """Wait for tag containing substring to show up in the DOM.""" - if timeout is None: - timeout = getattr(self, "default_wait", 5) - with self.wrap_exception(locator.state_description): + if "caption" not in kwargs: + kwargs["caption"] = f"Wait for {locator.description}" + + return self.wait_until(locator, EC.visibility_of_element_located, **kwargs) + + def wait_until( + self, locator: Locator, condition: Callable, timeout: Optional[int] = None, capture_delay: int = 0, **kwargs + ): + timeout = self._resolve_timeout(timeout) + with self.wrap_exception(locator.description): wait = Waiter(self, timeout) return wait.until( - EC.visibility_of_element_located(locator.payload), + condition(locator.payload), capture_delay=capture_delay, + **kwargs, ) def wait_for_tag(self, tag: str, with_substring: str, **kwargs): @@ -215,18 +214,43 @@ def run_commands(self, commands): for method, *args in commands: getattr(self, method)(*args) - def snap(self): + @contextmanager + def _resize_for_screenshot(self): + original_size = self.get_window_size() + required_width = self.execute_script("return document.body.parentNode.scrollWidth") + required_height = self.execute_script("return document.body.parentNode.scrollHeight") + self.set_window_size(required_width, required_height) + yield + self.set_window_size(original_size["width"], original_size["height"]) + + def get(self, url: string, snap: bool = False, caption: Optional[str] = None): + self.execute_script("console.clear();") + super().get(url) + if self.autocapture and snap: + if not caption: + caption = f"Render {url}" + self.snap(caption=caption) + + def snap(self, caption: Optional[str] = None, is_error: bool = False): """ Store the screenshot as a base64 png in memory. Resize the window ahead of time so the full page shows in the shot. """ - size = self.get_window_size() - height = int(self.execute_script("return document.body.scrollHeight")) - # For remote webdriver the scrollHeight still shows a scroll bar. - # Bump it up just a little. - size["height"] = height + 120 - self.set_window_size(**size) - self.pngs.append(self.get_screenshot_as_base64()) + with self._resize_for_screenshot(): + b64_image = self.find_element(By_.TAG_NAME, "body").screenshot_as_base64 + # The sha256 digest is used to fingerprint the image. + # This can SIGNIFICANTLY reduce the payload of a report + # artifact bundle by de-duplicating images, especially + # in parametrized tests. + b64_sha = sha256(b64_image.encode("UTF-8")).hexdigest() + self.pngs.append( + Image( + url=f"screenshots/{b64_sha}.png", + base64=b64_image, + caption=caption, + is_error=is_error, + ) + ) def send(self, *strings): """ @@ -237,9 +261,9 @@ def send(self, *strings): chain.send_keys(Keys.TAB.join(strings)).perform() def send_inputs(self, *strings): - elements = self.find_elements_by_css_selector("input") - for element, string in zip(elements, strings): - element.send_keys(string) + elements = self.find_elements(By.TAG_NAME.value, "input") + for element, val in zip(elements, strings): + element.send_keys(val) def hide_inputs(self): """Obscure all text inputs on the current screen.""" @@ -288,9 +312,21 @@ def wrap_exception(self, message): try: yield except Exception as e: - if not self.autocapture: - self.snap() - raise BrowserError(self, message, str(e)) + if not isinstance(e, BrowserError): + err = BrowserError(self, message) + err.orig = e + else: + err = e + + # Only capture this screenshot if the error occurred + # in a context that didn't automatically log the error. + if not self.pngs or not self.pngs[-1].is_error: + try: + self.snap(caption=f"Python error: {type(e).__name__}", is_error=True) + except WebDriverException: # pragma: no cover + logger.warning(f"Could not take screenshot after encountering error {e=}.") + + raise err from None class Waiter(WebDriverWait): @@ -300,30 +336,41 @@ def __init__(self, driver, timeout, *args, **kwargs): super().__init__(driver, timeout, *args, **kwargs) self.__driver = driver - def until(self, *arg, capture_delay: int = 0, **kwargs): + def until( + self, *args, capture_delay: int = 0, caption: Optional[str] = None, is_error: Optional[bool] = False, **kwargs + ) -> WebElement: """ Every time we wait, take a screenshot of the outcome. capture_delay - when we're done waiting, wait just a little longer for whatever animations to take effect. """ + found = False + caption = caption or "" + err = None try: - element = super().until(*arg, **kwargs) + element = super().until(*args, **kwargs) + found = True + except Exception as e: + err = BrowserError(self.__driver, str(e)) + err.orig = e + raise err from None finally: - if self.__driver.autocapture: + if self.__driver.autocapture or err: if capture_delay: time.sleep(capture_delay) - self.__driver.snap() + self.__driver.snap(caption=caption, is_error=not found) return element class BrowserError(Exception): """Error to raise for a meaningful browser error report.""" - def __init__(self, browser, message, *args): + def __init__(self, browser: BrowserRecorder, message, *args): self.message = message self.url = browser.current_url self.logs = browser.get_log("browser") self.log_last_http(browser) + self.orig = None super().__init__(message, self.url, self.logs, *args) @staticmethod @@ -344,24 +391,15 @@ def log_last_http(browser): def _xpath_contains(node, substring): - lc_translate = ( - "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')" - ) if '"' in substring: raise ValueError("double quotes in substring not supported") substring = substring.lower() - return f'{node}[contains({lc_translate}, "{substring}")]' + return f'{node}[contains({_XPATH_TRANSLATE_CASE}, "{substring}")]' class Chrome(BrowserRecorder, webdriver.Chrome): - def __init__(self, *args, options=None, **kwargs): - if not options: - options = webdriver.ChromeOptions() - options.binary_location = os.environ.get("CHROME_BIN") - options.headless = True # default to what works in CI. - options.add_experimental_option("w3c", False) - super().__init__(*args, options=options, **kwargs) + pass class Remote(BrowserRecorder, webdriver.Remote): - capabilities = DesiredCapabilities + pass diff --git a/webdriver_recorder/export-report.py b/webdriver_recorder/export-report.py new file mode 100644 index 0000000..9485a2e --- /dev/null +++ b/webdriver_recorder/export-report.py @@ -0,0 +1,45 @@ +import argparse +import logging +import os + +from webdriver_recorder.models import Report +from webdriver_recorder.report_exporter import ReportExporter + +here = os.path.dirname(os.path.abspath(__file__)) + + +def get_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + "Regenerates a report from an existing report.json using a custom or default template. " + "This makes it easier to tweak the template without having to run a full test suite each time, " + "as long as the report.json appears correct." + ) + parser.add_argument("--input-json", "-i", required=True, help="The path to the report.json you want to load.") + parser.add_argument( + "--output-dir", + "-o", + required=False, + default=None, + help="The directory you want the report output to go. If not provided, it will overwrite " + "the input report artifacts.", + ) + parser.add_argument( + "--template-filename", + "-t", + required=False, + default=os.path.join(here, "templates", "report.html"), + help="The name of a template to use when generating the report.", + ) + return parser + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + args = get_parser().parse_args() + output_dir = args.output_dir or os.path.dirname(args.input_json) + exporter = ReportExporter( + template_dir=os.path.dirname(args.template_filename), root_template=os.path.basename(args.template_filename) + ) + report = Report.parse_file(args.input_json) + exporter.export_all(report, output_dir) + print(f"Exported artifacts to {output_dir}") diff --git a/webdriver_recorder/models.py b/webdriver_recorder/models.py new file mode 100644 index 0000000..5061725 --- /dev/null +++ b/webdriver_recorder/models.py @@ -0,0 +1,146 @@ +import base64 +import os +import re +from datetime import datetime +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, Field, validator + + +class Image(BaseModel): + url: str + base64: Optional[str] + caption: Optional[str] + is_error: bool = False + + def save(self, root: str): + if not self.base64: + return + bytes_ = base64.b64decode(self.base64.encode("UTF-8")) + filename = os.path.join(root, self.url) + dirname = os.path.dirname(filename) + os.makedirs(dirname, exist_ok=True) + with open(filename, "wb") as f: + f.write(bytes_) + + +class Outcome(Enum): + success = "success" + failure = "failure" + never_started = "never started" + + +class Timed(BaseModel): + start_time: datetime = Field(default_factory=lambda: datetime.now()) + end_time: Optional[datetime] + + def __enter__(self): + self.start_time = datetime.now() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.end_time = datetime.now() + + def stop_timer(self): + self.end_time = datetime.now() + + @property + def duration(self) -> str: + """ + Creates a human-readable minutes/seconds slug + detailing how long the test took. + + If the duration was 129.5 seconds, + the output would be '2m 10s' + """ + end_time = self.end_time or datetime.now() + minutes = 0 + seconds = (end_time - self.start_time).seconds + if seconds > 60: + minutes = int(seconds / 60) + seconds = round(seconds % 60) + if minutes: + return f"{minutes}m {seconds}s" + return f"{seconds}s" + + # There are several 'noqa: E821' notes below. + # The arguments here are part of the dict() method provided by + # pydantic, but refer to types we don't care about here. Rather + # than unnecessarily mangle the signature of this complex + # method, it's better to just ignore the E821 that complains + # about it; it's "not our problem" because we didn't make + # these signatures. + def dict( + self, + *, + include: Union["AbstractSetIntStr", "MappingIntStrAny"] = None, # noqa: F821 + exclude: Union["AbstractSetIntStr", "MappingIntStrAny"] = None, # noqa: F821 + by_alias: bool = False, + skip_defaults: bool = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + ) -> "DictStrAny": # noqa: F821 + result = super().dict( + include=include, + exclude=exclude, + by_alias=by_alias, + skip_defaults=skip_defaults, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + if not exclude or "duration" not in exclude: + result["duration"] = self.duration + + return result + + +class TestResult(Timed): + test_name: str + test_id: Optional[str] # Will be set from test_name + pngs: List[Image] = [] + console_errors: List[str] = [] + traceback: Optional[str] + test_description: Optional[str] + outcome: Outcome = Outcome.never_started + + @validator("test_id", always=True) + def populate_test_id(cls, v, values): + test_id = re.sub(r"[^\w]", "-", values.get("test_name")) + if test_id.endswith("-"): + test_id = test_id[:-1] + test_id = re.sub(r"--", "-", test_id) + return test_id + + +class Report(Timed): + outcome: Outcome = "never started" + results: List[TestResult] = [] + arguments: Optional[str] + title: str + + @property + def failures(self) -> List[TestResult]: + filter_ = filter(lambda result: (result.outcome != Outcome.success), self.results) + return list(filter_) + + @property + def num_failures(self) -> int: + return len(self.failures) + + +class ReportResult: + """ + A test result for passing to the report_test fixture. + report -- a pytest test outcome + excinfo -- exception info if there is any + doc -- the docstring for the test if there is any + """ + + def __init__(self, report, excinfo, doc): + self.report = report + self.excinfo = excinfo + self.doc = doc diff --git a/webdriver_recorder/plugin.py b/webdriver_recorder/plugin.py index e623496..0199e65 100644 --- a/webdriver_recorder/plugin.py +++ b/webdriver_recorder/plugin.py @@ -1,31 +1,45 @@ -import datetime -import html -import itertools -import json import logging import os -import re +import sys import tempfile from contextlib import contextmanager -from pathlib import Path -from string import ascii_uppercase -from typing import Any, Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Type, Union -import jinja2 import pytest -from _pytest.fixtures import FixtureRequest -from pydantic import BaseModel, root_validator +from pydantic import BaseSettings, validator from selenium import webdriver from .browser import BrowserError, BrowserRecorder, Chrome, Remote +from .models import Outcome, Report, ReportResult, TestResult, Timed +from .report_exporter import ReportExporter -TEMPLATE_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "report.template.html" -) +_here = os.path.abspath(os.path.dirname(__file__)) +logger = logging.getLogger(__name__) -log = logging.getLogger(__name__) -_here = os.path.abspath(os.path.dirname(__file__)) +class EnvSettings(BaseSettings): + """ + Automatically derives from environment variables and + translates truthy/falsey strings into bools. Only required + for code that must be conditionally loaded; all others + should be part of 'pytest_addoption()' + """ + + # If set to True, will generate a new browser instance within every request + # for a given scope, instead of only creating a single instance and generating + # contexts for each test. + # This has a significant performance impact, + # but sometimes cannot be avoided. + disable_session_browser: Optional[bool] = False + + @validator("*", pre=True, always=True) + def handle_empty_string(cls, v): + if not v: + return None + return v + + +_SETTINGS = EnvSettings() def pytest_addoption(parser): @@ -34,14 +48,14 @@ def pytest_addoption(parser): "--selenium-server", action="store", dest="selenium_server", - default=None, + default=os.environ.get("REMOTE_SELENIUM"), help="Remote selenium webdriver to connect to (eg localhost:4444)", ) group.addoption( "--report-dir", action="store", dest="report_dir", - default=os.path.join(os.getcwd(), "webdriver-report"), + default=os.environ.get("REPORT_DIR", os.path.join(os.getcwd(), "webdriver-report")), help="The path to the directory where artifacts should be stored.", ) group.addoption( @@ -54,100 +68,111 @@ def pytest_addoption(parser): "--report-title", action="store", dest="report_title", - default=None, - help="An optional title for your report; if not provided, " - "the url of the final test-case executed before generation will be used. " - "You may provide a constant default by overriding the report_title fixture.", + default="Webdriver Recorder Summary", + help="An optional title for your report; if not provided, a default will be used. " + "You may also provide a constant default by overriding the report_title fixture.", ) -class HTMLEscapeBaseModel(BaseModel): - """ - Base model that automatically escapes all strings passed into it. - """ - - @root_validator(pre=True) - def escape_strings(cls, values) -> Dict[str, Any]: - values = dict(values) - for k, v in values.items(): - if isinstance(v, str): - values[k] = html.escape(str(v)) - return values - - -class ResultFailure(HTMLEscapeBaseModel): - message: str - url: Optional[str] - loglines: Optional[List[str]] +@pytest.fixture(scope="session", autouse=True) +def clean_screenshots(report_dir): + screenshots_dir = os.path.join(report_dir, "screenshots") + if os.path.exists(screenshots_dir): + old_screenshots = os.listdir(screenshots_dir) + for png in old_screenshots: + os.remove(os.path.join(screenshots_dir, png)) -class ResultAttributes(HTMLEscapeBaseModel): - link: str - doc: Optional[str] - nodeid: str - pngs: List[bytes] - failure: Optional[ResultFailure] - time1: str - time2: str +@pytest.fixture(scope="session", autouse=True) +def test_report(report_title) -> Report: + args = [] + if len(sys.argv) > 1: + args.extend(sys.argv[1:]) + + return Report( + arguments=" ".join(args), + outcome=Outcome.never_started, + title=report_title, + ) -class ResultHeader(HTMLEscapeBaseModel): - link: str - is_failed: bool - description: str +@pytest.fixture(scope="session") +def selenium_server(request) -> Optional[str]: + """Returns a non-empty string or None""" + value = request.config.getoption("selenium_server") + if value: + return value.strip() + return None -class ReportResult(object): +@pytest.fixture(scope="session") +def chrome_options() -> webdriver.ChromeOptions: """ - A test result for passing to the report_test fixture. - report -- a pytest test outcome - excinfo -- exception info if there is any - doc -- the docstring for the test if there is any + An extensible instance of ChromeOptions with default + options configured for a balance between performance + and test isolation. + + You can extend this: + @pytest.fixture(scope='session') + def chrome_options(chrome_options) -> ChromeOptions: + chrome_options.add_argument("--option-name") + return chrome_options + + or override it entirely: + @pytest.fixture(scope='session') + def chrome_options() -> ChromeOptions: + return ChromeOptions() """ + options = webdriver.ChromeOptions() - def __init__(self, report, excinfo, doc): - self.report = report - self.excinfo = excinfo - self.doc = doc + # Our default options promote a balance between + # performance and test isolation. + options.add_argument("--headless") + options.add_argument("--incognito") + options.add_argument("--disable-application-cache") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + return options @pytest.fixture(scope="session") -def selenium_server(request) -> Optional[str]: - """Returns a non-empty string or None""" - return ( - # CLI arg takes precedence - request.config.getoption("selenium_server") - # Otherwise, we look for a non-empty string - or os.environ.get('SELENIUM_SERVER', '').strip() - # If the result is still Falsey, we always return None. - or None - ) +def browser_args(selenium_server, chrome_options) -> Dict[str, Optional[Union[webdriver.ChromeOptions, str]]]: + args = {"options": chrome_options} + if selenium_server: + args["command_executor"] = f"http://{selenium_server}/wd/hub" + return args @pytest.fixture(scope="session") -def template_filename(): - return TEMPLATE_FILE +def browser_class(browser_args) -> Type[BrowserRecorder]: + if browser_args.get("command_executor"): + return Remote + return Chrome @pytest.fixture(scope="session") -def chrome_options(): - options = webdriver.ChromeOptions() - options.headless = True - options.add_experimental_option("w3c", False) - options.add_argument("--incognito") - options.add_argument("--disable-application-cache") - return options +def build_browser(browser_args, browser_class) -> Callable[..., BrowserRecorder]: + logger.info( + "Browser generator will build instances using the following settings:\n" + f" Browser class: {browser_class.__name__}\n" + f" Browser args: {dict(browser_args)}" + ) + + def inner() -> BrowserRecorder: + return browser_class(**browser_args) + + return inner @pytest.fixture(scope="session") -def session_browser(selenium_server, chrome_options): - if selenium_server and selenium_server.strip(): # pragma: no cover - logging.info(f"Creating connection to remote selenium server {selenium_server}") - browser = Remote( - options=chrome_options, command_executor=f"http://{selenium_server}/wd/hub" - ) - else: - browser = Chrome(options=chrome_options) +def session_browser(build_browser) -> BrowserRecorder: + """ + A browser instance that is kept open for the entire test run. + Only instantiated if it is used, but by default will be used in both the + 'browser' and 'class_browser' fixtures, unless "disable_session_browser=1" + is set in the environment. + """ + browser = build_browser() try: yield browser finally: @@ -155,7 +180,7 @@ def session_browser(selenium_server, chrome_options): @pytest.fixture(scope="session") -def browser_context(request: FixtureRequest) -> Callable[..., Chrome]: +def browser_context() -> Callable[..., Chrome]: """ This fixture allows you to create a fresh context for a given browser instance. @@ -174,14 +199,7 @@ def test_something(browser_context): """ @contextmanager - def inner( - browser: Optional[Chrome] = None, cookie_urls: Optional[List[str]] = None - ): - if not browser: - # Only loads this fixture if no override is present - # to avoid creating session_browsers if the - # dependent does not to. - browser = request.getfixturevalue('session_browser') + def inner(browser: BrowserRecorder, cookie_urls: Optional[List[str]] = None) -> BrowserRecorder: browser.open_tab() cookie_urls = cookie_urls or [] try: @@ -196,73 +214,136 @@ def inner( return inner -@pytest.fixture(scope="class") -def class_browser(request, browser_context) -> Chrome: - with browser_context() as browser: +if _SETTINGS.disable_session_browser: + logger.warning("Disabling auto-use of 'session_browser', this may significantly decrease test performance.") + + @pytest.fixture(scope="session") + def session_browser_disabled() -> bool: + return True + + @pytest.fixture + def browser(build_browser) -> BrowserRecorder: + """Creates a fresh instance of the browser using the configured chrome_options fixture.""" + browser = build_browser() + try: + yield browser + finally: + browser.quit() + + @pytest.fixture(scope="class") + def class_browser(build_browser, request) -> BrowserRecorder: + """ + Creates a fresh instance of the browser for use in the requesting class, using the configure + chrome_options fixture. + """ + browser = build_browser() request.cls.browser = browser - yield browser + try: + yield browser + finally: + browser.quit() +else: + logger.info( + "Enabling auto-use of 'session_browser'; if your tests appear stuck, try disabling " + "by setting 'disable_session_browser=1' in your environment." + ) -@pytest.fixture -def browser(browser_context) -> BrowserRecorder: - """ - The default browser fixture. This default behavior will lazily - instantiate a `session_browser` if one does not exist. To - override this behavior and create your own context, you can - redefine this fixture. - """ - with browser_context() as browser: - yield browser + @pytest.fixture + def browser(session_browser, browser_context) -> BrowserRecorder: + """ + Creates a function-scoped tab context for the session_browser which cleans + up after itself (to the best of its ability). If you need a fresh instance + each test, you can set `disable_session_browser=1` in your environment. + """ + with browser_context(session_browser) as browser: + yield browser + + @pytest.fixture(scope="class") + def class_browser(request, session_browser, browser_context) -> BrowserRecorder: + """ + Creates a class-scoped tab context and binds it to the requesting class + as 'self.browser'; this tab will close once all tests in the class have run, + and will clean up after itself (to the best of its ability). If you need + a fresh browser instance for each class, you can set `disable_session_browser=1` in your + environment. + """ + with browser_context(session_browser) as browser: + request.cls.browser = browser + yield browser + + @pytest.fixture(scope="session") + def session_browser_disabled() -> bool: + return False @pytest.fixture(scope="session") def report_dir(request): - return request.config.getoption("report_dir") + dir_ = request.config.getoption("report_dir") + os.makedirs(dir_, exist_ok=True) + return dir_ @pytest.fixture(scope="session", autouse=True) -def report_generator(generate_report, report_dir) -> str: - """Fixture returning the directory containing our report files. Overridable using `--report-dir`""" - os.makedirs(report_dir, exist_ok=True) - worker_file = tempfile.mktemp(prefix="worker.", dir=report_dir) - Path(worker_file).touch() - try: - yield report_dir - finally: - os.remove(worker_file) - workers = list(f for f in os.listdir(report_dir) if f.startswith("worker.")) - if not workers: - generate_report() +def report_generator(report_dir, test_report): + with tempfile.NamedTemporaryFile(prefix="worker.", dir=report_dir) as worker_file: + suffix = ".".join(worker_file.name.split(".")[1:]) + yield + test_report.stop_timer() + exporter = ReportExporter() + workers = list(f for f in os.listdir(report_dir) if f.startswith("worker.")) + worker_results = list(f for f in os.listdir(report_dir) if f.endswith(".result.json")) + if not workers: + test_report.outcome = Outcome.success + # Aggregate worker reports into this "root" report. + for result_file in [os.path.join(report_dir, f) for f in worker_results]: + worker_report = Report.parse_file(result_file) + test_report.results.extend(worker_report.results) + os.remove(result_file) + exporter.export_all(test_report, report_dir) + else: + # If there are other workers, only export the report json of the + # current worker. The last worker running will be responsible for aggregating and reporting results. + exporter.export_json(test_report, report_dir, dest_filename=f"{suffix}.result.json") @pytest.fixture(autouse=True) -def report_test(report_generator, request): +def report_test(report_generator, request, test_report): """ Print the results to report_file after a test run. Without this, the results of the test will not be saved. - You can ensure this is always run by including the following in your conftest.py: - - @pytest.fixture(autouse=True) - def report_test(report_test): - return report_test """ - time1 = str(datetime.datetime.now()) - yield - time2 = str(datetime.datetime.now()) - try: - # TODO: Sometimes this line causes problems, but I can't - # remember the context surrounding it. I think if there - # is an error setting up a test fixture, `report_call` is not - # defined, or something. Anyway, it'd be a great - # to figure out a graceful solution. - # In the meantime this'll be nicer output. - nodeid = request.node.report_call.report.nodeid - except Exception: # pragma: no cover + tb = None + console_logs = [] + timer: Timed + with Timed() as timer: + yield + + call_summary = getattr(request.node, "report_result", None) + + if call_summary: + doc = call_summary.doc + test_name = call_summary.report.nodeid + outcome = Outcome.failure if call_summary.report.failed else Outcome.success + if call_summary and call_summary.excinfo and not tb: + outcome = Outcome.failure + exception: BaseException = call_summary.excinfo.value + exception_msg = f"{exception.__class__.__name__}: {str(exception)}" + if isinstance(exception, BrowserError): + if exception.orig: + tb = f"{exception_msg}\n{exception.orig=}" + console_logs = [log.get("message", "") for log in exception.logs] + if not tb: + tb = f"{exception_msg}\n(No traceback is available)" + + else: logging.error( - f"Unable to extract nodeid from node: {request.node}; " - "not preparing report segment" + f"Test {request.node} reported no outcomes; " + f"this usually indicates a fixture caused an error when setting up the test." ) - return - is_failed = request.node.report_call.report.failed + doc = None + test_name = f"{request.node.name}" + outcome = Outcome.never_started + # TODO: Figure out a way to include class docs if they exist # class TestFooBar: # """ @@ -272,105 +353,35 @@ def report_test(report_test): # """and baz is bop""" # do_work('bop') # The report output should then read "When foo is bar and baz is bop" - doc = request.node.report_call.doc - slug = re.sub(r"\W", "-", nodeid) - header = ResultHeader(link=slug, is_failed=is_failed, description=nodeid) - failure = None - if is_failed: - exc_info = request.node.report_call.excinfo - if isinstance(exc_info.value, BrowserError): - e = exc_info.value - failure = ResultFailure( - message=e.message, - url=e.url, - loglines=[log.get("message", "") for log in e.logs], - ) - else: - failure = ResultFailure(message=str(exc_info)) - - report = ResultAttributes( - link=slug, - doc=doc, - nodeid=nodeid, + + result = TestResult( pngs=BrowserRecorder.pngs, - failure=failure, - time1=time1, - time2=time2, + test_name=test_name, + test_description=doc, + outcome=outcome, + start_time=timer.start_time, + end_time=timer.end_time, + traceback=tb, + console_errors=console_logs, ) + BrowserRecorder.pngs = [] - filename = os.path.join(report_generator, f"result.{slug}.html") - headerfile = os.path.join(report_generator, f"head.{slug}.html") - - with open(headerfile, "w") as fd: - fd.write(header.json()) - with open(filename, "w") as fd: - fd.write(report.json()) - BrowserRecorder.pngs.clear() + test_report.results.append(result) @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """ This gives us hooks from which to report status post test-run. - Import this into your conftest.py. """ outcome = yield + report = outcome.get_result() if report.when == "call": doc = getattr(getattr(item, "function", None), "__doc__", None) - item.report_call = ReportResult(report=report, excinfo=call.excinfo, doc=doc) - - -def lettergen(): - """ - Used to generate unique alpha tags. - :return: A, B, C, ..., AA, AB, AC, ..., BA, BB, BC, ... - """ - for repeat in range(1, 10): - for item in itertools.product(ascii_uppercase, repeat=repeat): - yield "".join(item) + item.report_result = ReportResult(report=report, excinfo=call.excinfo, doc=doc) @pytest.fixture(scope="session") def report_title(request) -> str: - return request.config.getoption("report_title", default="Webdriver Recorder Summary") - - -@pytest.fixture(scope="session", autouse=True) -def generate_report(template_filename, report_title, report_dir): - """ - Uses the included HTML template to generate the final report, using the results found in `report_dir`. Can be - called explicitly in order to do this at any time. - """ - - def inner(output_dir: Optional[str] = None): - output_dir = output_dir or report_dir - with open(template_filename) as fd: - template = jinja2.Template(fd.read()) - - template.globals.update( - {"date": str(datetime.datetime.now()), "lettergen": lettergen, "zip": zip} - ) - - headers = iterfiles(output_dir, "head.") - results = iterfiles(output_dir, "result.") - stream = template.stream(headers=headers, results=results, project=report_title) - artifact = os.path.join(output_dir, "index.html") - stream.dump(artifact) - logging.info(f"Created report: {artifact}") - - return inner - - -def iterfiles(dir, prefix): - """ - Iterate through the objects contained in files starting with prefix. - Delete afterwards. - """ - files = (f for f in os.listdir(dir) if f.startswith(prefix)) - for filename in sorted(files): - filename = os.path.join(dir, filename) - with open(filename) as fd: - data = json.load(fd) - yield data - os.remove(filename) + return request.config.getoption("report_title") diff --git a/webdriver_recorder/report.template.html b/webdriver_recorder/report.template.html deleted file mode 100644 index 37b36a7..0000000 --- a/webdriver_recorder/report.template.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - {{project}} Storyboard - - - - - - - -

Results for {{project}} Scenarios

- - -

Generated {{ date }}

- - {% for result in results %} - {% set index = loop.index %} -

- Test #{{index}}: {{ result.time1 }} to {{ result.time2 }} -

-

{{ result.doc|safe or result.nodeid }}

- {% if result.doc %} -

{{result.nodeid}}

- {% endif %} - {% if result.failure %} -
-

The following action failed: {{ result.failure.message }}

- {% if result.failure.url %} -

Current url: {{result.failure.url}}

-

Browser logs: - {% for logline in result.failure.loglines %} - {{logline}}
- {% endfor %} -

- {% endif %} -
- {% endif %} - {% for sequence, png in zip(lettergen(), result.pngs) %} -
-
#{{index}}{{sequence}}
- -
- {% endfor %} - {% endfor %} - - diff --git a/webdriver_recorder/report_exporter.py b/webdriver_recorder/report_exporter.py new file mode 100644 index 0000000..d87b73d --- /dev/null +++ b/webdriver_recorder/report_exporter.py @@ -0,0 +1,89 @@ +import logging +import os +import shutil +from typing import NoReturn + +import jinja2 + +from webdriver_recorder.models import Outcome, Report + +logger = logging.getLogger(__name__) +here = os.path.dirname(os.path.abspath(__file__)) + + +class ReportExporter: + def __init__( + self, + template_dir: str = os.path.join(here, "templates"), + root_template: str = "report.html", + static_assets_dir: str = "static", + ): + self.template_dir = template_dir + self.relative_static_dir = static_assets_dir + self.abs_input_static_dir = os.path.join(self.template_dir, static_assets_dir) + self.static_assets = [] + if static_assets_dir: + if os.path.exists(self.abs_input_static_dir): + self.static_assets = [ + os.path.join(self.abs_input_static_dir, asset) for asset in os.listdir(self.abs_input_static_dir) + ] + else: # pragma: no cover + logger.warning( + f"Expected {self.abs_input_static_dir} to exist, but it does not. Skipping static asset " + f"copying. You can disable this warning by setting `static_assets_dir=None` when creating " + f"your ReportExporter instance." + ) + + self.env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir)) + self.template = self.env.get_template(root_template) + + def export_json( + self, report: Report, dest_directory: str, dest_filename: str = "report.json", exclude_image_data: bool = False + ) -> str: + if report.num_failures > 0: + report.outcome = Outcome.failure + + exclude = None + if exclude_image_data: + # This is a complex pydantic filter meaning: + # for each result in report, + # for each png in result, + # ignore the 'base64' field. + logger.info("Stripping base64 image data from report.json") + exclude = {"results": {"__all__": {"pngs": {"__all__": {"base64"}}}}} + filename = os.path.join(dest_directory, dest_filename) + with open(filename, "w") as f: + f.write(report.json(indent=4, exclude=exclude)) + logger.info(f"Report JSON saved to {filename}") + return filename + + def export_static(self, dest_directory: str): + abs_dest_static_dir = os.path.join(dest_directory, self.relative_static_dir) + for asset in self.static_assets: + destination = os.path.abspath(os.path.join(abs_dest_static_dir, os.path.basename(asset))) + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copyfile(asset, destination) + logger.info(f"Copied static asset {asset} to {destination}") + + def export_all(self, report: Report, dest_directory: str): + # If we write images to disk, don't include base64 of the + # images in the exported json. + # This will still preserve image metadata, including its filename. + + self.export_json(report, dest_directory=dest_directory, exclude_image_data=True) + self.export_images(report, dest_directory=dest_directory) + self.export_static(dest_directory=dest_directory) + self.export_html(report, dest_directory=dest_directory) + + def export_html(self, report: Report, dest_directory: str, dest_filename: str = "index.html"): + dest_filename = os.path.join(dest_directory, dest_filename) + stream = self.template.stream(report=report) + stream.dump(dest_filename) + logger.info(f"Exported report HTML to {dest_filename}") + + @classmethod + def export_images(cls, report: Report, dest_directory: str) -> NoReturn: + for test in report.results: + for image in filter(lambda i: i.base64, test.pngs): + image.save(dest_directory) + logger.info(f"Saved image {image.url}") diff --git a/webdriver_recorder/templates/help.html b/webdriver_recorder/templates/help.html new file mode 100644 index 0000000..3bfc1ac --- /dev/null +++ b/webdriver_recorder/templates/help.html @@ -0,0 +1,68 @@ +{% set help_content %} +
Test Summary
+ +
Test Details
+ +
Report data:
+ +{% endset %} + + +{% with %} + {% set modal_id="help-modal" %} + {% set modal_title %} + + Help — WebdriverRecorder Report 5.0 + {% endset %} + {% set modal_body %} + {{ help_content|safe }} + {% endset %} + {% include 'modal.html' %} +{% endwith %} diff --git a/webdriver_recorder/templates/modal.html b/webdriver_recorder/templates/modal.html new file mode 100644 index 0000000..efda643 --- /dev/null +++ b/webdriver_recorder/templates/modal.html @@ -0,0 +1,60 @@ +{# +## template variables: +## - modal_id: A unique id for the modal. (e.g., foo-bar-modal) +## +## - modal_title: An h5 title for your modal. You may provide HTML here, just note +## it will render inside of the h5. +## +## - modal_body: The inner body of the modal. You may provide HTML here. This will +## render inside a div. +## +## provides the following classes for styling: +## +## - -label (e.g., foo-bar-modal-label) +## The inner content of the title. +## - -body (e.g., foo-bar-modal-body) +## The inner content of the modal body. +## +## Use: +## {% with %} +## {% set modal_id="foo-bar" %} +## {% set modal_title="Foos that Bar" %} +## {% set modal_body %} +##

Lorem ipsum ipso facto blah de blah de bloop

+## {% endset %} +## {% include 'modal.html' %} +## {% endwith %} +#} + + diff --git a/webdriver_recorder/templates/modal_link.html b/webdriver_recorder/templates/modal_link.html new file mode 100644 index 0000000..9fea414 --- /dev/null +++ b/webdriver_recorder/templates/modal_link.html @@ -0,0 +1,34 @@ +{# +## template variables: +## - modal_id: A unique id for the modal. (e.g., foo-bar-modal) +## - button_id: (Optional!) If defined, gives your button this id. +## See the provided style handle below. +## - button_content: The inner content for your button. +## You may provide HTML here. +## +## provides the following classes for styling: +## +## - -link -- Note that this will (by default) cascade to +## all instances of a link to that specific modal id. +## If you need to further refine your styling, you +## should also include a button_id with your render. +## +## Use: +## {% with %} +## {% set modal_id="foo-bar" %} +## {% set button_content %} +## +## Whoa! +## {% endset %} +## {% include 'modal_link.html' %} +## {% endwith %} +#} + diff --git a/webdriver_recorder/templates/report.html b/webdriver_recorder/templates/report.html new file mode 100644 index 0000000..b6a9358 --- /dev/null +++ b/webdriver_recorder/templates/report.html @@ -0,0 +1,34 @@ +{# Index of template variables: + project: str - The name of the project being tested + report_title: str - The title of the report that was generated +#} + + + + + + + + + + {{ report.title }} + + +
+ {% include 'report_header.html' %} +
+ {% for result in report.results %} + {% include 'test_case.html' %} + {% endfor %} +
+
+{% include 'toast-copied-to-clipboard.html' %} +{% include 'help.html' %} + + diff --git a/webdriver_recorder/templates/report_header.html b/webdriver_recorder/templates/report_header.html new file mode 100644 index 0000000..8c3ed08 --- /dev/null +++ b/webdriver_recorder/templates/report_header.html @@ -0,0 +1,68 @@ +
+
+
+

+ {% if report.outcome.value == 'success' %} + Success + {% else %} + {{ report.outcome.value }} + {% endif %} + {{ report.title }} +

+
+
+ {% with %} + {% set modal_id="help-modal" %} + {% set button_content %} + + Help + {% endset %} + {% include 'modal_link.html' %} + {% endwith %} +
+
+
+
+ {% if report.outcome.value != 'success' %} +
+
+ + +
+
+ {% endif %} +
+
+ Started {{ report.start_time.strftime('%Y-%m-%d %H:%M:%S') }}, ran for + {{ report.duration }}. +
+ + +
+
+
+
+
+
+ pytest args +
+
+ +
+
+
+
diff --git a/webdriver_recorder/templates/static/report.js b/webdriver_recorder/templates/static/report.js new file mode 100644 index 0000000..a903c14 --- /dev/null +++ b/webdriver_recorder/templates/static/report.js @@ -0,0 +1,114 @@ + +const expandIconClass = "bi-chevron-bar-expand"; +const collapseIconClass = "bi-chevron-bar-contract"; +const collapseHideEvent = new Event('hide.bs.collapse'); +const collapseShowEvent = new Event('show.bs.collapse'); + +function zoomToAnchor() { + // When called, will expand a test case that is include in the URL + // as an anchor. e.g., "index.html#test_foo-test_bar-baz" would + // expand the "test_foo-test_bar-baz-collapse" element, + // then scroll it into view. + let anchor = window.location.hash.substr(1); + if (anchor) { + let element = document.getElementById(anchor + "-collapse"); + bootstrap.Collapse.getOrCreateInstance(element, { toggle: true}).show(); + element.scrollIntoView(); + } +} + +function bindFailureToggleEvents() { + let showFailuresToggle = document.getElementById('show-failures-slider') + if (showFailuresToggle !== null) { + showFailuresToggle.addEventListener('change', function(event) { + let elements = [].slice.call( + document.querySelectorAll('.result-success') + ); + let showElement = !this.checked; // 'checked' means 'do not show successes' + elements.map(function(e) { + e.classList.toggle('show', showElement); + }); + }); + } +} + +function toggleElementCollapse(element, state) { + let instance = bootstrap.Collapse.getOrCreateInstance(element, {toggle: state}); + if (state) { + instance.show(); + } else { + instance.hide(); + } +} + +function collapseAll(elementList) { + elementList.map(function(e) { + toggleElementCollapse(e, false)} + ); +} + +function expandAll(elementList) { + elementList.map(function(e) { + toggleElementCollapse(e, true)} + ); +} + +function bindCollapseEvents() { + let collapseContainerList = [].slice.call( + document.querySelectorAll('.collapse') + ); + collapseAll(collapseContainerList); + + let collapseAllElement = document.getElementById('collapse-all'); + let expandAllElement = document.getElementById('expand-all'); + + expandAllElement.addEventListener('click', function() { + expandAll(collapseContainerList); + }); + + collapseAllElement.addEventListener('click', function() { + collapseAll(collapseContainerList); + }); + + collapseContainerList.map(function(element) { + let collapseIcon = element.parentElement + .getElementsByClassName('collapse-toggle-icon')[0]; + let collapseAnchor = element.parentElement + .getElementsByClassName('collapse-toggle-anchor')[0]; + element.addEventListener('shown.bs.collapse', function() { + collapseIcon.classList.replace(expandIconClass, collapseIconClass); + collapseAnchor.setAttribute('title', 'Collapse'); + }) + element.addEventListener('hidden.bs.collapse', function() { + collapseIcon.classList.replace(collapseIconClass, expandIconClass); + collapseAnchor.setAttribute('title', 'Expand'); + }) + }); +} + +function copyToClipboard(value, target) { + // Copies the given value to the user's clipboard. + // Optionally accepts `target: string` which can be HTML, + // which provides details on what was copied. See example + // use in toast.html + navigator.clipboard.writeText(value); + let toastElement = document.getElementById('toast-clipboard-toast'); + let toastTargetElement = document.getElementById('clipboard-copy-target'); + let toastValueElement = document.getElementById('clipboard-copy-value'); + let toast = bootstrap.Toast.getOrCreateInstance(toastElement); + target = target ? target : ""; + toastValueElement.innerHTML = value; + toastTargetElement.innerHTML = target; + toast.show(); +} + +function copyUrlToClipboard(path) { + let url = window.location + path + copyToClipboard(url, 'URL'); +} + +document.addEventListener("DOMContentLoaded", function(event) { + bindFailureToggleEvents(); + bindCollapseEvents(); + zoomToAnchor(); +}); diff --git a/webdriver_recorder/templates/static/style.css b/webdriver_recorder/templates/static/style.css new file mode 100644 index 0000000..1e50c95 --- /dev/null +++ b/webdriver_recorder/templates/static/style.css @@ -0,0 +1,201 @@ +body { + padding: 1em; + font-size: 12pt; +} + +#content { + width: 75%; +} + +#report-summary { + padding: 1em; + margin-top: 2em; + font-size: 90%; + margin-bottom: 1em; +} + +#toggle-failures { + margin: 1em; +} + +.bi-link.text-primary { + font-size: 14pt; +} + +.toast { + /* By default, this is set to 350px, too short for URLs. */ + width: max-content; + max-width: 100%; +} + +.toast .text-success code { + /* On some browsers (or perhaps all?) the bootstrap + * default * shows up with pink text, which can lead + * one to believe there has been an error when + * it flashes quickly across your screen. + */ + color: #4b2e83; /* UW Purple */ +} + +.result-success { + display: none; + transition: opacity 0.3s linear; +} + +.cursor-pointer { + cursor: pointer; +} + +.snap-caption { + font-size: 75%; + padding-top: 1em; + border-top: #4b2e83; /* UW Purple */ +} + +.show { + display: block; +} + +.card { + margin-bottom: 1em; +} + +.card.screenshot { + max-width: 15rem; + max-height: 25rem; +} + +.test-result .card-header { + padding: 0; +} + +#help-modal-body ul li { + margin-bottom: 2em; +} + +.card.screenshot img.img-thumbnail { + border-top: none; + border-right: none; + border-left: none; + border-bottom: none; + max-width: 13rem; + max-height: 18rem; + object-fit: contain; + object-position: top; + padding: .25rem; +} + +.card-footer.image-link { + margin-top: auto; +} + +code.log-display { + display: block; + white-space: pre-wrap; +} + +.image-link a { + color: #4b2e83; /* UW Purple */ +} + +.image-link a:visited { + color: #4b2e83; /* UW Purple */ +} + +.image-link a:hover { + color: #85754d; /*UW Gold */ +} + +input.argument-display { + font-family:monospace; + font-size:85%; + color: #fff; +} +input.argument-display::placeholder { + color: #ddd; +} + +/** + The components below create a slider toggle. + The styles were taken from W3C and can be + consulted at: + https://www.w3schools.com/howto/howto_css_switch.asp +*/ + +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 3.5em; + height: 2em; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.modal-header { + overflow-wrap: anywhere; +} + +.slider:before { + position: absolute; + content: ""; + height: 1.5em; + width: 1.5em; + left: .25em; + bottom: .25em; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: #4b2e83; +} + +input:checked + .slider:before { + -webkit-transform: translateX(1.5em); + -ms-transform: translateX(1.5em); + transform: translateX(1.5em); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 2em; +} + +.slider.round:before { + border-radius: 50%; +} + +.slider-label { + cursor: pointer; + display: inline-block; + position: relative; + top: .25em; +} + +.console-log-container code { + display:block; + background-color: #ccc; + border-radius: .5em; + padding: .5em; + margin: 1em; +} diff --git a/webdriver_recorder/templates/test_case.html b/webdriver_recorder/templates/test_case.html new file mode 100644 index 0000000..7940405 --- /dev/null +++ b/webdriver_recorder/templates/test_case.html @@ -0,0 +1,101 @@ +{# TODO: Documentation #} +{# TODO: docstring from test as banner #} +{% set console_log_modal_id=result.test_id ~ '-console-log' %} +{% set traceback_modal_id=result.test_id ~ '-error-traceback' %} +{% set result_anchor = '#' ~ result.test_id %} + +
+
+ +
+ + +
+
+
+
+ {% if result.test_description %} +
+ {{ result.test_description }} +
+ {% endif %} +
+ {% if result.traceback %} + {% with %} + {% set modal_id=traceback_modal_id %} + {% set button_content="View python traceback" %} + {% include 'modal_link.html' %} + {% endwith %} + {% endif %} + {% if result.console_errors %} + {% with %} + {% set modal_id=console_log_modal_id %} + {% set button_content="View browser console errors" %} + {% include 'modal_link.html' %} + {% endwith %} + {% endif %} +
+ {% if result.pngs %} + {% include 'test_screenshots.html' %} + {% else %} +

No screenshots were captured for this test.

+ {% endif %} +
+
+
+ +{% if result.console_errors %} + {% with %} + {% set modal_id=console_log_modal_id %} + {% set modal_title=result.test_name ~ " browser console errors" %} + {% set modal_body %} +

+ These errors were in the browser console logs at the time of + the failure. This does not always indicate they are related + to the test failure. +

+
+ {% for error in result.console_errors %} + {{ error }} + {% endfor %} +
+ {% endset %} + {% include 'modal.html' %} + {% endwith %} +{% endif %} + +{% if result.traceback %} + {% with %} + {% set modal_id=traceback_modal_id %} + {% set modal_title=result.test_name ~ " error traceback" %} + {% set modal_body %} +
+ {{ result.traceback }} +
+ {% endset %} + {% include 'modal.html' %} + {% endwith %} +{% endif %} diff --git a/webdriver_recorder/templates/test_screenshots.html b/webdriver_recorder/templates/test_screenshots.html new file mode 100644 index 0000000..cfc351c --- /dev/null +++ b/webdriver_recorder/templates/test_screenshots.html @@ -0,0 +1,19 @@ +
+ {% for png in result.pngs %} +
+
+ Screenshot #{{ loop.index }} for test "{{ result.test_name }}" +
+ {{ png.caption or " " }} +
+ +
+
+ {% endfor %} +
diff --git a/webdriver_recorder/templates/toast-copied-to-clipboard.html b/webdriver_recorder/templates/toast-copied-to-clipboard.html new file mode 100644 index 0000000..844d01f --- /dev/null +++ b/webdriver_recorder/templates/toast-copied-to-clipboard.html @@ -0,0 +1,16 @@ +
+ +