Combined fe and be (#479)

* fix main.js conflicts

* fix js test

* fix resource (main.js) test

* revert change to report extra

* filters style

* css and dom brush up

* Buildable app

* always store data in html

* Always store data blob in file

* json dump test data

* read data from dom element

* manually initialize state

* minimalistic dataset

* simplify included files

* Handle report extras

* Handle python report hooks

* imgviewer

* present name in image viewer and open img on click

* setup linter for project

* conform to styles

* show video in imageviewer (#14)

* show video in imageviewer

* Chore: Pluralize extra (#15)

* Add extras.HTML

* Move outcome handling to backend (#18)

* Move outcome handling to backend

* Pass in text version of longrepr if present

* make collapse/expand all functional (#20)

* make collapse/expand all functional

* only create links for text, url and json (#22)

* make filter search case insensitive (#21)

* make filter search case insensitive

* use sessionStorage to prevent preferences to be reapplied on new reports

* avoid multiple event bindings + fix filter bug

* Collapse individual row

Co-authored-by: Jim Brännlund <jim.brannlund@gmail.com>

* add no log output captured string

* Query params (#25)

* Add query params

* adjust tests (#26)

* Duration format (#27)

* adjust tests

* build format handler

* remove dependency

---------

Co-authored-by: Jim Brännlund <jimbrannlund@fastmail.com>

* Beyondevil/cleanup (#28)

* chore: Cleanup branch before merge

* chore: Fix duration and CI

* Fix pre-commit issues

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Viktor Gustafsson <vikt.gust@gmail.com>
This commit is contained in:
Jim Brännlund 2023-03-05 16:18:52 +01:00 committed by GitHub
parent aa85f41296
commit 0408b0d504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 9139 additions and 2644 deletions

View File

@ -1,32 +1,30 @@
{
"env": {
"browser": true,
"commonjs": true,
"es2021": true
},
"extends": "eslint:recommended",
"extends": [
"google"
],
"parserOptions": {
"ecmaVersion": 12
"ecmaVersion": "latest"
},
"rules": {
"array-bracket-spacing": "error",
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": "error",
"camelcase": "error",
"camelcase": "off",
"class-methods-use-this": "error",
"consistent-return": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"grouped-accessor-pairs": "error",
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"indent": [ "error", 4 ],
"linebreak-style": [ "error", "unix" ],
"max-len": ["error", { "code": 120 }],
"no-caller": "error",
"no-console": "error",
"no-empty-function": "error",
@ -43,17 +41,25 @@
"no-throw-literal": "error",
"no-undefined": "error",
"no-unreachable-loop": "error",
"no-unused-expressions": "error",
"no-unused-expressions": "off",
"no-useless-backreference": "error",
"no-useless-concat": "error",
"no-var": "error",
"object-curly-spacing": [
"error",
"always",
{
"arraysInObjects": true
}
],
"prefer-const": "error",
"prefer-promise-reject-errors": "error",
"require-atomic-updates": "error",
"require-await": "error",
"require-jsdoc" : 0,
"semi": [
"error",
"always"
"never"
],
"quotes": [
"error",

View File

@ -18,34 +18,36 @@ jobs:
name: Build Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: '3.10'
- name: Install tox
run: python -m pip install --upgrade tox
- name: Build docs with tox
run: python -m tox -e docs
run: tox -e docs
tests:
uses: ./.github/workflows/tests.yml
publish:
if: github.repository_owner == 'pytest-dev'
name: Publish to PyPI registry
needs:
- tests
runs-on: ubuntu-latest
env:
PY_COLORS: 1
TOXENV: packaging
steps:
- uses: actions/setup-python@v3
- name: Switch to using Python 3.10 by default
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: '3.10'
- name: Install tox
run: python -m pip install --user tox
- name: Check out src from Git
uses: actions/checkout@v3
with:
@ -62,6 +64,7 @@ jobs:
) &&
1 || 0
}}
- name: Drop Git tags from HEAD for non-tag-create events
if: >-
github.event_name != 'create' ||
@ -70,8 +73,16 @@ jobs:
git tag --points-at HEAD
|
xargs git tag --delete
- name: Build dists
run: python -m tox
- name: Build and Check Package
uses: hynek/build-and-inspect-python-package@v1
- name: Download Package
uses: actions/download-artifact@v3
with:
name: Packages
path: dist
- name: Publish to test.pypi.org
if: >-
(
@ -84,14 +95,15 @@ jobs:
github.event_name == 'create' &&
github.event.ref_type == 'tag'
)
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.testpypi_password }}
repository_url: https://test.pypi.org/legacy/
- name: Publish to pypi.org
if: >- # "create" workflows run separately from "push" & "pull_request"
github.event_name == 'create' &&
github.event.ref_type == 'tag'
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.pypi_password }}

View File

@ -5,123 +5,133 @@ on:
jobs:
test_python:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
name: ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
name: py37-ubuntu
python-version: 3.7
- tox-env: "py37"
python-version: "3.7"
- os: windows-latest
name: py37-windows
python-version: 3.7
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: windows-latest
# name: py37-windows
# python-version: 3.7
- os: macOS-latest
name: py37-mac
python-version: 3.7
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: macOS-latest
# name: py37-mac
# python-version: 3.7
- os: ubuntu-latest
name: py38-ubuntu
python-version: 3.8
- tox-env: "py38"
python-version: "3.8"
- os: windows-latest
name: py38-windows
python-version: 3.8
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: windows-latest
# name: py38-windows
# python-version: 3.8
- os: macOS-latest
name: py38-mac
python-version: 3.8
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: macOS-latest
# name: py38-mac
# python-version: 3.8
- os: ubuntu-latest
name: py39-ubuntu
python-version: 3.9
- tox-env: "py39"
python-version: "3.9"
- os: windows-latest
name: py39-windows
python-version: 3.9
- tox-env: "py310"
python-version: "3.10"
- os: macOS-latest
name: py39-mac
python-version: 3.9
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: windows-latest
# name: py39-windows
# python-version: 3.9
- os: ubuntu-latest
name: py310-ubuntu
python-version: '3.10'
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: macOS-latest
# name: py39-mac
# python-version: 3.9
- os: windows-latest
name: py310-windows
python-version: '3.10'
- tox-env: "pypy3"
python-version: "pypy3.9"
skip-coverage: true
- os: macOS-latest
name: py310-mac
python-version: '3.10'
- os: ubuntu-latest
name: pypy3-ubuntu
python-version: pypy-3.8
- os: windows-latest
name: pypy3-windows
python-version: pypy-3.8
# https://github.com/pytest-dev/pytest-html/issues/585
# - os: windows-latest
# name: pypy3-windows
# python-version: pypy3
# https://github.com/pytest-dev/pytest-html/issues/482
- os: macOS-latest
name: pypy3-mac
python-version: pypy-3.8
# - os: macOS-latest
# name: pypy3-mac
# python-version: pypy-3.8
- os: ubuntu-latest
name: devel-ubuntu
python-version: 3.9
- tox-env: "devel"
python-version: "3.11-dev"
steps:
- name: Set Newline Behavior
run : git config --global core.autocrlf false
run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- name: Start chrome
run: ./start
- name: Use Node.js
uses: actions/setup-node@v3
with:
fetch-depth: 0
node-version: '16.x'
- name: Install Dependencies
run: npm ci
- name: Build app
run: npm run build:ci
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: ${{ matrix['python-version'] }}
python-version: ${{ matrix.python-version }}
- name: Install tox
run: python -m pip install --upgrade tox
- name: Get Tox Environment Name From Matrix Name
uses: rishabhgupta/split-by@v1
id: split-matrix-name
with:
string: '${{ matrix.name }}'
split-by: '-'
- name: Test with coverage
if: "! contains(matrix.name, 'pypy3')"
run: python -m tox -e ${{ steps.split-matrix-name.outputs._0}}-cov
if: ${{ ! matrix.skip-coverage }}
run: tox -e ${{ matrix.tox-env }}-cov
- name: Test without coverage
if: "contains(matrix.name, 'pypy3')"
run: python -m tox -e ${{ steps.split-matrix-name.outputs._0}}
if: ${{ matrix.skip-coverage }}
run: tox -e ${{ matrix.tox-env }}
# TODO: https://github.com/pytest-dev/pytest-html/issues/481
# - name: Upload coverage to codecov
# if: github.event.schedule == ''
# uses: codecov/codecov-action@v2
# with:
# fail_ci_if_error: true
# file: ./coverage.xml
# flags: tests
# name: ${{ matrix.py }} - ${{ matrix.os }}
# verbose: true
- name: Upload coverage to codecov
if: >-
${{
! github.event.schedule &&
! matrix.skip-coverage &&
github.repository_owner == 'pytest-dev'
}}
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
files: ./coverage.xml
flags: tests
name: ${{ matrix.tox-env }}
verbose: true
test_javascript:
name: grunt
name: mocha
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '12.x'
node-version: '16.x'
- name: Install Dependencies
run: npm install
- name: QUnit Tests
run: npm test
run: npm ci
- name: Mocha Tests
run: npm run unit

4
.gitignore vendored
View File

@ -35,7 +35,6 @@ local.properties
# JS files/folders
## node / npm
node_modules/
package-lock.json
# MacOS files
.DS_Store
@ -51,3 +50,6 @@ Pipfile.lock
# sphinx/read the docs
docs/_build/
*.html
assets/

View File

@ -3,12 +3,14 @@ repos:
rev: 22.3.0
hooks:
- id: black
args: [--safe, --quiet]
args: [--safe, --quiet, --line-length=88]
- repo: https://github.com/asottile/blacken-docs
rev: v1.12.1
hooks:
- id: blacken-docs
additional_dependencies: [black==22.3.0]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
@ -19,6 +21,7 @@ repos:
- id: check-yaml
- id: debug-statements
language_version: python3
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
hooks:
@ -27,16 +30,19 @@ repos:
additional_dependencies:
- flake8-builtins==1.5.3
- flake8-typing-imports==1.12.0
- repo: https://github.com/asottile/reorder_python_imports
rev: v3.0.1
hooks:
- id: reorder-python-imports
args: ["--application-directories=.:src:testing", --py3-plus]
- repo: https://github.com/asottile/pyupgrade
rev: v2.32.0
hooks:
- id: pyupgrade
args: [--py3-plus]
# - repo: https://github.com/pre-commit/mirrors-eslint
# rev: v7.13.0
# hooks:
@ -44,6 +50,7 @@ repos:
# additional_dependencies:
# - eslint@7.13.0
# args: [src]
- repo: local
hooks:
- id: rst
@ -52,6 +59,7 @@ repos:
files: ^(README.rst)$
language: python
additional_dependencies: [pygments, restructuredtext_lint]
- repo: https://github.com/elidupuis/mirrors-sass-lint
rev: "5cc45653263b423398e4af2561fae362903dd45d"
hooks:

15
Pipfile
View File

@ -1,15 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
pytest = "*"
tox = "*"
flake8 = "==4.0.1" # also bump this in .pre-commit-config.yaml
black = "==22.3.0" # also bump this in .pre-commit-config.yaml
pre-commit = "*"
pytest-rerunfailures = "*"
[packages]
pytest-html = {editable = true,path = "."}

12
docker-compose.tmpl.yml Normal file
View File

@ -0,0 +1,12 @@
version: "3"
services:
chrome:
image: seleniarm/standalone-chromium:110.0
container_name: chrome
shm_size: '2gb'
ports:
- "4444:4444"
- "7900:7900"
volumes:
- "%%VOLUME%%:Z"

View File

@ -93,11 +93,11 @@ Once `npm`_ is installed, you can install all needed dependencies by running:
$ npm install
Run the following to generate the CSS:
Run the following to build the application:
.. code-block:: bash
$ npm run build:css
$ npm run build
Releasing a new version
-----------------------

File diff suppressed because one or more lines are too long

6225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,18 @@
{
"main": "Gruntfile.js",
"devDependencies": {
"grunt": "^1.3.0",
"grunt-cli": "^1.3.2",
"grunt-contrib-qunit": "^4.0.0",
"sass": "^1.29.0"
},
"scripts": {
"test": "grunt test",
"build:css": "sass --no-source-map --no-error-css src/layout/css/style.scss src/pytest_html/resources/style.css"
"unit": "mocha testing/**/unittest.js",
"build:ci": "npm run build:css && npm run build:jsapp",
"build:css": "sass --no-source-map --no-error-css src/layout/css/style.scss src/pytest_html/resources/style.css",
"build:jsapp": "browserify ./src/pytest_html/scripts/index.js > ./src/pytest_html/resources/app.js",
"build": "npm run unit && npm run build:css && npm run build:jsapp"
},
"devDependencies": {
"browserify": "^17.0.0",
"chai": "^4.3.6",
"eslint": "^8.20.0",
"eslint-config-google": "^0.14.0",
"mocha": "^10.0.0",
"sass": "^1.52.3",
"sinon": "^14.0.0"
}
}

766
poetry.lock generated Normal file
View File

@ -0,0 +1,766 @@
[[package]]
name = "assertpy"
version = "1.1"
description = "Simple assertion library for unit testing in python with a fluent API"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "attrs"
version = "22.2.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
dev = ["attrs[docs,tests]"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
tests = ["attrs[tests-no-zope]", "zope.interface"]
tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
[[package]]
name = "black"
version = "23.1.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
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\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cfgv"
version = "3.3.1"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "distlib"
version = "0.3.6"
description = "Distribution utilities"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "exceptiongroup"
version = "1.1.0"
description = "Backport of PEP 654 (exception groups)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "execnet"
version = "1.9.0"
description = "execnet: rapid multi-Python deployment"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
testing = ["pre-commit"]
[[package]]
name = "filelock"
version = "3.9.0"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
[[package]]
name = "flake8"
version = "5.0.4"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
importlib-metadata = {version = ">=1.1.0,<4.3", markers = "python_version < \"3.8\""}
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.9.0,<2.10.0"
pyflakes = ">=2.5.0,<2.6.0"
[[package]]
name = "identify"
version = "2.5.17"
description = "File identification library for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
license = ["ukkonen"]
[[package]]
name = "importlib-metadata"
version = "4.2.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markupsafe"
version = "2.1.2"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "nodeenv"
version = "1.7.0"
description = "Node.js virtual environment builder"
category = "dev"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
[package.dependencies]
setuptools = "*"
[[package]]
name = "packaging"
version = "23.0"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pathspec"
version = "0.11.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "platformdirs"
version = "2.6.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""}
[package.extras]
docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "main"
optional = false
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 = "pre-commit"
version = "2.21.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycodestyle"
version = "2.9.1"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pyflakes"
version = "2.5.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pytest"
version = "7.2.1"
description = "pytest: simple powerful testing with Python"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-metadata"
version = "2.0.4"
description = "pytest plugin for test session metadata"
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
[package.dependencies]
pytest = ">=3.0.0,<8.0.0"
[[package]]
name = "pytest-mock"
version = "3.10.0"
description = "Thin-wrapper around the mock package for easier use with pytest"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
pytest = ">=5.0"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "pytest-xdist"
version = "3.2.0"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
execnet = ">=1.1"
pytest = ">=6.2.0"
[package.extras]
psutil = ["psutil (>=3.0)"]
setproctitle = ["setproctitle"]
testing = ["filelock"]
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "setuptools"
version = "67.2.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "tox"
version = "3.28.0"
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]
colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""}
filelock = ">=3.0.0"
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
packaging = ">=14"
pluggy = ">=0.12.0"
py = ">=1.4.17"
six = ">=1.14.0"
tomli = {version = ">=2.0.1", markers = "python_version >= \"3.7\" and python_version < \"3.11\""}
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)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"]
[[package]]
name = "typed-ast"
version = "1.5.4"
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 = "4.4.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "virtualenv"
version = "20.16.2"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
distlib = ">=0.3.1,<1"
filelock = ">=3.2,<4"
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
platformdirs = ">=2,<3"
[package.extras]
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)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"]
[[package]]
name = "zipp"
version = "3.13.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = ">=3.7, <4.0.0"
content-hash = "288796311da5b8708fdc236a6a1e57c26f710715e13a2ff487beb9266eca38ac"
[metadata.files]
assertpy = [
{file = "assertpy-1.1.tar.gz", hash = "sha256:acc64329934ad71a3221de185517a43af33e373bb44dc05b5a9b174394ef4833"},
]
attrs = [
{file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
{file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
]
black = [
{file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"},
{file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"},
{file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"},
{file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"},
{file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"},
{file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"},
{file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"},
{file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"},
{file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"},
{file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"},
{file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"},
{file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"},
{file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"},
{file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"},
{file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"},
{file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"},
{file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"},
{file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"},
{file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"},
{file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"},
{file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"},
{file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"},
{file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"},
{file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"},
{file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"},
]
cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
distlib = [
{file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
]
exceptiongroup = [
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
]
execnet = [
{file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"},
{file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},
]
filelock = [
{file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"},
{file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"},
]
flake8 = [
{file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
{file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
]
identify = [
{file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"},
{file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"},
]
importlib-metadata = [
{file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"},
{file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"},
]
iniconfig = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
jinja2 = [
{file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
]
markupsafe = [
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"},
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"},
{file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"},
]
mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
mypy-extensions = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
nodeenv = [
{file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
{file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},
]
packaging = [
{file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
{file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
]
pathspec = [
{file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
{file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
]
platformdirs = [
{file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
{file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
pre-commit = [
{file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
{file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
]
py = [
{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.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"},
{file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"},
]
pyflakes = [
{file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},
{file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
]
pytest = [
{file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"},
{file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"},
]
pytest-metadata = [
{file = "pytest_metadata-2.0.4-py3-none-any.whl", hash = "sha256:acb739f89fabb3d798c099e9e0c035003062367a441910aaaf2281bc1972ee14"},
{file = "pytest_metadata-2.0.4.tar.gz", hash = "sha256:fcc653f65fe3035b478820b5284fbf0f52803622ee3f60a2faed7a7d3ba1f41e"},
]
pytest-mock = [
{file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"},
{file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"},
]
pytest-xdist = [
{file = "pytest-xdist-3.2.0.tar.gz", hash = "sha256:fa10f95a2564cd91652f2d132725183c3b590d9fdcdec09d3677386ecf4c1ce9"},
{file = "pytest_xdist-3.2.0-py3-none-any.whl", hash = "sha256:336098e3bbd8193276867cc87db8b22903c3927665dff9d1ac8684c02f597b68"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
{file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
{file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
{file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
{file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
setuptools = [
{file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"},
{file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
tox = [
{file = "tox-3.28.0-py2.py3-none-any.whl", hash = "sha256:57b5ab7e8bb3074edc3c0c0b4b192a4f3799d3723b2c5b76f1fa9f2d40316eea"},
{file = "tox-3.28.0.tar.gz", hash = "sha256:d0d28f3fe6d6d7195c27f8b054c3e99d5451952b54abdae673b71609a581f640"},
]
typed-ast = [
{file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
{file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
{file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
{file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
{file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
{file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
{file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
{file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
{file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
]
typing-extensions = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
]
virtualenv = [
{file = "virtualenv-20.16.2-py2.py3-none-any.whl", hash = "sha256:635b272a8e2f77cb051946f46c60a54ace3cb5e25568228bd6b57fc70eca9ff3"},
{file = "virtualenv-20.16.2.tar.gz", hash = "sha256:0ef5be6d07181946891f5abc8047fda8bc2f0b4b9bf222c64e6e8963baee76db"},
]
zipp = [
{file = "zipp-3.13.0-py3-none-any.whl", hash = "sha256:e8b2a36ea17df80ffe9e2c4fda3f693c3dad6df1697d3cd3af232db680950b0b"},
{file = "zipp-3.13.0.tar.gz", hash = "sha256:23f70e964bc11a34cef175bc90ba2914e1e4545ea1e3e2f67c079671883f9cb6"},
]

View File

@ -1,12 +1,64 @@
[build-system]
requires = [
"pip >= 19.3.1",
"setuptools >= 42",
"setuptools_scm[toml] >= 3.5.0",
"setuptools_scm_git_archive >= 1.1",
"wheel >= 0.33.6",
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "pytest-html"
description = "pytest plugin for generating HTML reports"
version = "4.0.0-rc0"
license = "MPL-2.0"
authors = [
"Dave Hunt <dhunt@mozilla.com>",
"Jim Brannlund <jimbrannlund@fastmail.com>"
]
build-backend = "setuptools.build_meta"
readme = "README.rst"
homepage = "https://github.com/pytest-dev/pytest-html"
repository = "https://github.com/pytest-dev/pytest-html"
keywords = [
"pytest",
"html",
"report",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Pytest",
"Intended Audience :: Developers",
"Operating System :: POSIX",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS :: MacOS X",
"Topic :: Software Development :: Quality Assurance",
"Topic :: Software Development :: Testing",
"Topic :: Utilities",
]
packages = [
{ include = "pytest_html", from = "src" },
]
include = [
{ path = "testing", format = "sdist" },
{ path = "docs", format = "sdist" },
"src/pytest_html/resources",
"src/pytest_html/resources/app.js",
]
[tool.poetry.dependencies]
python = ">=3.7"
pytest = ">=7.0.0"
pytest-metadata = ">=2.0.2"
Jinja2 = ">=3.0.0"
[tool.poetry.dev-dependencies]
assertpy = ">=1.1"
beautifulsoup4 = ">=4.11.1"
black = ">=22.1.0"
flake8 = ">=4.0.1"
pre-commit = ">=2.17.0"
pytest-xdist = ">=2.4.0"
pytest-mock = ">=3.7.0"
selenium = ">=4.3.0"
tox = ">=3.24.5"
[tool.poetry.plugins.pytest11]
html = "pytest_html.plugin"
[tool.setuptools_scm]
local_scheme = "no-local-version"

View File

@ -1 +0,0 @@
const jsonData = {"title": "Next Gen Report", "collectedItems": 89, "environment": {"Python": "3.7.7", "Platform": "Darwin-19.6.0-x86_64-i386-64bit", "Packages": {"pytest": "6.1.2", "py": "1.10.0", "pluggy": "0.13.1"}, "Plugins": {"rerunfailures": "9.1.1", "metadata": "1.11.0", "xdist": "2.1.0", "mock": "3.3.1", "html": "2.1.2.dev80", "forked": "1.3.0"}}, "tests": []}

View File

@ -10,10 +10,10 @@ setup(
url="https://github.com/pytest-dev/pytest-html",
package_dir={"": "src"},
packages=["pytest_html"],
package_data={"pytest_html": ["resources/*"]},
package_data={"pytest_html": ["resources/*", "scripts/*"]},
entry_points={"pytest11": ["html = pytest_html.plugin"]},
setup_requires=["setuptools_scm"],
install_requires=["pytest>=5.0,!=6.0.0", "pytest-metadata"],
install_requires=["pytest>=5.0,!=6.0.0", "pytest-metadata", "jinja2>=3.0,<4.0"],
license="Mozilla Public License 2.0 (MPL 2.0)",
keywords="py.test pytest html report",
python_requires=">=3.6",

4
src/.gitignore vendored
View File

@ -1,3 +1,5 @@
# file generated by setuptools_scm. don't track in version control
pytest_html/__version.py
# don't track built file
app.js

View File

@ -82,9 +82,6 @@ span.xpassed,
color: red;
}
.col-result {
text-transform: capitalize;
}
.col-links__extra {
margin-right: 3px;
}
@ -143,50 +140,74 @@ $extra-media-width: 320px;
}
}
div.image {
div.media {
border: $border-width solid #e6e6e6;
float: right;
height: $extra-height;
margin-left: $spacing;
margin: 0 $spacing;
overflow: hidden;
width: $extra-media-width;
img {
width: $extra-media-width;
}
}
div.video {
border: $border-width solid #e6e6e6;
float: right;
height: $extra-height;
margin-left: $spacing;
.media-container {
display: grid;
grid-template-columns: 25px auto 25px;
align-items: center;
flex: 1 1;
overflow: hidden;
width: $extra-media-width;
height: 200px;
}
.media-container__nav--right,
.media-container__nav--left {
text-align: center;
cursor: pointer;
}
.media-container__viewport {
cursor: pointer;
text-align: center;
height: inherit;
img,
video {
overflow: hidden;
width: $extra-media-width;
height: $extra-height;
object-fit: cover;
width: 100%;
max-height: 100%;
}
}
.media__name,
.media__counter {
display: flex;
flex-direction: row;
justify-content: space-around;
flex: 0 0 25px;
align-items: center;
}
.collapsed {
display: none;
}
.expander::after {
content: ' (show details)';
@mixin rowToggle {
color: #bbb;
font-style: italic;
cursor: pointer;
}
.collapser::after {
content: ' (hide details)';
color: #bbb;
font-style: italic;
.col-result {
cursor: pointer;
&:hover::after {
@include rowToggle;
}
}
.col-result.collapser {
&:hover::after {
content: ' (hide details)';
}
}
.col-result.expander {
&:hover::after {
content: ' (show details)';
}
}
/*------------------
@ -249,7 +270,6 @@ div.video {
}
.summary {
display: flex;
&__data {
flex: 0 0 550px;
}
@ -280,7 +300,25 @@ div.video {
flex: 0 0 550px;
}
}
input.filter {
margin-left: 10px;
.controls {
display: flex;
justify-content: space-between;
}
.filters,
.collapse {
display: flex;
align-items: center;
button {
color: #999;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover {
color: #ccc;
}
}
}
.filter__label {
margin-right: 10px;
}

View File

@ -1,342 +0,0 @@
import bisect
import datetime
import json
import os
import re
import time
from collections import defaultdict
from collections import OrderedDict
from py.xml import html
from py.xml import raw
from . import __pypi_url__
from . import __version__
from .outcome import Outcome
from .result import TestResult
from .util import ansi_support
class HTMLReport:
def __init__(self, logfile, config):
logfile = os.path.expanduser(os.path.expandvars(logfile))
self.logfile = os.path.abspath(logfile)
self.test_logs = []
self.title = os.path.basename(self.logfile)
self.results = []
self.errors = self.failed = 0
self.passed = self.skipped = 0
self.xfailed = self.xpassed = 0
has_rerun = config.pluginmanager.hasplugin("rerunfailures")
self.rerun = 0 if has_rerun else None
self.self_contained = config.getoption("self_contained_html")
self.config = config
self.reports = defaultdict(list)
def _appendrow(self, outcome, report):
result = TestResult(outcome, report, self.logfile, self.config)
if result.row_table is not None:
index = bisect.bisect_right(self.results, result)
self.results.insert(index, result)
tbody = html.tbody(
result.row_table,
class_="{} results-table-row".format(result.outcome.lower()),
)
if result.row_extra is not None:
tbody.append(result.row_extra)
self.test_logs.insert(index, tbody)
def append_passed(self, report):
if report.when == "call":
if hasattr(report, "wasxfail"):
self.xpassed += 1
self._appendrow("XPassed", report)
else:
self.passed += 1
self._appendrow("Passed", report)
def append_failed(self, report):
if getattr(report, "when", None) == "call":
if hasattr(report, "wasxfail"):
# pytest < 3.0 marked xpasses as failures
self.xpassed += 1
self._appendrow("XPassed", report)
else:
self.failed += 1
self._appendrow("Failed", report)
else:
self.errors += 1
self._appendrow("Error", report)
def append_rerun(self, report):
self.rerun += 1
self._appendrow("Rerun", report)
def append_skipped(self, report):
if hasattr(report, "wasxfail"):
self.xfailed += 1
self._appendrow("XFailed", report)
else:
self.skipped += 1
self._appendrow("Skipped", report)
def _generate_report(self, session):
suite_stop_time = time.time()
suite_time_delta = suite_stop_time - self.suite_start_time
numtests = self.passed + self.failed + self.xpassed + self.xfailed
generated = datetime.datetime.now()
with open(
os.path.join(os.path.dirname(__file__), "resources", "style.css")
) as style_css_fp:
self.style_css = style_css_fp.read()
if ansi_support():
ansi_css = [
"\n/******************************",
" * ANSI2HTML STYLES",
" ******************************/\n",
]
ansi_css.extend([str(r) for r in ansi_support().style.get_styles()])
self.style_css += "\n".join(ansi_css)
# <DF> Add user-provided CSS
for path in self.config.getoption("css"):
self.style_css += "\n/******************************"
self.style_css += "\n * CUSTOM CSS"
self.style_css += f"\n * {path}"
self.style_css += "\n ******************************/\n\n"
with open(path) as f:
self.style_css += f.read()
css_href = "assets/style.css"
html_css = html.link(href=css_href, rel="stylesheet", type="text/css")
if self.self_contained:
html_css = html.style(raw(self.style_css))
session.config.hook.pytest_html_report_title(report=self)
head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css)
outcomes = [
Outcome("passed", self.passed),
Outcome("skipped", self.skipped),
Outcome("failed", self.failed),
Outcome("error", self.errors, label="errors"),
Outcome("xfailed", self.xfailed, label="expected failures"),
Outcome("xpassed", self.xpassed, label="unexpected passes"),
]
if self.rerun is not None:
outcomes.append(Outcome("rerun", self.rerun))
summary = [
html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "),
html.p(
"(Un)check the boxes to filter the results.",
class_="filter",
hidden="true",
),
]
for i, outcome in enumerate(outcomes, start=1):
summary.append(outcome.checkbox)
summary.append(outcome.summary_item)
if i < len(outcomes):
summary.append(", ")
cells = [
html.th("Result", class_="sortable result initial-sort", col="result"),
html.th("Test", class_="sortable", col="name"),
html.th("Duration", class_="sortable", col="duration"),
html.th("Links", class_="sortable links", col="links"),
]
session.config.hook.pytest_html_results_table_header(cells=cells)
results = [
html.h2("Results"),
html.table(
[
html.thead(
html.tr(cells),
html.tr(
[
html.th(
"No results found. Try to check the filters",
colspan=len(cells),
)
],
id="not-found-message",
hidden="true",
),
id="results-table-head",
),
self.test_logs,
],
id="results-table",
),
]
with open(
os.path.join(os.path.dirname(__file__), "resources", "old_main.js")
) as main_js_fp:
main_js = main_js_fp.read()
body = html.body(
html.script(raw(main_js)),
html.h1(self.title),
html.p(
"Report generated on {} at {} by ".format(
generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S")
),
html.a("pytest-html", href=__pypi_url__),
f" v{__version__}",
),
onLoad="init()",
)
body.extend(self._generate_environment(session.config))
summary_prefix, summary_postfix = [], []
session.config.hook.pytest_html_results_summary(
prefix=summary_prefix, summary=summary, postfix=summary_postfix
)
body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix)
body.extend(results)
doc = html.html(head, body)
unicode_doc = "<!DOCTYPE html>\n{}".format(doc.unicode(indent=2))
# Fix encoding issues, e.g. with surrogates
unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace")
return unicode_doc.decode("utf-8")
def _generate_environment(self, config):
if not hasattr(config, "_metadata") or config._metadata is None:
return []
metadata = config._metadata
environment = [html.h2("Environment")]
rows = []
keys = [k for k in metadata.keys()]
if not isinstance(metadata, OrderedDict):
keys.sort()
for key in keys:
value = metadata[key]
if self._is_redactable_environment_variable(key, config):
black_box_ascii_value = 0x2593
value = "".join(chr(black_box_ascii_value) for char in str(value))
if isinstance(value, str) and value.startswith("http"):
value = html.a(value, href=value, target="_blank")
elif isinstance(value, (list, tuple, set)):
value = ", ".join(str(i) for i in sorted(map(str, value)))
elif isinstance(value, dict):
sorted_dict = {k: value[k] for k in sorted(value)}
value = json.dumps(sorted_dict)
raw_value_string = raw(str(value))
rows.append(html.tr(html.td(key), html.td(raw_value_string)))
environment.append(html.table(rows, id="environment"))
return environment
def _is_redactable_environment_variable(self, environment_variable, config):
redactable_regexes = config.getini("environment_table_redact_list")
for redactable_regex in redactable_regexes:
if re.match(redactable_regex, environment_variable):
return True
return False
def _save_report(self, report_content):
dir_name = os.path.dirname(self.logfile)
assets_dir = os.path.join(dir_name, "assets")
os.makedirs(dir_name, exist_ok=True)
if not self.self_contained:
os.makedirs(assets_dir, exist_ok=True)
with open(self.logfile, "w", encoding="utf-8") as f:
f.write(report_content)
if not self.self_contained:
style_path = os.path.join(assets_dir, "style.css")
with open(style_path, "w", encoding="utf-8") as f:
f.write(self.style_css)
def _post_process_reports(self):
for test_name, test_reports in self.reports.items():
report_outcome = "passed"
wasxfail = False
failure_when = None
full_text = ""
extras = []
duration = 0.0
# in theory the last one should have all logs so we just go
# through them all to figure out the outcome, xfail, duration,
# extras, and when it swapped from pass
for test_report in test_reports:
if test_report.outcome == "rerun":
# reruns are separate test runs for all intensive purposes
self.append_rerun(test_report)
else:
full_text += test_report.longreprtext
extras.extend(getattr(test_report, "extra", []))
duration += getattr(test_report, "duration", 0.0)
if (
test_report.outcome not in ("passed", "rerun")
and report_outcome == "passed"
):
report_outcome = test_report.outcome
failure_when = test_report.when
if hasattr(test_report, "wasxfail"):
wasxfail = True
# the following test_report.<X> = settings come at the end of us
# looping through all test_reports that make up a single
# case.
# outcome on the right comes from the outcome of the various
# test_reports that make up this test case
# we are just carrying it over to the final report.
test_report.outcome = report_outcome
test_report.when = "call"
test_report.nodeid = test_name
test_report.longrepr = full_text
test_report.extra = extras
test_report.duration = duration
if wasxfail:
test_report.wasxfail = True
if test_report.outcome == "passed":
self.append_passed(test_report)
elif test_report.outcome == "skipped":
self.append_skipped(test_report)
elif test_report.outcome == "failed":
test_report.when = failure_when
self.append_failed(test_report)
def pytest_runtest_logreport(self, report):
self.reports[report.nodeid].append(report)
def pytest_collectreport(self, report):
if report.failed:
self.append_failed(report)
def pytest_sessionstart(self, session):
self.suite_start_time = time.time()
def pytest_sessionfinish(self, session):
self._post_process_reports()
report_content = self._generate_report(session)
self._save_report(report_content)
def pytest_terminal_summary(self, terminalreporter):
terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}")

View File

@ -1,49 +1,241 @@
import base64
import binascii
import datetime
import json
from typing import Any
from typing import Dict
import os
import re
import warnings
from collections import defaultdict
from functools import partial
from pathlib import Path
import pytest
from jinja2 import Environment
from jinja2 import FileSystemLoader
from jinja2 import select_autoescape
from . import __version__
from . import extras
from .util import cleanup_unserializable
class NextGenReport:
def __init__(self, config, data_file):
self._config = config
self._data_file = data_file
try:
from ansi2html import Ansi2HTMLConverter, style
self._title = "Next Gen Report"
converter = Ansi2HTMLConverter(inline=False, escaped=False)
_handle_ansi = partial(converter.convert, full=False)
_ansi_styles = style.get_styles()
except ImportError:
from _pytest.logging import _remove_ansi_escape_sequences
_handle_ansi = _remove_ansi_escape_sequences
_ansi_styles = []
class BaseReport:
class Cells:
def __init__(self):
self._html = {}
@property
def html(self):
return self._html
def insert(self, index, html):
self._html[index] = html
class Report:
def __init__(self, title, duration_format):
self._data = {
"title": self._title,
"title": title,
"collectedItems": 0,
"runningState": "not_started",
"durationFormat": duration_format,
"environment": {},
"tests": [],
"resultsTableHeader": {},
"additionalSummary": defaultdict(list),
}
self._data_file.parent.mkdir(parents=True, exist_ok=True)
@property
def data(self):
return self._data
def _write(self):
try:
data = json.dumps(self._data)
except TypeError:
data = cleanup_unserializable(self._data)
data = json.dumps(data)
def set_data(self, key, value):
self._data[key] = value
with self._data_file.open("w", buffering=1, encoding="UTF-8") as f:
f.write("const jsonData = ")
f.write(data)
f.write("\n")
@property
def title(self):
return self._data["title"]
@title.setter
def title(self, title):
self._data["title"] = title
def __init__(self, report_path, config, default_css="style.css"):
self._report_path = Path(os.path.expandvars(report_path)).expanduser()
self._report_path.parent.mkdir(parents=True, exist_ok=True)
self._resources_path = Path(__file__).parent.joinpath("resources")
self._config = config
self._template = _read_template([self._resources_path])
self._css = _process_css(
Path(self._resources_path, default_css), self._config.getoption("css")
)
self._duration_format = config.getini("duration_format")
self._max_asset_filename_length = int(
config.getini("max_asset_filename_length")
)
self._report = self.Report(self._report_path.name, self._duration_format)
@property
def css(self):
# implement in subclasses
return
def _asset_filename(self, test_id, extra_index, test_index, file_extension):
return "{}_{}_{}.{}".format(
re.sub(r"[^\w.]", "_", test_id),
str(extra_index),
str(test_index),
file_extension,
)[-self._max_asset_filename_length :]
def _generate_report(self, self_contained=False):
generated = datetime.datetime.now()
rendered_report = self._render_html(
generated.strftime("%d-%b-%Y"),
generated.strftime("%H:%M:%S"),
__version__,
self.css,
self_contained=self_contained,
test_data=cleanup_unserializable(self._report.data),
prefix=self._report.data["additionalSummary"]["prefix"],
summary=self._report.data["additionalSummary"]["summary"],
postfix=self._report.data["additionalSummary"]["postfix"],
)
self._write_report(rendered_report)
def _generate_environment(self):
metadata = self._config._metadata
for key in metadata.keys():
value = metadata[key]
if self._is_redactable_environment_variable(key):
black_box_ascii_value = 0x2593
metadata[key] = "".join(chr(black_box_ascii_value) for _ in str(value))
return metadata
def _is_redactable_environment_variable(self, environment_variable):
redactable_regexes = self._config.getini("environment_table_redact_list")
for redactable_regex in redactable_regexes:
if re.match(redactable_regex, environment_variable):
return True
return False
def _data_content(self, *args, **kwargs):
pass
def _media_content(self, *args, **kwargs):
pass
def _process_extras(self, report, test_id):
test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
report_extras = getattr(report, "extras", [])
for extra_index, extra in enumerate(report_extras):
content = extra["content"]
asset_name = self._asset_filename(
test_id.encode("utf-8").decode("unicode_escape"),
extra_index,
test_index,
extra["extension"],
)
if extra["format_type"] == extras.FORMAT_JSON:
content = json.dumps(content)
extra["content"] = self._data_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
if extra["format_type"] == extras.FORMAT_TEXT:
if isinstance(content, bytes):
content = content.decode("utf-8")
extra["content"] = self._data_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
if (
extra["format_type"] == extras.FORMAT_IMAGE
or extra["format_type"] == extras.FORMAT_VIDEO
):
extra["content"] = self._media_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
return report_extras
def _render_html(
self,
date,
time,
version,
styles,
self_contained,
test_data,
summary,
prefix,
postfix,
):
return self._template.render(
date=date,
time=time,
version=version,
styles=styles,
self_contained=self_contained,
test_data=json.dumps(test_data),
summary=summary,
prefix=prefix,
postfix=postfix,
)
def _write_report(self, rendered_report):
with self._report_path.open("w", encoding="utf-8") as f:
f.write(rendered_report)
@pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session):
config = session.config
if hasattr(config, "_metadata") and config._metadata:
metadata = config._metadata
self._data["environment"] = metadata
self._write()
self._report.data["environment"] = self._generate_environment()
session.config.hook.pytest_html_report_title(report=self._report)
header_cells = self.Cells()
session.config.hook.pytest_html_results_table_header(cells=header_cells)
self._report.set_data("resultsTableHeader", header_cells.html)
self._report.data["runningState"] = "Started"
self._generate_report()
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(self, session):
session.config.hook.pytest_html_results_summary(
prefix=self._report.data["additionalSummary"]["prefix"],
summary=self._report.data["additionalSummary"]["summary"],
postfix=self._report.data["additionalSummary"]["postfix"],
)
self._report.data["runningState"] = "Finished"
self._generate_report()
@pytest.hookimpl(trylast=True)
def pytest_terminal_summary(self, terminalreporter):
terminalreporter.write_sep(
"-", f"Generated html report: file://{self._report_path.resolve()}"
)
@pytest.hookimpl(trylast=True)
def pytest_collection_finish(self, session):
self._data["collectedItems"] = len(session.items)
self._write()
self._report.data["collectedItems"] = len(session.items)
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(self, report):
@ -51,24 +243,138 @@ class NextGenReport:
config=self._config, report=report
)
# rename to "extras" since list
if hasattr(report, "extra"):
for extra in report.extra:
print(extra)
if extra["mime_type"] is not None and "image" in extra["mime_type"]:
data.update({"extras": extra})
test_id = report.nodeid
if report.when != "call":
test_id += f"::{report.when}"
data["nodeid"] = test_id
self._data["tests"].append(data)
self._write()
# Order here matters!
log = report.longreprtext or report.capstdout or "No log output captured."
data["longreprtext"] = _handle_ansi(log)
data["outcome"] = _process_outcome(report)
row_cells = self.Cells()
self._config.hook.pytest_html_results_table_row(report=report, cells=row_cells)
data.update({"resultsTableRow": row_cells.html})
table_html = []
self._config.hook.pytest_html_results_table_html(report=report, data=table_html)
data.update({"tableHtml": table_html})
data.update({"extras": self._process_extras(report, test_id)})
self._report.data["tests"].append(data)
self._generate_report()
def cleanup_unserializable(d: Dict[str, Any]) -> Dict[str, Any]:
"""Return new dict with entries that are not json serializable by their str()."""
result = {}
for k, v in d.items():
class NextGenReport(BaseReport):
def __init__(self, report_path, config):
super().__init__(report_path, config)
self._assets_path = Path(self._report_path.parent, "assets")
self._assets_path.mkdir(parents=True, exist_ok=True)
self._css_path = Path(self._assets_path, "style.css")
with self._css_path.open("w", encoding="utf-8") as f:
f.write(self._css)
@property
def css(self):
# print("woot", Path(self._assets_path.name, "style.css"))
# print("waat", self._css_path.relative_to(self._report_path.parent))
return Path(self._assets_path.name, "style.css")
def _data_content(self, content, asset_name, *args, **kwargs):
content = content.encode("utf-8")
return self._write_content(content, asset_name)
def _media_content(self, content, asset_name, *args, **kwargs):
try:
json.dumps({k: v})
except TypeError:
v = str(v)
result[k] = v
return result
media_data = base64.b64decode(content.encode("utf-8"), validate=True)
return self._write_content(media_data, asset_name)
except binascii.Error:
# if not base64 content, just return as it's a file or link
return content
def _write_content(self, content, asset_name):
content_relative_path = Path(self._assets_path, asset_name)
content_relative_path.write_bytes(content)
return str(content_relative_path.relative_to(self._report_path.parent))
class NextGenSelfContainedReport(BaseReport):
def __init__(self, report_path, config):
super().__init__(report_path, config)
@property
def css(self):
return self._css
def _data_content(self, content, mime_type, *args, **kwargs):
charset = "utf-8"
data = base64.b64encode(content.encode(charset)).decode(charset)
return f"data:{mime_type};charset={charset};base64,{data}"
def _media_content(self, content, mime_type, *args, **kwargs):
try:
# test if content is base64
base64.b64decode(content.encode("utf-8"), validate=True)
return f"data:{mime_type};base64,{content}"
except binascii.Error:
# if not base64, issue warning and just return as it's a file or link
warnings.warn(
"Self-contained HTML report "
"includes link to external "
f"resource: {content}"
)
return content
def _generate_report(self, *args, **kwargs):
super()._generate_report(self_contained=True)
def _process_css(default_css, extra_css):
with open(default_css, encoding="utf-8") as f:
css = f.read()
# Add user-provided CSS
for path in extra_css:
css += "\n/******************************"
css += "\n * CUSTOM CSS"
css += f"\n * {path}"
css += "\n ******************************/\n\n"
with open(path, encoding="utf-8") as f:
css += f.read()
# ANSI support
if _ansi_styles:
ansi_css = [
"\n/******************************",
" * ANSI2HTML STYLES",
" ******************************/\n",
]
ansi_css.extend([str(r) for r in _ansi_styles])
css += "\n".join(ansi_css)
return css
def _process_outcome(report):
if report.when in ["setup", "teardown"] and report.outcome == "failed":
return "Error"
if hasattr(report, "wasxfail"):
if report.outcome in ["passed", "failed"]:
return "XPassed"
if report.outcome == "skipped":
return "XFailed"
return report.outcome.capitalize()
def _read_template(search_paths, template_name="index.jinja2"):
env = Environment(
loader=FileSystemLoader(search_paths),
autoescape=select_autoescape(
enabled_extensions=("jinja2",),
),
)
return env.get_template(template_name)

View File

@ -1,33 +0,0 @@
from py.xml import html
class Outcome:
def __init__(self, outcome, total=0, label=None, test_result=None, class_html=None):
self.outcome = outcome
self.label = label or outcome
self.class_html = class_html or outcome
self.total = total
self.test_result = test_result or outcome
self.generate_checkbox()
self.generate_summary_item()
def generate_checkbox(self):
checkbox_kwargs = {"data-test-result": self.test_result.lower()}
if self.total == 0:
checkbox_kwargs["disabled"] = "true"
self.checkbox = html.input(
type="checkbox",
checked="true",
onChange="filterTable(this)",
name="filter_checkbox",
class_="filter",
hidden="true",
**checkbox_kwargs,
)
def generate_summary_item(self):
self.summary_item = html.span(
f"{self.total} {self.label}", class_=self.class_html
)

View File

@ -1,14 +1,13 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import warnings
from pathlib import Path
import pytest
from _pytest.pathlib import Path
from . import extras # noqa: F401
from .html_report import HTMLReport
from .nextgen import NextGenReport
from .nextgen import NextGenSelfContainedReport
def pytest_addhooks(pluginmanager):
@ -43,6 +42,11 @@ def pytest_addoption(parser):
default=[],
help="append given css file content to report style file.",
)
parser.addini(
"duration_format",
default=None,
help="the format for duration.",
)
parser.addini(
"render_collapsed",
type="bool",
@ -64,40 +68,35 @@ def pytest_addoption(parser):
def pytest_configure(config):
htmlpath = config.getoption("htmlpath")
if htmlpath:
html_path = config.getoption("htmlpath")
if html_path:
missing_css_files = []
for csspath in config.getoption("css"):
if not os.path.exists(csspath):
missing_css_files.append(csspath)
for css_path in config.getoption("css"):
if not Path(css_path).exists():
missing_css_files.append(css_path)
if missing_css_files:
oserror = (
os_error = (
f"Missing CSS file{'s' if len(missing_css_files) > 1 else ''}:"
f" {', '.join(missing_css_files)}"
)
raise OSError(oserror)
raise OSError(os_error)
if not hasattr(config, "workerinput"):
# prevent opening htmlpath on worker nodes (xdist)
config._html = HTMLReport(htmlpath, config)
# prevent opening html_path on worker nodes (xdist)
if config.getoption("self_contained_html"):
html = NextGenSelfContainedReport(html_path, config)
else:
html = NextGenReport(html_path, config)
config._next_gen = NextGenReport(config, Path("nextgendata.js"))
config.pluginmanager.register(config._html)
config.pluginmanager.register(config._next_gen)
config.pluginmanager.register(html)
def pytest_unconfigure(config):
html = getattr(config, "_html", None)
html = config.pluginmanager.getplugin("html")
if html:
del config._html
config.pluginmanager.unregister(html)
next_gen = getattr(config, "_next_gen", None)
if next_gen:
del config._next_gen
config.pluginmanager.unregister(next_gen)
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
@ -105,8 +104,8 @@ def pytest_runtest_makereport(item, call):
report = outcome.get_result()
if report.when == "call":
fixture_extras = getattr(item.config, "extras", [])
plugin_extras = getattr(report, "extra", [])
report.extra = fixture_extras + plugin_extras
plugin_extras = getattr(report, "extras", [])
report.extras = fixture_extras + plugin_extras
@pytest.fixture
@ -119,7 +118,29 @@ def extra(pytestconfig):
def test_foo(extra):
extra.append(pytest_html.extras.url("http://www.example.com/"))
extra.append(pytest_html.extras.url("https://www.example.com/"))
"""
warnings.warn(
"The 'extra' fixture is deprecated and will be removed in a future release"
", use 'extras' instead.",
DeprecationWarning,
)
pytestconfig.extras = []
yield pytestconfig.extras
del pytestconfig.extras[:]
@pytest.fixture
def extras(pytestconfig):
"""Add details to the HTML reports.
.. code-block:: python
import pytest_html
def test_foo(extras):
extras.append(pytest_html.extras.url("https://www.example.com/"))
"""
pytestconfig.extras = []
yield pytestconfig.extras

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@ -1,82 +0,0 @@
// const templateEnvRow = find('#template_environment_row');
// const templateResult = find('#template_results-table__tbody');
// const aTag = find('#template_a');
// const aTagImg = find('#template_img');
// const listHeader = find('#template_results-table__head');
const dom = {
getStaticRow: (key, value) => {
var envRow = templateEnvRow.content.cloneNode(true);
const isObj = typeof value === 'object' && value !== null;
const values = isObj
? Object.keys(value).map((k) => `${k}: ${value[k]}`)
: null;
const valuesElement = htmlToElements(
values
? `<ul>${values.map((val) => `<li>${val}</li>`).join('')}<ul>`
: `<div>${value}</div>`
)[0];
var td = findAll('td', envRow);
td[0].textContent = key;
td[1].appendChild(valuesElement);
return envRow;
},
getListHeader: () => {
const header = listHeader.content.cloneNode(true);
const sortAttr = localStorage.getItem('sort');
const sortAsc = JSON.parse(localStorage.getItem('sortAsc'));
const sortables = ['outcome', 'nodeid', 'duration'];
sortables.forEach((sortCol) => {
if (sortCol === sortAttr) {
find(`[data-column-type="${sortCol}"]`, header).classList.add(
sortAsc ? 'desc' : 'asc'
);
}
});
return header;
},
getListHeaderEmpty: () => listHeaderEmpty.content.cloneNode(true),
getResultTBody: ({ nodeid, longrepr, extras, duration }, outcome) => {
const isFail = outcome === 'failed';
const resultBody = templateResult.content.cloneNode(true);
find('tbody', resultBody).classList.add(outcome);
find('.col-result', resultBody).innerText = outcome;
find('.col-name', resultBody).innerText = nodeid;
find('.col-duration', resultBody).innerText = `${(duration * 1000).toFixed(
2
)}s`;
if (isFail) {
find('.log', resultBody).innerText = longrepr
? longrepr.reprtraceback.reprentries[0].data.lines.join('\n')
: '';
} else {
find('.extras-row', resultBody).classList.add('hidden');
}
extras &&
extras.forEach(({ name, format_type, content }) => {
const extraLink = aTag.content.cloneNode(true);
const extraLinkItem = find('a', extraLink);
const folderItems = ['image', 'video', 'text', 'html', 'json'];
extraLinkItem.href = `${
folderItems.includes(format_type) ? 'assets/' : ''
}${content}`;
extraLinkItem.className = `col-links__extra ${format_type}`;
extraLinkItem.innerText = name;
find('.col-links', resultBody).appendChild(extraLinkItem);
if (format_type === 'image') {
const imgElTemp = aTagImg.content.cloneNode(true);
find('a', imgElTemp).href = `assets/${content}`;
find('img', imgElTemp).src = `assets/${content}`;
find('.extra .image', resultBody).appendChild(imgElTemp);
}
});
return resultBody;
},
};

View File

@ -1,46 +0,0 @@
const dispatchEvent = (type, detail) => {
const newEvent = new CustomEvent(type, { detail });
document.dispatchEvent(newEvent);
};
const doSort = (type) => {
const newSortType = localStorage.getItem('sort') !== type;
const currentAsc = JSON.parse(localStorage.getItem('sortAsc'));
const ascending = newSortType ? true : !currentAsc;
localStorage.setItem('sort', type);
localStorage.setItem('sortAsc', ascending);
dispatchEvent('sort', { type, ascending });
};
document.addEventListener('sort', (e) => {
const { type, ascending } = e.detail;
const sortedList = genericSort(renderData.tests, type, ascending);
renderData.tests = sortedList;
initRender();
});
const doFilter = (type, apply) => {
const currentFilter = [
...new Set(JSON.parse(localStorage.getItem('filter'))),
];
if (apply) {
currentFilter.push(type);
} else {
const index = currentFilter.indexOf(type);
currentFilter.splice(index, 1);
}
localStorage.setItem('filter', JSON.stringify(currentFilter));
const filteredSubset = [];
if (currentFilter.length) {
currentFilter.forEach((filter) => {
filteredSubset.push(
...jsonData.tests.filter(({ outcome }) => outcome === filter)
);
});
renderData.tests = filteredSubset;
} else {
renderData.tests = jsonData.tests;
}
initRender();
};

View File

@ -1,100 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Test Report</title>
<link href="style.css" rel="stylesheet" type="text/css"/>
</head>
<body onLoad="init()">
<h1 id="title"></h1>
<p>Report generated on 17-Dec-2020 at 14:02:36 by <a href="https://pypi.python.org/pypi/pytest-html">pytest-html</a> v1.22.0</p>
<h2>Environment</h2>
<table id="environment"><table>
<!-- TEMPLATES -->
<template id="template_environment_row">
<tr>
<td></td>
<td></td>
</tr>
</template>
<template id="template_a">
<a target="_blank"></a>
</template>
<template id="template_img">
<a target="_blank">
<img />
</a>
</template>
<template id="template_results-table__tbody">
<tbody class="results-table-row">
<tr>
<td class="col-result"></td>
<td class="col-name"></td>
<td class="col-duration"></td>
<td class="col-links"></td>
</tr>
<tr class="extras-row">
<td class="extra" colspan="4">
<div class="image"></div>
<div class="log"></div>
</td>
</tr>
</tbody>
</template>
<template id="template_results-table__head">
<thead id="results-table-head">
<tr>
<th class="sortable" data-column-type="outcome">Result</th>
<th class="sortable" data-column-type="nodeid">Test</th>
<th class="sortable" data-column-type="duration">Duration</th>
<th>Links</th>
</tr>
</thead>
</template>
<template id="template_results-table__head--empty">
<tr id="not-found-message">
<th colspan="4">No results found. Try to check the filters</th>
</tr>
</template>
<!-- END TEMPLATES -->
<div class="summary">
<div class="summary__data">
<h2>Summary</h2>
<p class="run-count"></p>
<p class="filter">(Un)check the boxes to filter the results.</p>
<input checked="true" class="filter" data-test-result="error" name="filter_checkbox" type="checkbox"/><span class="error"></span>
<input checked="true" class="filter" data-test-result="failed" name="filter_checkbox" type="checkbox"/><span class="failed"></span>
<input checked="true" class="filter" data-test-result="rerun" name="filter_checkbox" type="checkbox"/><span class="rerun"></span>
<input checked="true" class="filter" data-test-result="xfailed" name="filter_checkbox" type="checkbox"/><span class="xfailed"></span>
<input checked="true" class="filter" data-test-result="xpassed" name="filter_checkbox" type="checkbox"/><span class="xpassed"></span>
<input checked="true" class="filter" data-test-result="passed" name="filter_checkbox" type="checkbox"/><span class="passed"></span>
<input checked="true" class="filter" data-test-result="skipped" name="filter_checkbox" type="checkbox"/><span class="skipped"></span>
</div>
<div class="summary__reload">
<div class="summary__reload__button" onclick="location.reload()">
<div>There are still tests running. <br />Reload this page to ge the latest results!</div>
</div>
</div>
<div class="summary__spacer">
</div>
</div>
<h2>Results</h2>
<table id="results-table">
</table>
</body>
<footer>
<script src="events.js"></script>
<script src="dom.js"></script>
<script src="nextgendata.js"></script>
<script src="sort.js"></script>
<script src="index.js"></script>
<script src="main.js"></script>
</footer>
</html>

View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title id="head-title"></title>
{% if self_contained %}
<style type="text/css">
{{- styles|safe }}
</style>
{% else %}
<link href="{{ styles }}" rel="stylesheet" type="text/css"/>
{% endif %}
</head>
<body>
<h1 id="title"></h1>
<p>Report generated on {{ date }} at {{ time }} by <a href="https://pypi.python.org/pypi/pytest-html">pytest-html</a>
v{{ version }}</p>
<h2>Environment</h2>
<table id="environment"></table>
<!-- TEMPLATES -->
<template id="template_environment_row">
<tr>
<td></td>
<td></td>
</tr>
</template>
<template id="template_a">
<a target="_blank"></a>
</template>
<template id="template_results-table__tbody">
<tbody class="results-table-row">
<tr>
<td class="col-result"></td>
<td class="col-name"></td>
<td class="col-duration"></td>
<td class="col-links"></td>
</tr>
<tr class="extras-row">
<td class="extra" colspan="4">
<div class="extraHTML"></div>
<div class="media">
<div class="media-container">
<div class="media-container__nav--left"><</div>
<div class="media-container__viewport">
<img src="" />
<video controls>
<source src="" type="video/mp4">
</video>
</div>
<div class="media-container__nav--right">></div>
</div>
<div class="media__name"></div>
<div class="media__counter"></div>
</div>
<div class="log"></div>
</td>
</tr>
</tbody>
</template>
<template id="template_results-table__head">
<thead id="results-table-head">
<tr>
<th class="sortable" data-column-type="outcome">Result</th>
<th class="sortable" data-column-type="nodeid">Test</th>
<th class="sortable" data-column-type="duration">Duration</th>
<th>Links</th>
</tr>
</thead>
</template>
<template id="template_results-table__head--empty">
<tr id="not-found-message">
<th colspan="4">No results found. Check the filters.</th>
</tr>
</template>
<template id="template_table-colgroup">
<colgroup>
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
</colgroup>
</template>
<!-- END TEMPLATES -->
<div class="summary">
<div class="summary__data">
<h2>Summary</h2>
{% for p in prefix %}
{{ p|safe }}
{% endfor %}
<p class="run-count"></p>
<p class="filter">(Un)check the boxes to filter the results.</p>
<div class="summary__reload">
<div class="summary__reload__button" onclick="location.reload()">
<div>There are still tests running. <br />Reload this page to ge the latest results!</div>
</div>
</div>
<div class="summary__spacer"></div>
<div class="controls">
<div class="filters">
<input checked="true" class="filter" data-test-result="error" name="filter_checkbox" type="checkbox"/><span class="error"></span>
<input checked="true" class="filter" data-test-result="failed" name="filter_checkbox" type="checkbox"/><span class="failed"></span>
<input checked="true" class="filter" data-test-result="rerun" name="filter_checkbox" type="checkbox"/><span class="rerun"></span>
<input checked="true" class="filter" data-test-result="xfailed" name="filter_checkbox" type="checkbox"/><span class="xfailed"></span>
<input checked="true" class="filter" data-test-result="xpassed" name="filter_checkbox" type="checkbox"/><span class="xpassed"></span>
<input checked="true" class="filter" data-test-result="passed" name="filter_checkbox" type="checkbox"/><span class="passed"></span>
<input checked="true" class="filter" data-test-result="skipped" name="filter_checkbox" type="checkbox"/><span class="skipped"></span>
</div>
<div class="collapse">
<button id="show_all_details">Show all details</button>&nbsp;/&nbsp;<button id="hide_all_details">Hide all details</button>
<div>
</div>
</div>
</div>
{% for s in summary %}
{{ s|safe }}
{% endfor %}
{% for p in postfix %}
{{ p|safe }}
{% endfor %}
<table id="results-table"></table>
</body>
<footer>
<div id="data-container" data-jsonblob="{{ test_data }}"></div>
<script>
{% include "app.js" %}
</script>
</footer>
</html>

View File

@ -1,134 +0,0 @@
function htmlToElements(html) {
let temp = document.createElement('template');
temp.innerHTML = html;
return temp.content.childNodes;
}
const find = (selector, elem) => {
if (!elem) {
elem = document;
}
return elem.querySelector(selector);
};
const findAll = (selector, elem) => {
if (!elem) {
elem = document;
}
return toArray(elem.querySelectorAll(selector));
};
const templateEnvRow = find('#template_environment_row');
const templateResult = find('#template_results-table__tbody');
const aTag = find('#template_a');
const aTagImg = find('#template_img');
const listHeader = find('#template_results-table__head');
const listHeaderEmpty = find('#template_results-table__head--empty');
const removeChildren = (node) => {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
};
const getOutcome = ({ nodeid }, tests) => {
const relatedOutcome = tests
.filter((test) => test.nodeid === nodeid)
.map(({ outcome }) => outcome);
if (relatedOutcome.includes('failed')) {
return 'failed';
} else if (relatedOutcome.includes('error')) {
return 'error';
} else if (relatedOutcome.includes('xpassed')) {
return 'xpassed';
} else if (relatedOutcome.includes('xfailed')) {
return 'xfailed';
} else if (relatedOutcome.includes('skipped')) {
return 'skipped';
} else {
return 'passed';
}
};
const renderStatic = (obj) => {
find('#title').innerText = obj.title;
const rows = Object.keys(obj.environment).map((key) =>
dom.getStaticRow(key, obj.environment[key])
);
const table = find('#environment');
removeChildren(table);
rows.forEach((row) => table.appendChild(row));
};
const renderContent = ({ tests }) => {
const renderSet = tests.filter(({ when }) => when === 'call');
const rows = renderSet.map((test) =>
dom.getResultTBody(test, getOutcome(test, tests))
);
const table = find('#results-table');
removeChildren(table);
const tableHeader = dom.getListHeader();
if (!rows.length) {
tableHeader.appendChild(dom.getListHeaderEmpty());
}
table.appendChild(tableHeader);
rows.forEach((row) => !!row && table.appendChild(row));
};
const renderDerrived = ({ tests, collectedItems }) => {
const renderSet = tests.filter(({ when }) => when === 'call');
const possibleOutcomes = [
'passed',
'skipped',
'failed',
'error',
'xfailed',
'xpassed',
'rerun',
];
possibleOutcomes.forEach((outcome) => {
const count = renderSet.filter((test) => test.outcome === outcome).length;
find(`.${outcome}`).innerText = `${count} ${outcome}`;
if (!count) {
find(`input[data-test-result="${outcome}"]`).disabled = true;
}
});
let accTime = 0;
if (collectedItems === renderSet.length) {
tests.forEach(({ duration }) => (accTime += duration));
accTime = accTime.toFixed(2);
find(
'.run-count'
).innerText = `${renderSet.length} tests ran in ${accTime} seconds.`;
find('.summary__reload__button').classList.add('hidden');
} else {
find(
'.run-count'
).innerText = `${renderSet.length} / ${collectedItems} tests done`;
}
};
const bindEvents = () => {
findAll('.sortable').forEach((elem) => {
elem.addEventListener('click', (evt) => {
const { target: element } = evt;
const { columnType } = element.dataset;
doSort(columnType);
});
});
};
const renderPage = (subset, full) => {
renderStatic(jsonData);
renderContent(renderData);
renderDerrived(jsonData);
};
let renderData = { ...jsonData };
const initRender = () => {
setTimeout(() => {
renderPage(renderData);
bindEvents();
}, 0);
};

View File

@ -1,115 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
function is_all_rows_hidden(value) {
return value.hidden == false;
}
function filter_table(elem) {
var outcome_att = 'data-test-result';
var outcome = elem.getAttribute(outcome_att);
console.log(outcome);
class_outcome = outcome + ' results-table-row';
var outcome_rows = document.getElementsByClassName(class_outcome);
for (var i = 0; i < outcome_rows.length; i++) {
outcome_rows[i].hidden = !elem.checked;
}
var rows = find_all('.results-table-row').filter(is_all_rows_hidden);
var all_rows_hidden = rows.length == 0 ? true : false;
var not_found_message = document.getElementById('not-found-message');
not_found_message.hidden = !all_rows_hidden;
}
function toArray(iter) {
if (iter === null) {
return null;
}
return Array.prototype.slice.call(iter);
}
function showAllExtras() {
// eslint-disable-line no-unused-vars
findAll('.col-result').forEach(showExtras);
}
function hideAllExtras() {
// eslint-disable-line no-unused-vars
findAll('.col-result').forEach(hideExtras);
}
function showExtras(colresultElem) {
const extras = colresultElem.parentNode.nextElementSibling;
const expandcollapse = colresultElem.firstElementChild;
extras.classList.remove('collapsed');
expandcollapse.classList.remove('expander');
expandcollapse.classList.add('collapser');
}
function hideExtras(colresultElem) {
const extras = colresultElem.parentNode.nextElementSibling;
const expandcollapse = colresultElem.firstElementChild;
extras.classList.add('collapsed');
expandcollapse.classList.remove('collapser');
expandcollapse.classList.add('expander');
}
function addCollapse() {
// Add links for show/hide all
const resulttable = find('table#results-table');
const showhideall = document.createElement('p');
showhideall.innerHTML =
'<a href="javascript:showAllExtras()">Show all details</a> / ' +
'<a href="javascript:hideAllExtras()">Hide all details</a>';
resulttable.parentElement.insertBefore(showhideall, resulttable);
// Add show/hide link to each result
findAll('.col-result').forEach(function (elem) {
const collapsed = getQueryParameter('collapsed') || 'Passed';
const extras = elem.parentNode.nextElementSibling;
const expandcollapse = document.createElement('span');
if (extras.classList.contains('collapsed')) {
expandcollapse.classList.add('expander');
} else if (collapsed.includes(elem.innerHTML)) {
extras.classList.add('collapsed');
expandcollapse.classList.add('expander');
} else {
expandcollapse.classList.add('collapser');
}
elem.appendChild(expandcollapse);
elem.addEventListener('click', function (event) {
if (
event.currentTarget.parentNode.nextElementSibling.classList.contains(
'collapsed'
)
) {
showExtras(event.currentTarget);
} else {
hideExtras(event.currentTarget);
}
});
});
}
function getQueryParameter(name) {
const match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
}
function init() {
// eslint-disable-line no-unused-vars
setTimeout(() => {
findAll('input[name="filter_checkbox"]').forEach((elem) => {
elem.addEventListener('click', (evt) => {
const { target: element } = evt;
const { testResult } = element.dataset;
doFilter(testResult, element.checked);
});
});
initRender();
addCollapse();
});
}

View File

@ -1,734 +0,0 @@
const jsonData = {
title: 'Next Gen Report',
collectedItems: 10,
environment: {
Python: '3.7.7',
Platform: 'Darwin-19.6.0-x86_64-i386-64bit',
Packages: { pytest: '6.1.2', py: '1.9.0', pluggy: '0.13.1' },
Plugins: { metadata: '1.11.0', reportlog: '0.1.2', html: '2.1.2.dev78' },
},
tests: [
{
nodeid: 'test_html.py::test_url',
location: ['test_html.py', 3, 'test_url'],
keywords: { test_url: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.0001576979999999839,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url',
location: ['test_html.py', 3, 'test_url'],
keywords: { test_url: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'failed',
extras: [
{
name: 'Snapshot',
format_type: 'image',
content: 'snapshot_of_what_went_wrong.png',
mime_type: 'image/png',
extension: '.png',
},
{
name: 'Google',
format_type: 'url',
content: 'https://search.yahoo.com/',
mime_type: null,
extension: null,
},
],
longrepr: {
reprcrash: {
path: '/Users/jimbrannlund/dev/pytest-dev/testing-html/test_html.py',
lineno: 12,
message: 'assert False',
},
reprtraceback: {
reprentries: [
{
type: 'ReprEntry',
data: {
lines: [
'universe = <pytest_setup.database.TestDataCollection object at 0x7fe2cf1c4dd8>',
'driver = <selenium.webdriver.firefox.webdriver.WebDriver (session="2c586c44-7cb6-8e48-86fd-ae2d2982a3bd")>',
'',
'@pytest.mark.user(name="Bob", account=\'Rolfs Account\')',
"@pytest.mark.setup_data({'EnterpriseProject': [{'name': 'Dependencies', 'user': 'Bob'}]},",
"{'Activity': [",
"{'name': 'Move',",
"'project': 'Dependencies',",
"'user': 'Bob',",
"'dates': {'start_date': 1, 'end_date': 7}},",
"{'name': 'Nope',",
"'project': 'Dependencies',",
"'user': 'Bob',",
"'dates': {'start_date': 8, 'end_date': 14}}]})",
'def test_dependency_forces_date_change(universe, driver):',
'from test_automation.tools.date_handling import convert_to_correct_date',
' ',
'_to_depend_on = "Move"',
"_expected_dates = {'start_date': convert_to_correct_date(15), 'end_date': convert_to_correct_date(21)}",
' ',
"_user = universe.get('User', 'Bob')",
"_act = universe.get('Activity', 'Move')",
' ',
'ActivityDetailsPane(driver).',
"go_to_page(universe.get('User', 'Bob'),",
"universe.get('EnterpriseProject', 'Dependencies'),",
'universe.get("Activity", "Nope")). ',
"open_activity('Move'). ",
"> open_activity('Nope'). ",
'open_widgets_section(). ',
"get_widget('planletdependencies').",
'add_predecessor(_to_depend_on).',
"get_details_pane('activity').",
'close_details_pane().',
'click_planlet_checkbox(_act.id).',
"get_widget('date').",
'verify_dates(_expected_dates)',
'',
'test_automation/tests/ui_tests/plan_tests/activity_details_pane_test.py:121: ',
'_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ',
'test_automation/page_objects/details_panes/activity_details_pane.py:241: in open_widgets_section',
'_wait_for_plus_sign()',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/decorators.py:112: in wait_handler',
'value = func(*args, **kwargs)',
'test_automation/page_objects/details_panes/activity_details_pane.py:236: in _wait_for_plus_sign',
'if self.get_present_element(self._plus_sign_locator):',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/base_page.py:422: in get_present_element',
'return self._get(locator, expected_condition, params, timeout, error_msg, parent)',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/base_page.py:569: in _get',
'return WebDriverWait(_driver, timeout).until(exp_cond, error_msg)',
'_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ',
'',
'self = <selenium.webdriver.support.wait.WebDriverWait (session="2c586c44-7cb6-8e48-86fd-ae2d2982a3bd")>',
'method = <selenium.webdriver.support.expected_conditions.presence_of_element_located object at 0x7fe2cf1a7240>',
'message = "Element was never present!\nLocator of type <CSS_SELECTOR> with selector <.pp-rounded-btn__icon.category-bck-3> with ...nExpected condition: <class \'selenium.webdriver.support.expected_conditions.presence_of_element_located\'>\nTimeout: 30"',
'',
"def until(self, method, message=''):",
'"""Calls the method provided with the driver as an argument until the ',
'return value is not False."""',
'screen = None',
'stacktrace = None',
' ',
'end_time = time.time() + self._timeout',
'while True:',
'try:',
'value = method(self._driver)',
'if value:',
'return value',
'except self._ignored_exceptions as exc:',
"screen = getattr(exc, 'screen', None)",
"stacktrace = getattr(exc, 'stacktrace', None)",
'time.sleep(self._poll)',
'if time.time() > end_time:',
'break',
'> raise TimeoutException(message, screen, stacktrace)',
'E selenium.common.exceptions.TimeoutException: Message: Element was never present!',
'E Locator of type <CSS_SELECTOR> with selector <.pp-rounded-btn__icon.category-bck-3> with params <None>',
"E Expected condition: <class 'selenium.webdriver.support.expected_conditions.presence_of_element_located'>",
'E Timeout: 30',
'',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/selenium/webdriver/support/wait.py:80: TimeoutException',
'------------------------------- pytest-selenium --------------------------------',
'Driver log: /private/var/folders/nt/zg2hyyxj6k77sf_qk34xtqf40000gn/T/pytest-of-vgustafsson/pytest-12/test_dependency_forces_date_ch0/driver.log',
'URL: https://local.rnd.projectplace.com/#project/79788/plan/28838',
'WARNING: Failed to gather log types: Message: HTTP method not allowed',
'',
'----------------------------- Additional Reporting -----------------------------',
'Window size: width <1680>, height <983>',
"WARNING: Failed to get HAR: 'NoneType' object has no attribute 'har'",
'Logs can be found here: http://elk.rnd.projectplace.com/goto/152df50b1a44ad0',
],
reprfuncargs: { args: [] },
reprlocals: null,
reprfileloc: {
path: 'test_html.py',
lineno: 12,
message: 'AssertionError',
},
style: 'long',
},
},
],
extraline: null,
style: 'long',
},
sections: [],
chain: [
[
{
reprentries: [
{
type: 'ReprEntry',
data: {
lines: [
' def test_url():',
' """',
' bla bla bla bla',
' alb alb alb',
' @param: hello',
' :param just',
' """',
' # driver.get("https://www.google.com")',
'> assert False',
'E assert False',
],
reprfuncargs: { args: [] },
reprlocals: null,
reprfileloc: {
path: 'test_html.py',
lineno: 12,
message: 'AssertionError',
},
style: 'long',
},
},
],
extraline: null,
style: 'long',
},
{
path:
'/Users/jimbrannlund/dev/pytest-dev/testing-html/test_html.py',
lineno: 12,
message: 'assert False',
},
null,
],
],
},
when: 'call',
user_properties: [],
sections: [],
duration: 0.00020797699999997032,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url',
location: ['test_html.py', 3, 'test_url'],
keywords: { test_url: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 0.0001561270000000059,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url2',
location: ['test_html.py', 14, 'test_url2'],
keywords: { test_url2: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url2',
location: ['test_html.py', 14, 'test_url2'],
keywords: { test_url2: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url2',
location: ['test_html.py', 14, 'test_url2'],
keywords: { test_url2: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url4',
location: ['test_html.py', 14, 'test_url4'],
keywords: { test_url4: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url4',
location: ['test_html.py', 14, 'test_url4'],
keywords: { test_url4: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url4',
location: ['test_html.py', 14, 'test_url4'],
keywords: { test_url4: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url5',
location: ['test_html.py', 14, 'test_url5'],
keywords: { test_url5: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url5',
location: ['test_html.py', 14, 'test_url5'],
keywords: { test_url5: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url5',
location: ['test_html.py', 14, 'test_url5'],
keywords: { test_url5: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url6',
location: ['test_html.py', 14, 'test_url6'],
keywords: { test_url6: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url6',
location: ['test_html.py', 14, 'test_url6'],
keywords: { test_url6: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url6',
location: ['test_html.py', 14, 'test_url6'],
keywords: { test_url6: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url7',
location: ['test_html.py', 14, 'test_url7'],
keywords: { test_url7: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url7',
location: ['test_html.py', 14, 'test_url7'],
keywords: { test_url7: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url7',
location: ['test_html.py', 14, 'test_url7'],
keywords: { test_url7: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url8',
location: ['test_html.py', 14, 'test_url8'],
keywords: { test_url8: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url8',
location: ['test_html.py', 14, 'test_url8'],
keywords: { test_url8: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url8',
location: ['test_html.py', 14, 'test_url8'],
keywords: { test_url8: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url9',
location: ['test_html.py', 14, 'test_url9'],
keywords: { test_url9: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url9',
location: ['test_html.py', 14, 'test_url9'],
keywords: { test_url9: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url9',
location: ['test_html.py', 14, 'test_url9'],
keywords: { test_url9: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url10',
location: ['test_html.py', 14, 'test_url10'],
keywords: { test_url10: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.00010436599999996687,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url10',
location: ['test_html.py', 14, 'test_url10'],
keywords: { test_url10: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'call',
user_properties: [],
sections: [],
duration: 0.00011543000000002746,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url10',
location: ['test_html.py', 14, 'test_url10'],
keywords: { test_url10: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 9.126600000003426,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url112',
location: ['test_html.py', 3, 'test_url112'],
keywords: { test_url112: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'setup',
user_properties: [],
sections: [],
duration: 0.0001576979999999839,
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url112',
location: ['test_html.py', 3, 'test_url112'],
keywords: { test_url112: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'failed',
extras: [
{
name: 'Snapshot',
format_type: 'image',
content: 'snapshot_of_what_went_wrong.png',
mime_type: 'image/png',
extension: '.png',
},
{
name: 'Google',
format_type: 'url',
content: 'https://search.yahoo.com/',
mime_type: null,
extension: null,
},
],
longrepr: {
reprcrash: {
path: '/Users/jimbrannlund/dev/pytest-dev/testing-html/test_html.py',
lineno: 12,
message: 'assert False',
},
reprtraceback: {
reprentries: [
{
type: 'ReprEntry',
data: {
lines: [
'universe = <pytest_setup.database.TestDataCollection object at 0x7fe2cf1c4dd8>',
'driver = <selenium.webdriver.firefox.webdriver.WebDriver (session="2c586c44-7cb6-8e48-86fd-ae2d2982a3bd")>',
'',
'@pytest.mark.user(name="Bob", account=\'Rolfs Account\')',
"@pytest.mark.setup_data({'EnterpriseProject': [{'name': 'Dependencies', 'user': 'Bob'}]},",
"{'Activity': [",
"{'name': 'Move',",
"'project': 'Dependencies',",
"'user': 'Bob',",
"'dates': {'start_date': 1, 'end_date': 7}},",
"{'name': 'Nope',",
"'project': 'Dependencies',",
"'user': 'Bob',",
"'dates': {'start_date': 8, 'end_date': 14}}]})",
'def test_dependency_forces_date_change(universe, driver):',
'from test_automation.tools.date_handling import convert_to_correct_date',
' ',
'_to_depend_on = "Move"',
"_expected_dates = {'start_date': convert_to_correct_date(15), 'end_date': convert_to_correct_date(21)}",
' ',
"_user = universe.get('User', 'Bob')",
"_act = universe.get('Activity', 'Move')",
' ',
'ActivityDetailsPane(driver).',
"go_to_page(universe.get('User', 'Bob'),",
"universe.get('EnterpriseProject', 'Dependencies'),",
'universe.get("Activity", "Nope")). ',
"open_activity('Move'). ",
"> open_activity('Nope'). ",
'open_widgets_section(). ',
"get_widget('planletdependencies').",
'add_predecessor(_to_depend_on).',
"get_details_pane('activity').",
'close_details_pane().',
'click_planlet_checkbox(_act.id).',
"get_widget('date').",
'verify_dates(_expected_dates)',
'',
'test_automation/tests/ui_tests/plan_tests/activity_details_pane_test.py:121: ',
'_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ',
'test_automation/page_objects/details_panes/activity_details_pane.py:241: in open_widgets_section',
'_wait_for_plus_sign()',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/decorators.py:112: in wait_handler',
'value = func(*args, **kwargs)',
'test_automation/page_objects/details_panes/activity_details_pane.py:236: in _wait_for_plus_sign',
'if self.get_present_element(self._plus_sign_locator):',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/base_page.py:422: in get_present_element',
'return self._get(locator, expected_condition, params, timeout, error_msg, parent)',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/basepage/base_page.py:569: in _get',
'return WebDriverWait(_driver, timeout).until(exp_cond, error_msg)',
'_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ',
'',
'self = <selenium.webdriver.support.wait.WebDriverWait (session="2c586c44-7cb6-8e48-86fd-ae2d2982a3bd")>',
'method = <selenium.webdriver.support.expected_conditions.presence_of_element_located object at 0x7fe2cf1a7240>',
'message = "Element was never present!\nLocator of type <CSS_SELECTOR> with selector <.pp-rounded-btn__icon.category-bck-3> with ...nExpected condition: <class \'selenium.webdriver.support.expected_conditions.presence_of_element_located\'>\nTimeout: 30"',
'',
"def until(self, method, message=''):",
'"""Calls the method provided with the driver as an argument until the ',
'return value is not False."""',
'screen = None',
'stacktrace = None',
' ',
'end_time = time.time() + self._timeout',
'while True:',
'try:',
'value = method(self._driver)',
'if value:',
'return value',
'except self._ignored_exceptions as exc:',
"screen = getattr(exc, 'screen', None)",
"stacktrace = getattr(exc, 'stacktrace', None)",
'time.sleep(self._poll)',
'if time.time() > end_time:',
'break',
'> raise TimeoutException(message, screen, stacktrace)',
'E selenium.common.exceptions.TimeoutException: Message: Element was never present!',
'E Locator of type <CSS_SELECTOR> with selector <.pp-rounded-btn__icon.category-bck-3> with params <None>',
"E Expected condition: <class 'selenium.webdriver.support.expected_conditions.presence_of_element_located'>",
'E Timeout: 30',
'',
'/Users/vgustafsson/.virtualenvs/pytest/lib/python3.7/site-packages/selenium/webdriver/support/wait.py:80: TimeoutException',
'------------------------------- pytest-selenium --------------------------------',
'Driver log: /private/var/folders/nt/zg2hyyxj6k77sf_qk34xtqf40000gn/T/pytest-of-vgustafsson/pytest-12/test_dependency_forces_date_ch0/driver.log',
'URL: https://local.rnd.projectplace.com/#project/79788/plan/28838',
'WARNING: Failed to gather log types: Message: HTTP method not allowed',
'',
'----------------------------- Additional Reporting -----------------------------',
'Window size: width <1680>, height <983>',
"WARNING: Failed to get HAR: 'NoneType' object has no attribute 'har'",
'Logs can be found here: http://elk.rnd.projectplace.com/goto/152df50b1a44ad0',
],
reprfuncargs: { args: [] },
reprlocals: null,
reprfileloc: {
path: 'test_html.py',
lineno: 12,
message: 'AssertionError',
},
style: 'long',
},
},
],
extraline: null,
style: 'long',
},
sections: [],
chain: [
[
{
reprentries: [
{
type: 'ReprEntry',
data: {
lines: [
' def test_url112():',
' """',
' bla bla bla bla',
' alb alb alb',
' @param: hello',
' :param just',
' """',
' # driver.get("https://www.google.com")',
'> assert False',
'E assert False',
],
reprfuncargs: { args: [] },
reprlocals: null,
reprfileloc: {
path: 'test_html.py',
lineno: 12,
message: 'AssertionError',
},
style: 'long',
},
},
],
extraline: null,
style: 'long',
},
{
path:
'/Users/jimbrannlund/dev/pytest-dev/testing-html/test_html.py',
lineno: 12,
message: 'assert False',
},
null,
],
],
},
when: 'call',
user_properties: [],
sections: [],
duration: 0.00020797699999997032,
extra: [],
$report_type: 'TestReport',
},
{
nodeid: 'test_html.py::test_url112',
location: ['test_html.py', 3, 'test_url112'],
keywords: { test_url112: 1, 'testing-html': 1, 'test_html.py': 1 },
outcome: 'passed',
longrepr: null,
when: 'teardown',
user_properties: [],
sections: [],
duration: 0.0001561270000000059,
$report_type: 'TestReport',
},
],
};

View File

@ -1,246 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
function toArray(iter) {
if (iter === null) {
return null;
}
return Array.prototype.slice.call(iter);
}
function find(selector, elem) { // eslint-disable-line no-redeclare
if (!elem) {
elem = document;
}
return elem.querySelector(selector);
}
function findAll(selector, elem) {
if (!elem) {
elem = document;
}
return toArray(elem.querySelectorAll(selector));
}
function sortColumn(elem) {
toggleSortStates(elem);
const colIndex = toArray(elem.parentNode.childNodes).indexOf(elem);
let key;
if (elem.classList.contains('result')) {
key = keyResult;
} else if (elem.classList.contains('links')) {
key = keyLink;
} else {
key = keyAlpha;
}
sortTable(elem, key(colIndex));
}
function showAllExtras() { // eslint-disable-line no-unused-vars
findAll('.col-result').forEach(showExtras);
}
function hideAllExtras() { // eslint-disable-line no-unused-vars
findAll('.col-result').forEach(hideExtras);
}
function showExtras(colresultElem) {
const extras = colresultElem.parentNode.nextElementSibling;
const expandcollapse = colresultElem.firstElementChild;
extras.classList.remove('collapsed');
expandcollapse.classList.remove('expander');
expandcollapse.classList.add('collapser');
}
function hideExtras(colresultElem) {
const extras = colresultElem.parentNode.nextElementSibling;
const expandcollapse = colresultElem.firstElementChild;
extras.classList.add('collapsed');
expandcollapse.classList.remove('collapser');
expandcollapse.classList.add('expander');
}
function showFilters() {
let visibleString = getQueryParameter('visible') || 'all';
visibleString = visibleString.toLowerCase();
const checkedItems = visibleString.split(',');
const filterItems = document.getElementsByClassName('filter');
for (let i = 0; i < filterItems.length; i++) {
filterItems[i].hidden = false;
if (visibleString != 'all') {
filterItems[i].checked = checkedItems.includes(filterItems[i].getAttribute('data-test-result'));
filterTable(filterItems[i]);
}
}
}
function addCollapse() {
// Add links for show/hide all
const resulttable = find('table#results-table');
const showhideall = document.createElement('p');
showhideall.innerHTML = '<a href="javascript:showAllExtras()">Show all details</a> / ' +
'<a href="javascript:hideAllExtras()">Hide all details</a>';
resulttable.parentElement.insertBefore(showhideall, resulttable);
// Add show/hide link to each result
findAll('.col-result').forEach(function(elem) {
const collapsed = getQueryParameter('collapsed') || 'Passed';
const extras = elem.parentNode.nextElementSibling;
const expandcollapse = document.createElement('span');
if (extras.classList.contains('collapsed')) {
expandcollapse.classList.add('expander');
} else if (collapsed.includes(elem.innerHTML)) {
extras.classList.add('collapsed');
expandcollapse.classList.add('expander');
} else {
expandcollapse.classList.add('collapser');
}
elem.appendChild(expandcollapse);
elem.addEventListener('click', function(event) {
if (event.currentTarget.parentNode.nextElementSibling.classList.contains('collapsed')) {
showExtras(event.currentTarget);
} else {
hideExtras(event.currentTarget);
}
});
});
}
function getQueryParameter(name) {
const match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
}
function init () { // eslint-disable-line no-unused-vars
resetSortHeaders();
addCollapse();
showFilters();
sortColumn(find('.initial-sort'));
findAll('.sortable').forEach(function(elem) {
elem.addEventListener('click',
function() {
sortColumn(elem);
}, false);
});
}
function sortTable(clicked, keyFunc) {
const rows = findAll('.results-table-row');
const reversed = !clicked.classList.contains('asc');
const sortedRows = sort(rows, keyFunc, reversed);
/* Whole table is removed here because browsers acts much slower
* when appending existing elements.
*/
const thead = document.getElementById('results-table-head');
document.getElementById('results-table').remove();
const parent = document.createElement('table');
parent.id = 'results-table';
parent.appendChild(thead);
sortedRows.forEach(function(elem) {
parent.appendChild(elem);
});
document.getElementsByTagName('BODY')[0].appendChild(parent);
}
function sort(items, keyFunc, reversed) {
const sortArray = items.map(function(item, i) {
return [keyFunc(item), i];
});
sortArray.sort(function(a, b) {
const keyA = a[0];
const keyB = b[0];
if (keyA == keyB) return 0;
if (reversed) {
return keyA < keyB ? 1 : -1;
} else {
return keyA > keyB ? 1 : -1;
}
});
return sortArray.map(function(item) {
const index = item[1];
return items[index];
});
}
function keyAlpha(colIndex) {
return function(elem) {
return elem.childNodes[1].childNodes[colIndex].firstChild.data.toLowerCase();
};
}
function keyLink(colIndex) {
return function(elem) {
const dataCell = elem.childNodes[1].childNodes[colIndex].firstChild;
return dataCell == null ? '' : dataCell.innerText.toLowerCase();
};
}
function keyResult(colIndex) {
return function(elem) {
const strings = ['Error', 'Failed', 'Rerun', 'XFailed', 'XPassed',
'Skipped', 'Passed'];
return strings.indexOf(elem.childNodes[1].childNodes[colIndex].firstChild.data);
};
}
function resetSortHeaders() {
findAll('.sort-icon').forEach(function(elem) {
elem.parentNode.removeChild(elem);
});
findAll('.sortable').forEach(function(elem) {
const icon = document.createElement('div');
icon.className = 'sort-icon';
icon.textContent = 'vvv';
elem.insertBefore(icon, elem.firstChild);
elem.classList.remove('desc', 'active');
elem.classList.add('asc', 'inactive');
});
}
function toggleSortStates(elem) {
//if active, toggle between asc and desc
if (elem.classList.contains('active')) {
elem.classList.toggle('asc');
elem.classList.toggle('desc');
}
//if inactive, reset all other functions and add ascending active
if (elem.classList.contains('inactive')) {
resetSortHeaders();
elem.classList.remove('inactive');
elem.classList.add('active');
}
}
function isAllRowsHidden(value) {
return value.hidden == false;
}
function filterTable(elem) { // eslint-disable-line no-unused-vars
const outcomeAtt = 'data-test-result';
const outcome = elem.getAttribute(outcomeAtt);
const classOutcome = outcome + ' results-table-row';
const outcomeRows = document.getElementsByClassName(classOutcome);
for(let i = 0; i < outcomeRows.length; i++){
outcomeRows[i].hidden = !elem.checked;
}
const rows = findAll('.results-table-row').filter(isAllRowsHidden);
const allRowsHidden = rows.length == 0 ? true : false;
const notFoundMessage = document.getElementById('not-found-message');
notFoundMessage.hidden = !allRowsHidden;
}

View File

@ -1,10 +0,0 @@
const genericSort = (list, key, ascending) => {
const sorted = list.sort((a, b) =>
a[key] === b[key] ? 0 : a[key] === b[key] ? 1 : -1
);
if (ascending) {
sorted.reverse();
}
return sorted;
};

View File

@ -70,10 +70,6 @@ span.xpassed,
color: red;
}
.col-result {
text-transform: capitalize;
}
.col-links__extra {
margin-right: 3px;
}
@ -123,48 +119,70 @@ span.xpassed,
height: inherit;
}
div.image {
div.media {
border: 1px solid #e6e6e6;
float: right;
height: 240px;
margin-left: 5px;
margin: 0 5px;
overflow: hidden;
width: 320px;
}
div.image img {
width: 320px;
}
div.video {
border: 1px solid #e6e6e6;
float: right;
height: 240px;
margin-left: 5px;
.media-container {
display: grid;
grid-template-columns: 25px auto 25px;
align-items: center;
flex: 1 1;
overflow: hidden;
width: 320px;
height: 200px;
}
div.video video {
overflow: hidden;
width: 320px;
height: 240px;
.media-container__nav--right,
.media-container__nav--left {
text-align: center;
cursor: pointer;
}
.media-container__viewport {
cursor: pointer;
text-align: center;
height: inherit;
}
.media-container__viewport img,
.media-container__viewport video {
object-fit: cover;
width: 100%;
max-height: 100%;
}
.media__name,
.media__counter {
display: flex;
flex-direction: row;
justify-content: space-around;
flex: 0 0 25px;
align-items: center;
}
.collapsed {
display: none;
}
.expander::after {
content: " (show details)";
.col-result {
cursor: pointer;
}
.col-result:hover::after {
color: #bbb;
font-style: italic;
cursor: pointer;
}
.collapser::after {
.col-result.collapser:hover::after {
content: " (hide details)";
color: #bbb;
font-style: italic;
cursor: pointer;
}
.col-result.expander:hover::after {
content: " (show details)";
}
/*------------------
@ -198,9 +216,6 @@ div.video video {
display: none;
}
.summary {
display: flex;
}
.summary__data {
flex: 0 0 550px;
}
@ -228,6 +243,29 @@ div.video video {
flex: 0 0 550px;
}
input.filter {
margin-left: 10px;
.controls {
display: flex;
justify-content: space-between;
}
.filters,
.collapse {
display: flex;
align-items: center;
}
.filters button,
.collapse button {
color: #999;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
}
.filters button:hover,
.collapse button:hover {
color: #ccc;
}
.filter__label {
margin-right: 10px;
}

View File

@ -1,287 +0,0 @@
import json
import os
import re
import time
import warnings
from base64 import b64decode
from base64 import b64encode
from html import escape
from os.path import isfile
from _pytest.logging import _remove_ansi_escape_sequences
from py.xml import html
from py.xml import raw
from . import extras
from .util import ansi_support
class TestResult:
def __init__(self, outcome, report, logfile, config):
self.test_id = report.nodeid.encode("utf-8").decode("unicode_escape")
if getattr(report, "when", "call") != "call":
self.test_id = "::".join([report.nodeid, report.when])
self.time = getattr(report, "duration", 0.0)
self.formatted_time = self._format_time(report)
self.outcome = outcome
self.additional_html = []
self.links_html = []
self.self_contained = config.getoption("self_contained_html")
self.max_asset_filename_length = int(config.getini("max_asset_filename_length"))
self.logfile = logfile
self.config = config
self.row_table = self.row_extra = None
test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
for extra_index, extra in enumerate(getattr(report, "extra", [])):
self.append_extra_html(extra, extra_index, test_index)
self.append_log_html(
report,
self.additional_html,
config.option.capture,
config.option.showcapture,
)
cells = [
html.td(self.outcome, class_="col-result"),
html.td(self.test_id, class_="col-name"),
html.td(self.formatted_time, class_="col-duration"),
html.td(self.links_html, class_="col-links"),
]
self.config.hook.pytest_html_results_table_row(report=report, cells=cells)
self.config.hook.pytest_html_results_table_html(
report=report, data=self.additional_html
)
if len(cells) > 0:
tr_class = None
if self.config.getini("render_collapsed"):
tr_class = "collapsed"
self.row_table = html.tr(cells)
self.row_extra = html.tr(
html.td(self.additional_html, class_="extra", colspan=len(cells)),
class_=tr_class,
)
def __lt__(self, other):
order = (
"Error",
"Failed",
"Rerun",
"XFailed",
"XPassed",
"Skipped",
"Passed",
)
return order.index(self.outcome) < order.index(other.outcome)
def create_asset(self, content, extra_index, test_index, file_extension, mode="w"):
asset_file_name = "{}_{}_{}.{}".format(
re.sub(r"[^\w\.]", "_", self.test_id),
str(extra_index),
str(test_index),
file_extension,
)[-self.max_asset_filename_length :]
asset_path = os.path.join(
os.path.dirname(self.logfile), "assets", asset_file_name
)
os.makedirs(os.path.dirname(asset_path), exist_ok=True)
relative_path = f"assets/{asset_file_name}"
kwargs = {"encoding": "utf-8"} if "b" not in mode else {}
with open(asset_path, mode, **kwargs) as f:
f.write(content)
return relative_path
def append_extra_html(self, extra, extra_index, test_index):
href = None
if extra.get("format_type") == extras.FORMAT_IMAGE:
self._append_image(extra, extra_index, test_index)
elif extra.get("format_type") == extras.FORMAT_HTML:
self.additional_html.append(html.div(raw(extra.get("content"))))
elif extra.get("format_type") == extras.FORMAT_JSON:
content = json.dumps(extra.get("content"))
if self.self_contained:
href = self._data_uri(content, mime_type=extra.get("mime_type"))
else:
href = self.create_asset(
content, extra_index, test_index, extra.get("extension")
)
elif extra.get("format_type") == extras.FORMAT_TEXT:
content = extra.get("content")
if isinstance(content, bytes):
content = content.decode("utf-8")
if self.self_contained:
href = self._data_uri(content)
else:
href = self.create_asset(
content, extra_index, test_index, extra.get("extension")
)
elif extra.get("format_type") == extras.FORMAT_URL:
href = extra.get("content")
elif extra.get("format_type") == extras.FORMAT_VIDEO:
self._append_video(extra, extra_index, test_index)
if href is not None:
self.links_html.append(
html.a(
extra.get("name"),
class_=extra.get("format_type"),
href=href,
target="_blank",
)
)
self.links_html.append(" ")
def _format_time(self, report):
# parse the report duration into its display version and return
# it to the caller
duration = getattr(report, "duration", None)
if duration is None:
return ""
duration_formatter = getattr(report, "duration_formatter", None)
string_duration = str(duration)
if duration_formatter is None:
if "." in string_duration:
split_duration = string_duration.split(".")
split_duration[1] = split_duration[1][0:2]
string_duration = ".".join(split_duration)
return string_duration
else:
# support %f, since time.strftime doesn't support it out of the box
# keep a precision of 2 for legacy reasons
formatted_milliseconds = "00"
if "." in string_duration:
milliseconds = string_duration.split(".")[1]
formatted_milliseconds = milliseconds[0:2]
duration_formatter = duration_formatter.replace(
"%f", formatted_milliseconds
)
duration_as_gmtime = time.gmtime(report.duration)
return time.strftime(duration_formatter, duration_as_gmtime)
def _populate_html_log_div(self, log, report):
if report.longrepr:
# longreprtext is only filled out on failure by pytest
# otherwise will be None.
# Use full_text if longreprtext is None-ish
# we added full_text elsewhere in this file.
text = report.longreprtext or report.full_text
for line in text.splitlines():
separator = line.startswith("_ " * 10)
if separator:
log.append(line[:80])
else:
exception = line.startswith("E ")
if exception:
log.append(html.span(raw(escape(line)), class_="error"))
else:
log.append(raw(escape(line)))
log.append(html.br())
for section in report.sections:
header, content = map(escape, section)
log.append(f" {header:-^80} ")
log.append(html.br())
if ansi_support():
converter = ansi_support().Ansi2HTMLConverter(
inline=False, escaped=False
)
content = converter.convert(content, full=False)
else:
content = _remove_ansi_escape_sequences(content)
log.append(raw(content))
log.append(html.br())
def append_log_html(
self,
report,
additional_html,
pytest_capture_value,
pytest_show_capture_value,
):
log = html.div(class_="log")
should_skip_captured_output = pytest_capture_value == "no"
if report.outcome == "failed" and not should_skip_captured_output:
should_skip_captured_output = pytest_show_capture_value == "no"
if not should_skip_captured_output:
self._populate_html_log_div(log, report)
if len(log) == 0:
log = html.div(class_="empty log")
log.append("No log output captured.")
additional_html.append(log)
def _make_media_html_div(
self, extra, extra_index, test_index, base_extra_string, base_extra_class
):
content = extra.get("content")
try:
is_uri_or_path = content.startswith(("file", "http")) or isfile(content)
except ValueError:
# On Windows, os.path.isfile throws this exception when
# passed a b64 encoded image.
is_uri_or_path = False
if is_uri_or_path:
if self.self_contained:
warnings.warn(
"Self-contained HTML report "
"includes link to external "
f"resource: {content}"
)
html_div = html.a(
raw(base_extra_string.format(extra.get("content"))), href=content
)
elif self.self_contained:
src = f"data:{extra.get('mime_type')};base64,{content}"
html_div = raw(base_extra_string.format(src))
else:
content = b64decode(content.encode("utf-8"))
href = src = self.create_asset(
content, extra_index, test_index, extra.get("extension"), "wb"
)
html_div = html.a(
raw(base_extra_string.format(src)),
class_=base_extra_class,
target="_blank",
href=href,
)
return html_div
def _append_image(self, extra, extra_index, test_index):
image_base = '<img src="{}"/>'
html_div = self._make_media_html_div(
extra, extra_index, test_index, image_base, "image"
)
self.additional_html.append(html.div(html_div, class_="image"))
def _append_video(self, extra, extra_index, test_index):
video_base = '<video controls><source src="{}" type="video/mp4"></video>'
html_div = self._make_media_html_div(
extra, extra_index, test_index, video_base, "video"
)
self.additional_html.append(html.div(html_div, class_="video"))
def _data_uri(self, content, mime_type="text/plain", charset="utf-8"):
data = b64encode(content.encode(charset)).decode("ascii")
return f"data:{mime_type};charset={charset};base64,{data}"

View File

@ -0,0 +1,57 @@
const { getCollapsedCategory } = require('./storage.js')
class DataManager {
setManager(data) {
const collapsedCategories = [...getCollapsedCategory(), 'passed']
const dataBlob = { ...data, tests: data.tests.map((test, index) => ({
...test,
id: `test_${index}`,
collapsed: collapsedCategories.includes(test.outcome.toLowerCase()),
})) }
this.data = { ...dataBlob }
this.renderData = { ...dataBlob }
}
get allData() {
return { ...this.data }
}
resetRender() {
this.renderData = { ...this.data }
}
setRender(data) {
this.renderData.tests = [...data]
}
toggleCollapsedItem(id) {
this.renderData.tests = this.renderData.tests.map((test) =>
test.id === id ? { ...test, collapsed: !test.collapsed } : test,
)
}
set allCollapsed(collapsed) {
this.renderData = { ...this.renderData, tests: [...this.renderData.tests.map((test) => (
{ ...test, collapsed }
))] }
}
get testSubset() {
return [...this.renderData.tests]
}
get allTests() {
return [...this.data.tests]
}
get title() {
return this.renderData.title
}
get environment() {
return this.renderData.environment
}
get collectedItems() {
return this.renderData.collectedItems
}
get isFinished() {
return this.data.runningState === 'Finished'
}
}
module.exports = {
manager: new DataManager(),
}

View File

@ -0,0 +1,130 @@
const storageModule = require('./storage.js')
const { formatDuration } = require('./utils.js')
const mediaViewer = require('./mediaviewer.js')
const templateEnvRow = document.querySelector('#template_environment_row')
const templateCollGroup = document.querySelector('#template_table-colgroup')
const templateResult = document.querySelector('#template_results-table__tbody')
const aTag = document.querySelector('#template_a')
const listHeader = document.querySelector('#template_results-table__head')
const listHeaderEmpty = document.querySelector('#template_results-table__head--empty')
function htmlToElements(html) {
const temp = document.createElement('template')
temp.innerHTML = html
return temp.content.childNodes
}
const find = (selector, elem) => {
if (!elem) {
elem = document
}
return elem.querySelector(selector)
}
const findAll = (selector, elem) => {
if (!elem) {
elem = document
}
return [...elem.querySelectorAll(selector)]
}
const insertAdditionalHTML = (html, element, selector) => {
Object.keys(html).map((key) => {
element.querySelectorAll(selector).item(key).insertAdjacentHTML('beforebegin', html[key])
})
}
const dom = {
getStaticRow: (key, value) => {
const envRow = templateEnvRow.content.cloneNode(true)
const isObj = typeof value === 'object' && value !== null
const values = isObj ? Object.keys(value).map((k) => `${k}: ${value[k]}`) : null
const valuesElement = htmlToElements(
values ? `<ul>${values.map((val) => `<li>${val}</li>`).join('')}<ul>` : `<div>${value}</div>`)[0]
const td = findAll('td', envRow)
td[0].textContent = key
td[1].appendChild(valuesElement)
return envRow
},
getListHeader: ({ resultsTableHeader }) => {
const header = listHeader.content.cloneNode(true)
const sortAttr = storageModule.getSort()
const sortAsc = JSON.parse(storageModule.getSortDirection())
const sortables = ['outcome', 'nodeid', 'duration']
sortables.forEach((sortCol) => {
if (sortCol === sortAttr) {
header.querySelector(`[data-column-type="${sortCol}"]`).classList.add(sortAsc ? 'desc' : 'asc')
}
})
// Add custom html from the pytest_html_results_table_header hook
insertAdditionalHTML(resultsTableHeader, header, 'th')
return header
},
getListHeaderEmpty: () => listHeaderEmpty.content.cloneNode(true),
getColGroup: () => templateCollGroup.content.cloneNode(true),
getResultTBody: ({ nodeid, id, longreprtext, duration, extras, resultsTableRow, tableHtml, outcome, collapsed }) => {
const outcomeLower = outcome.toLowerCase()
let formattedDuration = formatDuration(duration)
formattedDuration = formatDuration < 1 ? formattedDuration.ms : formattedDuration.formatted
const resultBody = templateResult.content.cloneNode(true)
resultBody.querySelector('tbody').classList.add(outcomeLower)
resultBody.querySelector('.col-result').innerText = outcome
resultBody.querySelector('.col-result').classList.add(`${collapsed ? 'expander' : 'collapser'}`)
resultBody.querySelector('.col-result').dataset.id = id
resultBody.querySelector('.col-name').innerText = nodeid
resultBody.querySelector('.col-duration').innerText = duration < 1 ? formatDuration(duration).ms : formatDuration(duration).formatted
if (longreprtext) {
// resultBody.querySelector('.log').innerText = longreprtext
resultBody.querySelector('.log').innerHTML = longreprtext
}
// if (collapsed || !longreprtext) {
if (collapsed) {
resultBody.querySelector('.extras-row').classList.add('hidden')
}
const media = []
extras?.forEach(({ name, format_type, content }) => {
if (['json', 'text', 'url'].includes(format_type)) {
const extraLink = aTag.content.cloneNode(true)
const extraLinkItem = extraLink.querySelector('a')
extraLinkItem.href = content
extraLinkItem.className = `col-links__extra ${format_type}`
extraLinkItem.innerText = name
resultBody.querySelector('.col-links').appendChild(extraLinkItem)
}
if (['image', 'video'].includes(format_type)) {
media.push({ path: content, name, format_type })
}
if (format_type === 'html') {
resultBody.querySelector('.extraHTML').insertAdjacentHTML('beforeend', `<div>${content}</div>`)
}
})
mediaViewer.setUp(resultBody, media)
// Add custom html from the pytest_html_results_table_row hook
resultsTableRow && insertAdditionalHTML(resultsTableRow, resultBody, 'td')
// Add custom html from the pytest_html_results_table_html hook
tableHtml?.forEach((item) => {
resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item)
})
return resultBody
},
}
exports.dom = dom
exports.htmlToElements = htmlToElements
exports.find = find
exports.findAll = findAll

View File

@ -0,0 +1,33 @@
const { manager } = require('./datamanager.js')
const storageModule = require('./storage.js')
const getFilteredSubSet = (filter) =>
manager.allData.tests.filter(({ outcome }) => filter.includes(outcome.toLowerCase()))
const doInitFilter = () => {
const currentFilter = storageModule.getVisible()
const filteredSubset = getFilteredSubSet(currentFilter)
manager.setRender(filteredSubset)
}
const doFilter = (type, show) => {
if (show) {
storageModule.showCategory(type)
} else {
storageModule.hideCategory(type)
}
const currentFilter = storageModule.getVisible()
if (currentFilter.length) {
const filteredSubset = getFilteredSubSet(currentFilter)
manager.setRender(filteredSubset)
} else {
manager.resetRender()
}
}
module.exports = {
doFilter,
doInitFilter,
}

View File

@ -0,0 +1,15 @@
const { redraw, bindEvents } = require('./main.js')
const { doInitFilter } = require('./filter.js')
const { doInitSort } = require('./sort.js')
const { manager } = require('./datamanager.js')
const data = JSON.parse(document.querySelector('#data-container').dataset.jsonblob)
function init() {
manager.setManager(data)
doInitFilter()
doInitSort()
redraw()
bindEvents()
}
init()

View File

@ -0,0 +1,134 @@
const { formatDuration } = require('./utils.js')
const { dom, findAll } = require('./dom.js')
const { manager } = require('./datamanager.js')
const { doSort } = require('./sort.js')
const { doFilter } = require('./filter.js')
const { getVisible } = require('./storage.js')
const removeChildren = (node) => {
while (node.firstChild) {
node.removeChild(node.firstChild)
}
}
const renderStatic = () => {
const renderTitle = () => {
const title = manager.title
document.querySelector('#title').innerText = title
document.querySelector('#head-title').innerText = title
}
const renderTable = () => {
const environment = manager.environment
const rows = Object.keys(environment).map((key) => dom.getStaticRow(key, environment[key]))
const table = document.querySelector('#environment')
removeChildren(table)
rows.forEach((row) => table.appendChild(row))
}
renderTitle()
renderTable()
}
const renderContent = (tests) => {
const renderSet = tests.filter(({ when, outcome }) => when === 'call' || outcome === 'Error' )
const rows = renderSet.map(dom.getResultTBody)
const table = document.querySelector('#results-table')
removeChildren(table)
const tableHeader = dom.getListHeader(manager.renderData)
if (!rows.length) {
tableHeader.appendChild(dom.getListHeaderEmpty())
}
table.appendChild(dom.getColGroup())
table.appendChild(tableHeader)
rows.forEach((row) => !!row && table.appendChild(row))
table.querySelectorAll('.extra').forEach((item) => {
item.colSpan = document.querySelectorAll('th').length
})
findAll('.sortable').forEach((elem) => {
elem.addEventListener('click', (evt) => {
const { target: element } = evt
const { columnType } = element.dataset
doSort(columnType)
redraw()
})
})
findAll('.col-result').forEach((elem) => {
elem.addEventListener('click', ({ target }) => {
manager.toggleCollapsedItem(target.dataset.id)
redraw()
})
})
}
const renderDerived = (tests, collectedItems, isFinished) => {
const renderSet = tests.filter(({ when, outcome }) => when === 'call' || outcome === 'Error')
const possibleOutcomes = [
{ outcome: 'passed', label: 'Passed' },
{ outcome: 'skipped', label: 'Skipped' },
{ outcome: 'failed', label: 'Failed' },
{ outcome: 'error', label: 'Errors' },
{ outcome: 'xfailed', label: 'Unexpected failures' },
{ outcome: 'xpassed', label: 'Unexpected passes' },
{ outcome: 'rerun', label: 'Reruns' },
]
const currentFilter = getVisible()
possibleOutcomes.forEach(({ outcome, label }) => {
const count = renderSet.filter((test) => test.outcome.toLowerCase() === outcome).length
const input = document.querySelector(`input[data-test-result="${outcome}"]`)
document.querySelector(`.${outcome}`).innerText = `${count} ${label}`
input.disabled = !count
input.checked = currentFilter.includes(outcome)
})
const numberOfTests = renderSet.filter(({ outcome }) =>
['Passed', 'Failed', 'XPassed', 'XFailed'].includes(outcome)).length
if (isFinished) {
const accTime = tests.reduce((prev, { duration }) => prev + duration, 0)
const formattedAccTime = formatDuration(accTime)
const testWord = numberOfTests > 1 ? 'tests' : 'test'
const durationText = formattedAccTime.hasOwnProperty('ms') ? formattedAccTime.ms : formattedAccTime.seconds
document.querySelector('.run-count').innerText = `${numberOfTests} ${testWord} ran in ${durationText}.`
document.querySelector('.summary__reload__button').classList.add('hidden')
} else {
document.querySelector('.run-count').innerText = `${numberOfTests} / ${collectedItems} tests done`
}
}
const bindEvents = () => {
const filterColumn = (evt) => {
const { target: element } = evt
const { testResult } = element.dataset
doFilter(testResult, element.checked)
redraw()
}
findAll('input[name="filter_checkbox"]').forEach((elem) => {
elem.removeEventListener('click', filterColumn)
elem.addEventListener('click', filterColumn)
})
document.querySelector('#show_all_details').addEventListener('click', () => {
manager.allCollapsed = false
redraw()
})
document.querySelector('#hide_all_details').addEventListener('click', () => {
manager.allCollapsed = true
redraw()
})
}
const redraw = () => {
const { testSubset, allTests, collectedItems, isFinished } = manager
renderStatic()
renderContent(testSubset)
renderDerived(allTests, collectedItems, isFinished)
}
exports.redraw = redraw
exports.bindEvents = bindEvents

View File

@ -0,0 +1,74 @@
class MediaViewer {
constructor(assets) {
this.assets = assets
this.index = 0
}
nextActive() {
this.index = this.index === this.assets.length - 1 ? 0 : this.index + 1
return [this.activeFile, this.index]
}
prevActive() {
this.index = this.index === 0 ? this.assets.length - 1 : this.index -1
return [this.activeFile, this.index]
}
get currentIndex() {
return this.index
}
get activeFile() {
return this.assets[this.index]
}
}
const setUp = (resultBody, assets) => {
if (!assets.length) {
resultBody.querySelector('.media').classList.add('hidden')
return
}
const mediaViewer = new MediaViewer(assets)
const leftArrow = resultBody.querySelector('.media-container__nav--left')
const rightArrow = resultBody.querySelector('.media-container__nav--right')
const mediaName = resultBody.querySelector('.media__name')
const counter = resultBody.querySelector('.media__counter')
const imageEl = resultBody.querySelector('img')
const sourceEl = resultBody.querySelector('source')
const videoEl = resultBody.querySelector('video')
const setImg = (media, index) => {
if (media?.format_type === 'image') {
imageEl.src = media.path
imageEl.classList.remove('hidden')
videoEl.classList.add('hidden')
} else if (media?.format_type === 'video') {
sourceEl.src = media.path
videoEl.classList.remove('hidden')
imageEl.classList.add('hidden')
}
mediaName.innerText = media?.name
counter.innerText = `${index + 1} / ${assets.length}`
}
setImg(mediaViewer.activeFile, mediaViewer.currentIndex)
const moveLeft = () => {
const [media, index] = mediaViewer.prevActive()
setImg(media, index)
}
const doRight = () => {
const [media, index] = mediaViewer.nextActive()
setImg(media, index)
}
const openImg = () => {
window.open(mediaViewer.activeFile.path, '_blank')
}
leftArrow.addEventListener('click', moveLeft)
rightArrow.addEventListener('click', doRight)
imageEl.addEventListener('click', openImg)
}
exports.setUp = setUp

View File

@ -0,0 +1,34 @@
const { manager } = require('./datamanager.js')
const storageModule = require('./storage.js')
const genericSort = (list, key, ascending) => {
const sorted = list.sort((a, b) => a[key] === b[key] ? 0 : a[key] > b[key] ? 1 : -1)
if (ascending) {
sorted.reverse()
}
return sorted
}
const doInitSort = () => {
const type = storageModule.getSort()
const ascending = storageModule.getSortDirection()
const list = manager.testSubset
const sortedList = genericSort(list, type, ascending)
manager.setRender(sortedList)
}
const doSort = (type) => {
const newSortType = storageModule.getSort() !== type
const currentAsc = storageModule.getSortDirection()
const ascending = newSortType ? true : !currentAsc
storageModule.setSort(type)
storageModule.setSortDirection(ascending)
const list = manager.testSubset
const sortedList = genericSort(list, type, ascending)
manager.setRender(sortedList)
}
exports.doSort = doSort
exports.doInitSort = doInitSort

View File

@ -0,0 +1,78 @@
const possibleFiltes = ['passed', 'skipped', 'failed', 'error', 'xfailed', 'xpassed', 'rerun']
const getVisible = () => {
const url = new URL(window.location.href)
const settings = new URLSearchParams(url.search).get('visible') || ''
return settings ?
[...new Set(settings.split(',').filter((filter) => possibleFiltes.includes(filter)))] : possibleFiltes
}
const hideCategory = (categoryToHide) => {
const url = new URL(window.location.href)
const visibleParams = new URLSearchParams(url.search).get('visible')
const currentVisible = visibleParams ? visibleParams.split(',') : [...possibleFiltes]
const settings = [...new Set(currentVisible)].filter((f) => f !== categoryToHide).join(',')
url.searchParams.set('visible', settings)
history.pushState({}, null, unescape(url.href))
}
const showCategory = (categoryToShow) => {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
const currentVisible = new URLSearchParams(url.search).get('visible')?.split(',') || [...possibleFiltes]
const settings = [...new Set([categoryToShow, ...currentVisible])]
const noFilter = possibleFiltes.length === settings.length || !settings.length
noFilter ? url.searchParams.delete('visible') : url.searchParams.set('visible', settings.join(','))
history.pushState({}, null, unescape(url.href))
}
const setFilter = (currentFilter) => {
if (!possibleFiltes.includes(currentFilter)) {
return
}
const url = new URL(window.location.href)
const settings = [currentFilter, ...new Set(new URLSearchParams(url.search).get('filter').split(','))]
url.searchParams.set('filter', settings)
history.pushState({}, null, unescape(url.href))
}
const getSort = () => {
const url = new URL(window.location.href)
return new URLSearchParams(url.search).get('sort') || 'outcome'
}
const setSort = (type) => {
const url = new URL(window.location.href)
url.searchParams.set('sort', type)
history.pushState({}, null, unescape(url.href))
}
const getCollapsedCategory = () => {
let categotries
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
const collapsedItems = new URLSearchParams(url.search).get('collapsed')
categotries = collapsedItems?.split(',') || []
} else {
categotries = []
}
return categotries
}
const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc'))
const setSortDirection = (ascending) => sessionStorage.setItem('sortAsc', ascending)
module.exports = {
getVisible,
setFilter,
hideCategory,
showCategory,
getSort,
getSortDirection,
setSort,
setSortDirection,
getCollapsedCategory,
}

View File

@ -0,0 +1,24 @@
const formatedNumber = (number) =>
number.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
})
const formatDuration = ( totalSeconds ) => {
if (totalSeconds < 1) {
return {ms: `${Math.round(totalSeconds * 1000)} ms`}
}
const hours = Math.floor(totalSeconds / 3600)
let remainingSeconds = totalSeconds % 3600
const minutes = Math.floor(remainingSeconds / 60)
remainingSeconds = remainingSeconds % 60
const seconds = Math.round(remainingSeconds)
return {
seconds: `${Math.round(totalSeconds)} seconds`,
formatted: `${formatedNumber(hours)}:${formatedNumber(minutes)}:${formatedNumber(seconds)}`,
}
}
module.exports = { formatDuration }

View File

@ -1,5 +1,8 @@
import importlib
import json
from functools import lru_cache
from typing import Any
from typing import Dict
@lru_cache()
@ -10,3 +13,15 @@ def ansi_support():
except ImportError:
# ansi2html is not installed
pass
def cleanup_unserializable(d: Dict[str, Any]) -> Dict[str, Any]:
"""Return new dict with entries that are not json serializable by their str()."""
result = {}
for k, v in d.items():
try:
json.dumps({k: v})
except TypeError:
v = str(v)
result[k] = v
return result

13
start Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
if [[ $(uname) == "Darwin" ]]; then
volume="/private/var/folders:/reports/private/var/folders"
else
volume="${TMPDIR:-/tmp}:/reports${TMPDIR:-/tmp}"
fi
if [[ "${1}" == "down" ]]; then
docker-compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) down
else
docker-compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) up -d
fi

View File

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>QUnit Pytest-HTML</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.0.1.css">
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<script src="https://code.jquery.com/qunit/qunit-2.0.1.js"></script>
<script src="https://raw.githubusercontent.com/alex-seville/blanket/master/dist/qunit/blanket.min.js"></script>
<script src="test.js"></script>
<script src="../src/pytest_html/resources/old_main.js" data-cover></script>
<div id="qunit-fixture">
<table id="results-table">
<thead id="results-table-head">
<tr>
<th class="sortable result initial-sort" col="result">Result</th>
<th class="sortable" col="name">Test</th>
<th class="sortable" col="duration">Duration</th>
<th class="sortable links" col="links">Links</th></tr>
<tr hidden="true" id="not-found-message">
<th colspan="5">No results found. Try to check the filters</th>
</tr>
</thead>
<tbody class="rerun results-table-row">
<tr>
<td class="col-result">Rerun</td>
<td class="test-1 col-name">rerun.py::test_rexample_1</td>
<td class="col-duration">1.00</td>
<td class="col-links"><a class="url" href="http://www.google.com/" target="_blank">URL</a> </td></tr>
<tr>
<td class="extra" colspan="5">
<div class="log">@pytest.mark.flaky(reruns=5)<br/> def test_example():<br/> import random<br/>&gt; assert random.choice([True, False])<br/><span class="error">E assert False</span><br/><span class="error">E + where False = &lt;bound method Random.choice of &lt;random.Random object at 0x7fe80b85f420&gt;&gt;([True, False])</span><br/><span class="error">E + where &lt;bound method Random.choice of &lt;random.Random object at 0x7fe80b85f420&gt;&gt; = &lt;module 'random' from '/usr/local/Cellar/python/2.7.12/Frameworks/Python.framework/Versions/2.7/lib/python2.7/random.pyc'&gt;.choice</span><br/><br/>rerun.py:6: AssertionError<br/></div></td></tr></tbody>
<tbody class="passed results-table-row">
<tr>
<td class="col-result">Passed</td>
<td class="test-2 col-name">rerun.py::test_example_2</td>
<td class="col-duration">0.00</td>
<td class="col-links"></td></tr>
<tr>
<td class="extra" colspan="5">
<div class="empty log">No log output captured.</div>
</td>
</tr>
</tbody>
<tbody class="passed results-table-row">
<tr>
<td class="col-result">Passed</td>
<td class="test-3 col-name">rerun.py::test_example_3</td>
<td class="col-duration">0.00</td>
<td class="col-links"></td></tr>
<tr>
<td class="extra" colspan="5">
<div class="empty log">No log output captured.</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -1,134 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
if (!String.prototype.includes) {
String.prototype.includes = function() {'use strict';
return String.prototype.indexOf.apply(this, arguments) !== -1;
};
}
QUnit.module( 'module', {
beforeEach: function( assert ) {
init();
}
});
QUnit.test('sortColumn', function(assert){
function sortColumnTest(col_re, first_element_then, first_element_now) {
assert.equal(findAll('.results-table-row')[0].className, first_element_then);
var row_sort = find(col_re);
sortColumn(row_sort);
assert.equal(findAll('.results-table-row')[0].className, first_element_now);
}
//check col-name, tests should be in this order test-1 => (test-2 => test-3) on col-name
assert.equal(findAll('.col-name')[1].className, 'test-2 col-name');
//result
sortColumnTest('[col=result]',
'rerun results-table-row', 'passed results-table-row');
//make sure sorting the result column does not change the tests order in the col-name
//tests should be in this order (test-2 => test-3) => test1 on col-name
assert.equal(findAll('.col-name')[0].className, 'test-2 col-name');
sortColumnTest('[col=result]',
'passed results-table-row', 'rerun results-table-row');
//name
sortColumnTest('[col=name]',
'rerun results-table-row', 'passed results-table-row');
sortColumnTest('[col=name]',
'passed results-table-row', 'rerun results-table-row');
//duration
sortColumnTest('[col=duration]',
'rerun results-table-row', 'passed results-table-row');
sortColumnTest('[col=duration]',
'passed results-table-row', 'rerun results-table-row');
//links
sortColumnTest('[col=links]',
'rerun results-table-row', 'passed results-table-row');
sortColumnTest('[col=links]',
'passed results-table-row', 'rerun results-table-row');
});
QUnit.test('filterTable', function(assert){
function filterTableTest(outcome, checked) {
var filter_input = document.createElement('input');
filter_input.setAttribute('data-test-result', outcome);
filter_input.checked = checked;
filterTable(filter_input);
var outcomes = findAll('.' + outcome);
for(var i = 0; i < outcomes.length; i++) {
assert.equal(outcomes[i].hidden, !checked);
}
}
assert.equal(find('#not-found-message').hidden, true);
filterTableTest('rerun', false);
filterTableTest('passed', false);
assert.equal(find('#not-found-message').hidden, false);
filterTableTest('rerun', true);
assert.equal(find('#not-found-message').hidden, true);
filterTableTest('passed', true);
});
QUnit.test('showHideExtras', function(assert) {
function showExtrasTest(element){
assert.equal(element.parentNode.nextElementSibling.className, 'collapsed');
showExtras(element);
assert.notEqual(element.parentNode.nextElementSibling.className, 'collapsed');
}
function hideExtrasTest(element){
assert.notEqual(element.parentNode.nextElementSibling.className, 'collapsed');
hideExtras(element);
assert.equal(element.parentNode.nextElementSibling.className, 'collapsed');
}
//Passed results have log collapsed by default
showExtrasTest(find('.passed').firstElementChild.firstElementChild);
hideExtrasTest(find('.passed').firstElementChild.firstElementChild);
hideExtrasTest(find('.rerun').firstElementChild.firstElementChild);
showExtrasTest(find('.rerun').firstElementChild.firstElementChild);
});
QUnit.test('showHideAllExtras', function(assert) {
function showAllExtrasTest(){
showAllExtras();
var extras = findAll('.extra');
for (var i = 0; i < extras.length; i++) {
assert.notEqual(extras[i].parentNode.className, 'collapsed');
}
}
function hideAllExtrasTest(){
hideAllExtras();
var extras = findAll('.extra');
for (var i = 0; i < extras.length; i++) {
assert.equal(extras[i].parentNode.className, 'collapsed');
}
}
showAllExtrasTest();
hideAllExtrasTest();
});
QUnit.test('find', function (assert) {
assert.notEqual(find('#results-table-head'), null);
assert.notEqual(find('table#results-table'), null);
assert.equal(find('.not-in-table'), null);
});
QUnit.test('findAll', function(assert) {
assert.equal(findAll('.sortable').length, 4);
assert.equal(findAll('.not-in-table').length, 0);
});

473
testing/test_new.py Normal file
View File

@ -0,0 +1,473 @@
import base64
import importlib.resources
import json
import os
import random
import re
from base64 import b64encode
from pathlib import Path
import pkg_resources
import pytest
from assertpy import assert_that
from bs4 import BeautifulSoup
from selenium import webdriver
pytest_plugins = ("pytester",)
OUTCOMES = {
"passed": "Passed",
"skipped": "Skipped",
"failed": "Failed",
"error": "Errors",
"xfailed": "Unexpected failures",
"xpassed": "Unexpected passes",
"rerun": "Reruns",
}
def run(pytester, path="report.html", *args):
path = pytester.path.joinpath(path)
pytester.runpytest("-s", "--html", path, *args)
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument("--headless")
chrome_options.add_argument("--window-size=1920x1080")
# chrome_options.add_argument("--allow-file-access-from-files")
driver = webdriver.Remote(
command_executor="http://127.0.0.1:4444", options=chrome_options
)
try:
# Begin workaround
# See: https://github.com/pytest-dev/pytest/issues/10738
path.chmod(0o755)
for parent in path.parents:
try:
os.chmod(parent, 0o755)
except PermissionError:
continue
# End workaround
driver.get(f"file:///reports{path}")
return BeautifulSoup(driver.page_source, "html.parser")
finally:
driver.quit()
def assert_results(
page,
passed=0,
skipped=0,
failed=0,
error=0,
xfailed=0,
xpassed=0,
rerun=0,
total_tests=None,
):
args = locals()
number_of_tests = 0
for outcome, number in args.items():
if outcome == "total_tests":
continue
if isinstance(number, int):
number_of_tests += number
result = get_text(page, f"span[class={outcome}]")
assert_that(result).is_equal_to(f"{number} {OUTCOMES[outcome]}")
# if total_tests is not None:
# number_of_tests = total_tests
# total = get_text(page, "p[class='run-count']")
# expr = r"%d %s ran in \d+.\d+ seconds."
# % (number_of_tests, "tests" if number_of_tests > 1 else "test")
# assert_that(total).matches(expr)
def get_element(page, selector):
return page.select_one(selector)
def get_text(page, selector):
return get_element(page, selector).string
def get_log(page):
# TODO(jim) move to get_text (use .contents)
log = get_element(page, ".summary div[class='log']")
all_text = ""
for text in log.strings:
all_text += text
return all_text
def file_content():
try:
return (
importlib.resources.files("pytest_html")
.joinpath("resources", "style.css")
.read_bytes()
.decode("utf-8")
.strip()
)
except AttributeError:
# Needed for python < 3.9
return pkg_resources.resource_string(
"pytest_html", os.path.join("resources", "style.css")
).decode("utf-8")
class TestHTML:
@pytest.mark.parametrize(
"pause, expectation",
[
(0.4, 400),
(1, r"^((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$)"),
],
)
def test_durations(self, pytester, pause, expectation):
pytester.makepyfile(
f"""
import time
def test_sleep():
time.sleep({pause})
"""
)
page = run(pytester)
duration = get_text(page, "#results-table td[class='col-duration']")
if pause < 1:
assert_that(int(duration.replace("ms", ""))).is_between(
expectation, expectation * 2
)
else:
assert_that(duration).matches(expectation)
def test_pass(self, pytester):
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
assert_results(page, passed=1)
def test_skip(self, pytester):
reason = str(random.random())
pytester.makepyfile(
f"""
import pytest
def test_skip():
pytest.skip('{reason}')
"""
)
page = run(pytester)
assert_results(page, skipped=1, total_tests=0)
log = get_text(page, ".summary div[class='log']")
assert_that(log).contains(reason)
def test_fail(self, pytester):
pytester.makepyfile("def test_fail(): assert False")
page = run(pytester)
assert_results(page, failed=1)
assert_that(get_log(page)).contains("AssertionError")
def test_xfail(self, pytester):
reason = str(random.random())
pytester.makepyfile(
f"""
import pytest
def test_xfail():
pytest.xfail('{reason}')
"""
)
page = run(pytester)
assert_results(page, xfailed=1)
assert_that(get_log(page)).contains(reason)
def test_xpass(self, pytester):
pytester.makepyfile(
"""
import pytest
@pytest.mark.xfail()
def test_xpass():
pass
"""
)
page = run(pytester)
assert_results(page, xpassed=1)
def test_rerun(self, pytester):
pytester.makepyfile(
"""
import pytest
import time
@pytest.mark.flaky(reruns=2)
def test_example():
time.sleep(0.2)
assert False
"""
)
page = run(pytester)
assert_results(page, failed=1, rerun=2, total_tests=1)
def test_conditional_xfails(self, pytester):
pytester.makepyfile(
"""
import pytest
@pytest.mark.xfail(False, reason='reason')
def test_fail(): assert False
@pytest.mark.xfail(False, reason='reason')
def test_pass(): pass
@pytest.mark.xfail(True, reason='reason')
def test_xfail(): assert False
@pytest.mark.xfail(True, reason='reason')
def test_xpass(): pass
"""
)
page = run(pytester)
assert_results(page, passed=1, failed=1, xfailed=1, xpassed=1)
def test_setup_error(self, pytester):
pytester.makepyfile(
"""
import pytest
@pytest.fixture
def arg(request):
raise ValueError()
def test_function(arg):
pass
"""
)
page = run(pytester)
assert_results(page, error=1, total_tests=0)
col_name = get_text(page, ".summary td[class='col-name']")
assert_that(col_name).contains("::setup")
assert_that(get_log(page)).contains("ValueError")
@pytest.mark.parametrize("title", ["", "Special Report"])
def test_report_title(self, pytester, title):
pytester.makepyfile("def test_pass(): pass")
if title:
pytester.makeconftest(
f"""
import pytest
def pytest_html_report_title(report):
report.title = "{title}"
"""
)
expected_title = title if title else "report.html"
page = run(pytester)
assert_that(get_text(page, "#head-title")).is_equal_to(expected_title)
assert_that(get_text(page, "h1[id='title']")).is_equal_to(expected_title)
def test_resources_inline_css(self, pytester):
pytester.makepyfile("def test_pass(): pass")
page = run(pytester, "report.html", "--self-contained-html")
content = file_content()
assert_that(get_text(page, "head style").strip()).contains(content)
def test_resources_css(self, pytester):
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
assert_that(page.select_one("head link")["href"]).is_equal_to(
str(Path("assets", "style.css"))
)
def test_custom_content_in_summary(self, pytester):
content = {
"prefix": str(random.random()),
"summary": str(random.random()),
"postfix": str(random.random()),
}
pytester.makeconftest(
f"""
import pytest
def pytest_html_results_summary(prefix, summary, postfix):
prefix.append(r"<p>prefix is {content['prefix']}</p>")
summary.extend([r"<p>summary is {content['summary']}</p>"])
postfix.extend([r"<p>postfix is {content['postfix']}</p>"])
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
elements = page.select(".summary__data p:not(.run-count):not(.filter)")
assert_that(elements).is_length(3)
for element in elements:
key = re.search(r"(\w+).*", element.string).group(1)
value = content.pop(key)
assert_that(element.string).contains(value)
def test_extra_html(self, pytester):
content = str(random.random())
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.html('<div>{content}</div>')]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
assert_that(page.select_one(".summary .extraHTML").string).is_equal_to(content)
@pytest.mark.parametrize(
"content, encoded",
[("u'\u0081'", "woE="), ("'foo'", "Zm9v"), ("b'\\xe2\\x80\\x93'", "4oCT")],
)
def test_extra_text(self, pytester, content, encoded):
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.text({content})]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester, "report.html", "--self-contained-html")
element = page.select_one(".summary a[class='col-links__extra text']")
assert_that(element.string).is_equal_to("Text")
assert_that(element["href"]).is_equal_to(
f"data:text/plain;charset=utf-8;base64,{encoded}"
)
def test_extra_json(self, pytester):
content = {str(random.random()): str(random.random())}
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.json({content})]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester, "report.html", "--self-contained-html")
content_str = json.dumps(content)
data = b64encode(content_str.encode("utf-8")).decode("ascii")
element = page.select_one(".summary a[class='col-links__extra json']")
assert_that(element.string).is_equal_to("JSON")
assert_that(element["href"]).is_equal_to(
f"data:application/json;charset=utf-8;base64,{data}"
)
def test_extra_url(self, pytester):
content = str(random.random())
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.url('{content}')]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
element = page.select_one(".summary a[class='col-links__extra url']")
assert_that(element.string).is_equal_to("URL")
assert_that(element["href"]).is_equal_to(content)
@pytest.mark.parametrize(
"mime_type, extension",
[
("image/png", "png"),
("image/png", "image"),
("image/jpeg", "jpg"),
("image/svg+xml", "svg"),
],
)
def test_extra_image(self, pytester, mime_type, extension):
content = str(random.random())
charset = "utf-8"
data = base64.b64encode(content.encode(charset)).decode(charset)
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.{extension}('{data}')]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester, "report.html", "--self-contained-html")
# element = page.select_one(".summary a[class='col-links__extra image']")
src = f"data:{mime_type};base64,{data}"
# assert_that(element.string).is_equal_to("Image")
# assert_that(element["href"]).is_equal_to(src)
element = page.select_one(".summary .media img")
assert_that(str(element)).is_equal_to(f'<img src="{src}"/>')
@pytest.mark.parametrize("mime_type, extension", [("video/mp4", "mp4")])
def test_extra_video(self, pytester, mime_type, extension):
content = str(random.random())
charset = "utf-8"
data = base64.b64encode(content.encode(charset)).decode(charset)
pytester.makeconftest(
f"""
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call':
from pytest_html import extras
report.extras = [extras.{extension}('{data}')]
"""
)
pytester.makepyfile("def test_pass(): pass")
page = run(pytester, "report.html", "--self-contained-html")
# element = page.select_one(".summary a[class='col-links__extra video']")
src = f"data:{mime_type};base64,{data}"
# assert_that(element.string).is_equal_to("Video")
# assert_that(element["href"]).is_equal_to(src)
element = page.select_one(".summary .media video")
assert_that(str(element)).is_equal_to(
f'<video controls="">\n<source src="{src}" type="{mime_type}"/>\n</video>'
)

158
testing/unittest.js Normal file
View File

@ -0,0 +1,158 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { doInitFilter, doFilter } = require('../src/pytest_html/scripts/filter.js')
const { doInitSort, doSort } = require('../src/pytest_html/scripts/sort.js')
const { formatDuration } = require('../src/pytest_html/scripts/utils.js')
const dataModule = require('../src/pytest_html/scripts/datamanager.js')
const storageModule = require('../src/pytest_html/scripts/storage.js')
const setTestData = () => {
const jsonDatan = {
'tests':
[
{
'id': 'passed_1',
'outcome': 'passed',
},
{
'id': 'failed_2',
'outcome': 'failed',
},
{
'id': 'passed_3',
'outcome': 'passed',
},
{
'id': 'passed_4',
'outcome': 'passed',
},
{
'id': 'passed_5',
'outcome': 'passed',
},
{
'id': 'passed_6',
'outcome': 'passed',
},
],
}
dataModule.manager.setManager(jsonDatan)
}
describe('Filter tests', () => {
let getFilterMock
let managerSpy
beforeEach(setTestData)
afterEach(() => [getFilterMock, managerSpy].forEach((fn) => fn.restore()))
after(() => dataModule.manager.setManager({ tests: [] }))
describe('doInitFilter', () => {
it('has no stored filters', () => {
getFilterMock = sinon.stub(storageModule, 'getVisible').returns([])
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doInitFilter()
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql([])
})
it('exclude passed', () => {
getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['failed'])
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doInitFilter()
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql(['failed'])
})
})
describe('doFilter', () => {
let setFilterMock
afterEach(() => setFilterMock.restore())
it('removes all but passed', () => {
getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['passed'])
setFilterMock = sinon.stub(storageModule, 'setFilter')
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doFilter('passed', true)
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql([
'passed', 'passed', 'passed', 'passed', 'passed',
])
})
})
})
describe('Sort tests', () => {
beforeEach(setTestData)
after(() => dataModule.manager.setManager({ tests: [] }))
describe('doInitSort', () => {
let managerSpy
let sortMock
let sortDirectionMock
beforeEach(() => dataModule.manager.resetRender())
afterEach(() => [sortMock, sortDirectionMock, managerSpy].forEach((fn) => fn.restore()))
it('has no stored sort', () => {
sortMock = sinon.stub(storageModule, 'getSort').returns(null)
sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null)
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doInitSort()
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql([
'passed', 'failed', 'passed', 'passed', 'passed', 'passed',
])
})
it('has stored sort preference', () => {
sortMock = sinon.stub(storageModule, 'getSort').returns('outcome')
sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(false)
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doInitSort()
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql([
'failed', 'passed', 'passed', 'passed', 'passed', 'passed',
])
})
})
describe('doSort', () => {
let getSortMock
let setSortMock
let getSortDirectionMock
let setSortDirection
let managerSpy
afterEach(() => [
getSortMock, setSortMock, getSortDirectionMock, setSortDirection, managerSpy,
].forEach((fn) => fn.restore()))
it('sort on outcome', () => {
getSortMock = sinon.stub(storageModule, 'getSort').returns(null)
setSortMock = sinon.stub(storageModule, 'setSort')
getSortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null)
setSortDirection = sinon.stub(storageModule, 'setSortDirection')
managerSpy = sinon.spy(dataModule.manager, 'setRender')
doSort('outcome')
expect(managerSpy.callCount).to.eql(1)
expect(dataModule.manager.testSubset.map(({ outcome }) => outcome)).to.eql([
'passed', 'passed', 'passed', 'passed', 'passed', 'failed',
])
})
})
})
describe('utils tests', () => {
describe('formatDuration', () => {
it('handles small durations', () => {
expect(formatDuration(0.123).ms).to.eql('123 ms')
expect(formatDuration(0).ms).to.eql('0 ms')
expect(formatDuration(0.999).ms).to.eql('999 ms')
})
it('handles larger durations', () => {
expect(formatDuration(1.234).formatted).to.eql('00:00:01')
expect(formatDuration(12345.678).formatted).to.eql('03:25:46')
})
})
})

45
tox.ini
View File

@ -8,16 +8,20 @@ envlist = py{37,38,39,310,py3}, docs, linting
isolated_build = True
[testenv]
setenv = PYTHONDONTWRITEBYTECODE=1
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
assertpy
beautifulsoup4
pytest-xdist
pytest-rerunfailures
pytest-mock
selenium
ansi2html # soft-dependency
cov: pytest-cov
commands =
!cov: pytest -v -r a --color=yes --html={envlogdir}/report.html --self-contained-html {posargs}
cov: pytest -v -r a --color=yes --html={envlogdir}/report.html --self-contained-html --cov={envsitepackagesdir}/pytest_html --cov-report=term --cov-report=xml {posargs}
!cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html {posargs}
cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html --cov={envsitepackagesdir}/pytest_html --cov-report=term --cov-report=xml {posargs}
[testenv:linting]
skip_install = True
@ -35,12 +39,6 @@ deps =
pytest-rerunfailures @ git+https://github.com/pytest-dev/pytest-rerunfailures.git
pytest @ git+https://github.com/pytest-dev/pytest.git
[testenv:devel-cov]
description = Tests with unreleased deps and coverage
basepython = {[testenv:devel]basepython}
pip_pre = {[testenv:devel]pip_pre}
deps = {[testenv:devel]deps}
[testenv:docs]
# NOTE: The command for doc building was taken from readthedocs documentation
# See https://docs.readthedocs.io/en/stable/builds.html#understanding-what-s-going-on
@ -49,35 +47,6 @@ changedir = docs
deps = sphinx
commands = sphinx-build -b html . _build/html
[testenv:packaging]
description =
Do packaging/distribution. If tag is not present or PEP440 compliant upload to
PYPI could fail
# `usedevelop = true` overrides `skip_install` instruction, it's unwanted
usedevelop = false
# don't install package in this env
skip_install = true
deps =
collective.checkdocs >= 0.2
pep517 >= 0.8.2
pip >= 20.2.2
toml >= 0.10.1
twine >= 3.2.0
setenv =
commands =
rm -rfv {toxinidir}/dist/
python -m pep517.build \
--source \
--binary \
--out-dir {toxinidir}/dist/ \
{toxinidir}
# metadata validation
python setup.py check
sh -c "python -m twine check {toxinidir}/dist/*"
whitelist_externals =
rm
sh
[flake8]
max-line-length = 88
exclude = .eggs,.tox