├── .coveragerc ├── .eslintrc.json ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── cron.yml │ ├── publish.yml │ ├── release-drafter.yml │ └── tests.yml ├── .gitignore ├── .nycrc ├── .pre-commit-config.yaml ├── .prettierrc ├── .readthedocs.yaml ├── LICENSE ├── README.rst ├── codecov.yml ├── docker-compose.tmpl.yml ├── docs ├── Makefile ├── api_reference.rst ├── changelog.rst ├── conf.py ├── deprecations.rst ├── development.rst ├── index.rst ├── installing.rst ├── make.bat ├── requirements.in ├── requirements.txt └── user_guide.rst ├── package-lock.json ├── package.json ├── pyproject.toml ├── scripts └── npm.py ├── src ├── .gitattributes ├── .gitignore ├── layout │ └── css │ │ └── style.scss └── pytest_html │ ├── __init__.py │ ├── basereport.py │ ├── extras.py │ ├── fixtures.py │ ├── hooks.py │ ├── plugin.py │ ├── report.py │ ├── report_data.py │ ├── resources │ ├── index.jinja2 │ └── style.css │ ├── scripts │ ├── datamanager.js │ ├── dom.js │ ├── filter.js │ ├── index.js │ ├── main.js │ ├── mediaviewer.js │ ├── sort.js │ └── storage.js │ ├── selfcontained_report.py │ └── util.py ├── start ├── testing ├── legacy_test_pytest_html.py ├── test_e2e.py ├── test_integration.py ├── test_unit.py └── unittest.js └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # reference: https://coverage.readthedocs.io/en/coverage-5.0/config.html 2 | # used by pytest-cov 3 | [coverage:report] 4 | precision = 2 5 | show_missing = true 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "google" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | }, 13 | "rules": { 14 | "array-bracket-spacing": "error", 15 | "block-scoped-var": "error", 16 | "block-spacing": "error", 17 | "brace-style": "error", 18 | "camelcase": "off", 19 | "class-methods-use-this": "error", 20 | "consistent-return": "error", 21 | "default-case": "error", 22 | "default-case-last": "error", 23 | "default-param-last": "error", 24 | "grouped-accessor-pairs": "error", 25 | "indent": [ "error", 4 ], 26 | "linebreak-style": [ "error", "unix" ], 27 | "max-len": ["error", { "code": 120 }], 28 | "no-caller": "error", 29 | "no-console": "error", 30 | "no-empty-function": "error", 31 | "no-eval": "error", 32 | "no-extra-parens": "error", 33 | "no-labels": "error", 34 | "no-new": "error", 35 | "no-new-func": "error", 36 | "no-new-wrappers": "error", 37 | "no-return-await": "error", 38 | "no-script-url": "error", 39 | "no-self-compare": "error", 40 | "no-shadow": "error", 41 | "no-throw-literal": "error", 42 | "no-undefined": "error", 43 | "no-unreachable-loop": "error", 44 | "no-unused-expressions": "off", 45 | "no-useless-backreference": "error", 46 | "no-useless-concat": "error", 47 | "no-var": "error", 48 | "object-curly-spacing": [ 49 | "error", 50 | "always", 51 | { 52 | "arraysInObjects": true 53 | } 54 | ], 55 | "prefer-const": "error", 56 | "prefer-promise-reject-errors": "error", 57 | "require-atomic-updates": "error", 58 | "require-await": "error", 59 | "require-jsdoc" : 0, 60 | "semi": [ 61 | "error", 62 | "never" 63 | ], 64 | "quotes": [ 65 | "error", 66 | "single" 67 | ], 68 | "yoda": "error" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | # Look for `package.json` and `lock` files in the `root` directory 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: 'Major Changes' 3 | labels: 4 | - 'major' # c6476b 5 | - title: 'Minor Changes' 6 | labels: 7 | - 'feature' # 006b75 8 | - 'enhancement' # ededed 9 | - 'performance' # 555555 10 | - 'docs' # 4071a5 11 | - title: 'Bugfixes' 12 | labels: 13 | - 'fix' 14 | - 'bugfix' 15 | - 'bug' # fbca04 16 | - 'packaging' # 4071a5 17 | - 'test' # 0e8a16 18 | - title: 'Deprecations' 19 | labels: 20 | - 'deprecated' # fef2c0 21 | exclude-labels: 22 | - 'skip-changelog' 23 | template: | 24 | ## Changes 25 | 26 | $CHANGES 27 | -------------------------------------------------------------------------------- /.github/workflows/cron.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled Tests 2 | 3 | on: 4 | schedule: 5 | - cron: '0 14 * * *' # Run daily at 14:00 UTC 6 | 7 | jobs: 8 | tests: 9 | if: github.repository_owner == 'pytest-dev' 10 | uses: ./.github/workflows/tests.yml 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 8 | 9 | jobs: 10 | publish: 11 | if: github.repository == 'pytest-dev/pytest-html' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | persist-credentials: false 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '16.x' 23 | 24 | - name: Build and Check Package 25 | uses: hynek/build-and-inspect-python-package@v2 26 | 27 | - name: Download Package 28 | uses: actions/download-artifact@v4 29 | with: 30 | name: Packages 31 | path: dist 32 | 33 | - name: Publish package to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | password: ${{ secrets.pypi_password }} 37 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | - 'releases/**' 9 | - 'stable/**' 10 | 11 | jobs: 12 | update_release_draft: 13 | runs-on: ubuntu-latest 14 | steps: 15 | # Drafts your next Release notes as Pull Requests are merged into "master" 16 | - uses: release-drafter/release-drafter@v6 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | workflow_call: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: ${{ ! contains(github.ref, github.event.repository.default_branch) }} 14 | 15 | jobs: 16 | build_docs: 17 | name: Build Docs 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.x' 26 | 27 | - name: Ensure latest pip 28 | run: python -m pip install --upgrade pip 29 | 30 | - name: Install tox 31 | run: python -m pip install --upgrade tox 32 | 33 | - name: Build docs with tox 34 | run: tox -e docs 35 | 36 | build_package: 37 | name: Build Package 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | persist-credentials: false 44 | 45 | - name: Use Node.js 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: '16.x' 49 | 50 | - name: Build and Check Package 51 | uses: hynek/build-and-inspect-python-package@v2 52 | 53 | test_javascript: 54 | name: Run javascript unit tests 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Use Node.js 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: '16.x' 62 | - name: Install dependencies 63 | run: npm ci 64 | - name: Run linting 65 | run: npm run lint 66 | - name: Run tests 67 | run: npm run unit 68 | - name: Upload coverage to codecov 69 | if: >- 70 | ${{ 71 | ! github.event.schedule && 72 | github.repository_owner == 'pytest-dev' 73 | }} 74 | uses: codecov/codecov-action@v5 75 | with: 76 | token: ${{ secrets.CODECOV_TOKEN }} 77 | fail_ci_if_error: false 78 | files: ./cobertura-coverage.xml 79 | flags: js_tests 80 | name: ubuntu-latest-node-16 81 | verbose: true 82 | 83 | test_unit: 84 | name: ${{ matrix.os }} - ${{ matrix.python-version }} - unit 85 | runs-on: ${{ matrix.os }} 86 | strategy: 87 | fail-fast: false 88 | matrix: 89 | os: [ubuntu-latest, windows-latest, macos-latest] 90 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9"] 91 | include: 92 | - os: ubuntu-latest 93 | python-version: "3.10" 94 | with-coverage: true 95 | 96 | - os: ubuntu-latest 97 | python-version: 3.13 98 | tox-env: devel 99 | - os: windows-latest 100 | python-version: 3.13 101 | tox-env: devel 102 | - os: macos-latest 103 | python-version: 3.13 104 | tox-env: devel 105 | 106 | steps: 107 | - name: Set newline behavior 108 | run: git config --global core.autocrlf false 109 | 110 | - uses: actions/checkout@v4 111 | 112 | - name: Use Node.js 113 | uses: actions/setup-node@v4 114 | with: 115 | node-version: '16.x' 116 | 117 | - name: Set up python 118 | uses: actions/setup-python@v5 119 | with: 120 | python-version: ${{ matrix.python-version }} 121 | 122 | - name: Ensure latest pip 123 | run: python -m pip install --upgrade pip 124 | 125 | - name: Install tox 126 | run: python -m pip install --upgrade tox 127 | 128 | - name: Cache tox virtual environment 129 | uses: actions/cache@v4 130 | with: 131 | path: .tox 132 | key: ${{ matrix.os }}-tox-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'tox.ini') }} 133 | restore-keys: | 134 | ${{ matrix.os }}-tox-${{ matrix.python-version }}- 135 | 136 | - name: Run unit tests with coverage 137 | if: ${{ matrix.with-coverage }} 138 | run: tox -e ${{ matrix.python-version }}-cov -- testing/test_unit.py 139 | 140 | - name: Run unit tests without coverage 141 | if: ${{ ! matrix.with-coverage }} 142 | run: tox -e ${{ matrix.tox-env || matrix.python-version }} -- testing/test_unit.py 143 | 144 | - name: Upload coverage to codecov 145 | if: >- 146 | ${{ 147 | ! github.event.schedule && 148 | matrix.with-coverage && 149 | github.repository_owner == 'pytest-dev' 150 | }} 151 | uses: codecov/codecov-action@v5 152 | with: 153 | token: ${{ secrets.CODECOV_TOKEN }} 154 | fail_ci_if_error: false 155 | files: ./coverage.xml 156 | flags: py_unit_tests 157 | name: ${{ matrix.os }}-python-${{ matrix.python-version }} 158 | verbose: true 159 | 160 | test_integration: 161 | name: ubuntu - ${{ matrix.python-version }} - integration 162 | runs-on: ubuntu-latest 163 | strategy: 164 | fail-fast: false 165 | matrix: 166 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9"] 167 | include: 168 | - python-version: "3.10" 169 | with-coverage: true 170 | - python-version: 3.13 171 | tox-env: devel 172 | 173 | steps: 174 | - name: Set newline behavior 175 | run: git config --global core.autocrlf false 176 | 177 | - uses: actions/checkout@v4 178 | 179 | - name: Start chrome 180 | run: ./start 181 | 182 | - name: Use Node.js 183 | uses: actions/setup-node@v4 184 | with: 185 | node-version: '16.x' 186 | 187 | - name: Set up python 188 | uses: actions/setup-python@v5 189 | with: 190 | python-version: ${{ matrix.python-version }} 191 | 192 | - name: Ensure latest pip 193 | run: python -m pip install --upgrade pip 194 | 195 | - name: Install tox 196 | run: python -m pip install --upgrade tox 197 | 198 | - name: Cache tox virtual environment 199 | uses: actions/cache@v4 200 | with: 201 | path: .tox 202 | key: ubuntu-latest-tox-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'tox.ini') }} 203 | restore-keys: | 204 | ubuntu-latest-tox-${{ matrix.python-version }}- 205 | 206 | - name: Run integration tests with coverage 207 | if: ${{ matrix.with-coverage }} 208 | run: tox -e ${{ matrix.python-version }}-cov -- testing/test_integration.py 209 | 210 | - name: Run integration tests without coverage 211 | if: ${{ ! matrix.with-coverage }} 212 | run: tox -e ${{ matrix.tox-env || matrix.python-version }} -- testing/test_integration.py 213 | 214 | - name: Upload coverage to codecov 215 | if: >- 216 | ${{ 217 | ! github.event.schedule && 218 | matrix.with-coverage && 219 | github.repository_owner == 'pytest-dev' 220 | }} 221 | uses: codecov/codecov-action@v5 222 | with: 223 | token: ${{ secrets.CODECOV_TOKEN }} 224 | fail_ci_if_error: false 225 | files: ./coverage.xml 226 | flags: py_integration_tests 227 | name: ubuntu-latest-${{ matrix.python-version }} 228 | verbose: true 229 | 230 | test_e2e: 231 | name: ubuntu - ${{ matrix.python-version }} - e2e 232 | runs-on: ubuntu-latest 233 | strategy: 234 | fail-fast: false 235 | matrix: 236 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9"] 237 | include: 238 | - python-version: 3.13 239 | tox-env: devel 240 | 241 | steps: 242 | - name: Set newline behavior 243 | run: git config --global core.autocrlf false 244 | 245 | - uses: actions/checkout@v4 246 | 247 | - name: Start chrome 248 | run: ./start 249 | 250 | - name: Use Node.js 251 | uses: actions/setup-node@v4 252 | with: 253 | node-version: '16.x' 254 | 255 | - name: Set up python 256 | uses: actions/setup-python@v5 257 | with: 258 | python-version: ${{ matrix.python-version }} 259 | 260 | - name: Ensure latest pip 261 | run: python -m pip install --upgrade pip 262 | 263 | - name: Install tox 264 | run: python -m pip install --upgrade tox 265 | 266 | - name: Cache tox virtual environment 267 | uses: actions/cache@v4 268 | with: 269 | path: .tox 270 | key: ubuntu-latest-tox-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'tox.ini') }} 271 | restore-keys: | 272 | ubuntu-latest-tox-${{ matrix.python-version }}- 273 | 274 | - name: Run e2e tests 275 | run: tox -e ${{ matrix.tox-env || matrix.python-version }} -- testing/test_e2e.py 276 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .cache 3 | .eggs 4 | *.egg-info 5 | *.pyc 6 | build 7 | dist 8 | 9 | # Codecov 10 | .coverage 11 | coverage.xml 12 | cobertura-coverage.xml 13 | 14 | # IDE Specific files/folders 15 | ## Pycharm IDE - Jetbrains 16 | .idea/* 17 | .vscode/* 18 | 19 | ## PyDev IDE - Eclipse 20 | .settings/ 21 | .loadpath 22 | .metadata 23 | tmp/ 24 | *.bak 25 | *.project 26 | *.pydevproject 27 | *.tmp 28 | local.properties 29 | 30 | ## Vim 31 | *.swp 32 | 33 | ### YouCompleteMe 34 | .ycm_extra_conf.py 35 | 36 | # JS files/folders 37 | ## node / npm 38 | node_modules/ 39 | 40 | # MacOS files 41 | .DS_Store 42 | 43 | # Pipenv files 44 | Pipfile.lock 45 | 46 | # pytest folders 47 | .pytest_cache 48 | 49 | # tox folders 50 | .tox 51 | 52 | # sphinx/read the docs 53 | docs/_build/ 54 | 55 | *.html 56 | assets/ 57 | 58 | .nyc_output 59 | 60 | .venv/ 61 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "report-dir": ".", 3 | "reporter": ["cobertura"], 4 | "exclude": ["testing/**"] 5 | } 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | args: [--safe, --quiet, --line-length=88] 7 | 8 | - repo: https://github.com/tox-dev/pyproject-fmt 9 | rev: "v2.6.0" 10 | hooks: 11 | - id: pyproject-fmt 12 | # https://pyproject-fmt.readthedocs.io/en/latest/#calculating-max-supported-python-version 13 | additional_dependencies: ["tox>=4.9"] 14 | 15 | - repo: https://github.com/asottile/blacken-docs 16 | rev: 1.19.1 17 | hooks: 18 | - id: blacken-docs 19 | additional_dependencies: [black==24.10.0] 20 | 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v5.0.0 23 | hooks: 24 | - id: trailing-whitespace 25 | - id: end-of-file-fixer 26 | - id: fix-encoding-pragma 27 | args: [--remove] 28 | - id: check-yaml 29 | - id: debug-statements 30 | language_version: python3 31 | - id: no-commit-to-branch 32 | args: ['--branch', 'master'] 33 | 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.2.0 36 | hooks: 37 | - id: flake8 38 | language_version: python3 39 | additional_dependencies: 40 | - flake8-builtins==1.5.3 41 | - flake8-typing-imports==1.12.0 42 | 43 | - repo: https://github.com/asottile/reorder-python-imports 44 | rev: v3.15.0 45 | hooks: 46 | - id: reorder-python-imports 47 | args: ["--application-directories=.:src:testing", --py3-plus] 48 | 49 | - repo: https://github.com/asottile/pyupgrade 50 | rev: v3.20.0 51 | hooks: 52 | - id: pyupgrade 53 | args: [--py39-plus] 54 | 55 | - repo: https://github.com/pre-commit/mirrors-eslint 56 | rev: v9.28.0 57 | hooks: 58 | - id: eslint 59 | additional_dependencies: 60 | - eslint@8.20.0 61 | - eslint-config-google@0.14.0 62 | args: ["--fix"] 63 | - repo: https://github.com/pre-commit/mirrors-mypy 64 | rev: v1.16.0 65 | hooks: 66 | - id: mypy 67 | files: ^(src/pytest_html|testing) 68 | additional_dependencies: 69 | - types-setuptools 70 | - repo: local 71 | hooks: 72 | - id: rst 73 | name: rst 74 | entry: rst-lint --encoding utf-8 75 | files: ^(README.rst)$ 76 | language: python 77 | additional_dependencies: [pygments, restructuredtext_lint] 78 | 79 | - repo: local 80 | hooks: 81 | - id: djlint 82 | name: djlint 83 | entry: djlint 84 | files: \.jinja2$ 85 | language: python 86 | additional_dependencies: [djlint] 87 | 88 | - repo: https://github.com/elidupuis/mirrors-sass-lint 89 | rev: "5cc45653263b423398e4af2561fae362903dd45d" 90 | hooks: 91 | - id: sass-lint 92 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | # Build from the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | fail_on_warning: true 12 | 13 | # Explicitly set the version of Python and its requirements 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This Source Code Form is subject to the terms of the Mozilla Public 2 | License, v. 2.0. If a copy of the MPL was not distributed with this 3 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest-html 2 | =========== 3 | 4 | pytest-html is a plugin for `pytest `_ that generates a HTML report for test results. 5 | 6 | .. image:: https://img.shields.io/badge/license-MPL%202.0-blue.svg 7 | :target: https://github.com/pytest-dev/pytest-html/blob/master/LICENSE 8 | :alt: License 9 | .. image:: https://img.shields.io/pypi/v/pytest-html.svg 10 | :target: https://pypi.python.org/pypi/pytest-html/ 11 | :alt: PyPI 12 | .. image:: https://img.shields.io/conda/vn/conda-forge/pytest-html.svg 13 | :target: https://anaconda.org/conda-forge/pytest-html 14 | :alt: Conda Forge 15 | .. image:: https://github.com/pytest-dev/pytest-html/workflows/gh/badge.svg 16 | :target: https://github.com/pytest-dev/pytest-html/actions 17 | :alt: CI 18 | .. image:: https://img.shields.io/requires/github/pytest-dev/pytest-html.svg 19 | :target: https://requires.io/github/pytest-dev/pytest-html/requirements/?branch=master 20 | :alt: Requirements 21 | .. image:: https://codecov.io/gh/pytest-dev/pytest-html/branch/master/graph/badge.svg?token=Y0myNKkdbi 22 | :target: https://codecov.io/gh/pytest-dev/pytest-html 23 | :alt: Codecov 24 | 25 | Resources 26 | --------- 27 | 28 | - `Documentation `_ 29 | - `Release Notes `_ 30 | - `Issue Tracker `_ 31 | - `Code `_ 32 | 33 | Contributing 34 | ------------ 35 | 36 | We welcome contributions. 37 | 38 | To learn more, see `Development `_ 39 | 40 | Screenshots 41 | ----------- 42 | 43 | .. image:: https://cloud.githubusercontent.com/assets/122800/11952194/62daa964-a88e-11e5-9745-2aa5b714c8bb.png 44 | :target: https://cloud.githubusercontent.com/assets/122800/11951695/f371b926-a88a-11e5-91c2-499166776bd3.png 45 | :alt: Enhanced HTML report 46 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # reference: https://docs.codecov.io/docs/codecovyml-reference 2 | # used by codecov site 3 | comment: false 4 | -------------------------------------------------------------------------------- /docker-compose.tmpl.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | chrome: 5 | image: seleniarm/standalone-chromium:110.0 6 | container_name: chrome 7 | shm_size: '2gb' 8 | ports: 9 | - "4444:4444" 10 | - "7900:7900" 11 | volumes: 12 | - "%%VOLUME%%:Z" 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ------------- 3 | 4 | This is a reference to the plugin API. 5 | 6 | Hooks 7 | ~~~~~ 8 | 9 | This plugin exposes the following hooks: 10 | 11 | .. automodule:: pytest_html.hooks 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Versions follow `Semantic Versioning`_ (``..``). 5 | 6 | Version History 7 | --------------- 8 | 9 | 4.1.1 (2023-11-07) 10 | ~~~~~~~~~~~~~~~~~~ 11 | 12 | * Fix original initial sort INI-setting. 13 | 14 | * Thanks to `@sturmf `_ for reporting. 15 | 16 | 4.1.0 (2023-11-04) 17 | ~~~~~~~~~~~~~~~~~~ 18 | 19 | * Fix typo ("ge" instead of "get") in green Reload button in report file. 20 | 21 | * Fix results table modification documentation. 22 | 23 | * Thanks to `@michalkaptur `_ for the reporting and PR. 24 | 25 | * Fix broken duration. 26 | 27 | * Revert report generation to full run. 28 | 29 | * Add collections errors to report. 30 | 31 | * Fix escaping HTML in the log. 32 | 33 | 4.0.2 (2023-09-12) 34 | ~~~~~~~~~~~~~~~~~~ 35 | 36 | * Use absolute path to the report file. 37 | 38 | * Thanks to `@adrien-berchet `_ for reporting and for the PR. 39 | 40 | 4.0.1 (2023-09-10) 41 | ~~~~~~~~~~~~~~~~~~ 42 | 43 | * Fix incorrectly labeled outcome. 44 | 45 | * Thanks to `@lodagro `_ for reporting 46 | 47 | 4.0.0 (2023-09-01) 48 | ~~~~~~~~~~~~~~~~~~ 49 | 50 | This release is the result of more than two years of rewrites. 51 | 52 | We've tried our best to keep this release backwards-compatible with v3. 53 | 54 | If you find something that seems to be a regression, please consult the documentation first, 55 | before filing an issue. 56 | 57 | Thanks to all the users who have contributed with ideas, solutions and beta-testing. 58 | You're too many to name, but you know who you are. 59 | 60 | A special thanks to `@drRedflint `_ and `@jeffwright13 `_ 61 | for all the javascript and testing respectively. 62 | 63 | 3.2.0 (2022-10-25) 64 | ~~~~~~~~~~~~~~~~~~ 65 | 66 | * Explicitly add py.xml dependency. 67 | 68 | * Thanks to `@smartEBL `_ for the PR 69 | 70 | * Implement the ``visible`` URL query parameter to control visibility of test results on page load. (`#399 `_) 71 | 72 | * Thanks to `@TheCorp `_ for reporting and `@gnikonorov `_ for the fix 73 | 74 | * Make the report tab title reflect the report name. (`#412 `_) 75 | 76 | * Thanks to `@gnikonorov `_ for the PR 77 | 78 | * Implement :code:`environment_table_redact_list` to allow for redaction of environment table values. (`#233 `_) 79 | 80 | * Thanks to `@fenchu `_ for reporting and `@gnikonorov `_ for the PR 81 | 82 | 3.1.1 (2020-12-13) 83 | ~~~~~~~~~~~~~~~~~~ 84 | 85 | * Fix issue with reporting of missing CSS files. (`#388 `_) 86 | 87 | * Thanks to `@prakhargurunani `_ for reporting and fixing! 88 | 89 | 3.1.0 (2020-12-2) 90 | ~~~~~~~~~~~~~~~~~ 91 | 92 | * Stop attaching test reruns to final test report entries (`#374 `_) 93 | 94 | * Thanks to `@VladimirPodolyan `_ for reporting and `@gnikonorov `_ for the fix 95 | 96 | * Allow for report duration formatting (`#376 `_) 97 | 98 | * Thanks to `@brettnolan `_ for reporting and `@gnikonorov `_ for the fix 99 | 100 | 3.0.0 (2020-10-28) 101 | ~~~~~~~~~~~~~~~~~~ 102 | 103 | * Respect ``--capture=no``, ``--show-capture=no``, and ``-s`` pytest flags (`#171 `_) 104 | 105 | * Thanks to `@bigunyak `_ for reporting and `@gnikonorov `_ for the fix 106 | 107 | * Make the ``Results`` table ``Links`` column sortable (`#242 `_) 108 | 109 | * Thanks to `@vashirov `_ for reporting and `@gnikonorov `_ for the fix 110 | 111 | * Fix issue with missing image or video in extras. (`#265 `_ and `pytest-selenium#237 `_) 112 | 113 | * Thanks to `@p00j4 `_ and `@anothermattbrown `_ for reporting and `@christiansandberg `_ and `@superdodd `_ and `@dhalperi `_ for the fix 114 | 115 | * Fix attribute name for compatibility with ``pytest-xdist`` 2. (`#305 `_) 116 | 117 | * Thanks to `@Zac-HD `_ for the fix 118 | 119 | * Post process HTML generation to allow teardown to appear in the HTML output. (`#131 `_) 120 | 121 | * Thanks to `@iwanb `_ for reporting and `@csm10495 `_ for the fix 122 | 123 | 2.1.1 (2020-03-18) 124 | ~~~~~~~~~~~~~~~~~~ 125 | 126 | * Fix issue with funcargs causing failures. (`#282 `_) 127 | 128 | * Thanks to `@ssbarnea `_ for reporting and `@christiansandberg `_ for the fix 129 | 130 | 2.1.0 (2020-03-09) 131 | ~~~~~~~~~~~~~~~~~~ 132 | 133 | * Added support for MP4 video format. (`#260 `_) 134 | 135 | * Thanks to `@ExaltedBagel `_ for the PR 136 | 137 | * Added support for sorting metadata by key. (`#245 `_) 138 | 139 | * Thanks to `@ssbarnea `_ for reporting and `@ExaltedBagel `_ for the fix 140 | 141 | * Added support for rendering reports collapsed (`#239 `_) 142 | 143 | * Thanks to `@Wramberg `_ for suggesting this enhancement 144 | 145 | * Added `extra` fixture (`#269 `_) 146 | 147 | * Thanks to `@christiansandberg `_ for the PR 148 | 149 | * Added ability to change report title using hook (`#270 `_) 150 | 151 | * Thanks to `@werdeil `_ for the PR 152 | 153 | 2.0.1 (2019-10-05) 154 | ~~~~~~~~~~~~~~~~~~ 155 | 156 | * Properly check for presence of CSS file. (`#246 `_) 157 | 158 | * Thanks to `@wanam `_ for reporting, and `@krzysztof-pawlik-gat `_ for the fix 159 | 160 | * Added support for UTF-8 display. (`#244 `_) 161 | 162 | * Thanks to `@Izhu666 `_ for the PR 163 | 164 | * Fix initial sort on column. (`#247 `_) 165 | 166 | * Thanks to `@wanam `_ for reporting and fixing 167 | 168 | 2.0.0 (2019-09-09) 169 | ~~~~~~~~~~~~~~~~~~ 170 | 171 | * Drop support for Python 2.7. We will continue to accept patches to ``1.22.x`` for the time being. 172 | 173 | * Thanks to `@hugovk `_ for the PR 174 | 175 | 1.22.0 (2019-08-06) 176 | ~~~~~~~~~~~~~~~~~~~ 177 | 178 | * Refactor assets naming to be more readable and OS safe. 179 | 180 | * This solves multiple reported issues, mainly from Windows users. 181 | * Thanks to `@franz-95 `_ and `@Uil2Liv `_ 182 | for reporting and testing fixes. 183 | 184 | * Add line break to log section of the report. 185 | 186 | * Thanks to `@borntyping `_ for reporting and fixing! 187 | 188 | 1.21.1 (2019-06-19) 189 | ~~~~~~~~~~~~~~~~~~~ 190 | 191 | * Fix issue with assets filenames being too long. 192 | 193 | * Thanks to `@D3X `_ for reporting and providing a fix 194 | 195 | 1.21.0 (2019-06-17) 196 | ~~~~~~~~~~~~~~~~~~~ 197 | 198 | * Allow opening generated html report in browser (`@ssbarnea `_) 199 | 200 | * Handle when report title is stored as an environment variable (`@BeyondEvil `_) 201 | 202 | * Change assets naming method (`@SunInJuly `_) 203 | 204 | 1.20.0 (2019-01-14) 205 | ~~~~~~~~~~~~~~~~~~~ 206 | 207 | * Tests running with Pytest 4.0 and Python 3.7 208 | 209 | * Stop filtering out falsy environment values (`#175 `_) 210 | 211 | * Thanks to `@jknotts `_ for reporting the issue 212 | and to `@crazymerlyn `_ for providing a fix 213 | 214 | * Removed extraneous space from anchor tag (`@chardbury `_) 215 | 216 | * Always define __version__ even if get_distribution() fails (`@nicoddemus `_) 217 | 218 | * Refactor css config code (`@crazymerlyn `_) 219 | 220 | 1.19.0 (2018-06-01) 221 | ~~~~~~~~~~~~~~~~~~~ 222 | 223 | * Allow collapsed outcomes to be configured by using a query parameter 224 | 225 | * Thanks to `@Formartha `_ for suggesting this 226 | enhancement and to `@jacebrowning `_ for 227 | providing a patch 228 | 229 | 1.18.0 (2018-05-22) 230 | ~~~~~~~~~~~~~~~~~~~ 231 | 232 | * Preserve the order if metadata is ``OrderedDict`` 233 | 234 | * Thanks to `@jacebrowning `_ for suggesting 235 | this enhancement and providing a patch 236 | 237 | 1.17.0 (2018-04-05) 238 | ~~~~~~~~~~~~~~~~~~~ 239 | 240 | * Add support for custom CSS (`#116 `_) 241 | 242 | * Thanks to `@APshenkin `_ for reporting the 243 | issue and to `@i-am-david-fernandez 244 | `_ for providing a fix 245 | 246 | * Report collection errors (`#148 `_) 247 | 248 | * Thanks to `@Formartha `_ for reporting the 249 | issue 250 | 251 | * Add hook for modifying summary section (`#109 `_) 252 | 253 | * Thanks to `@shreyashah `_ for reporting the 254 | issue and to `@j19sch `_ for providing a 255 | fix 256 | 257 | * Add filename to report as heading 258 | 259 | * Thanks to `@j19sch `_ for the PR 260 | 261 | 262 | 1.16.1 (2018-01-04) 263 | ~~~~~~~~~~~~~~~~~~~ 264 | 265 | * Fix for including screenshots on Windows 266 | (`#124 `_) 267 | 268 | * Thanks to `@ngavrish `_ for reporting the 269 | issue and to `@pinkie1378 `_ for providing a 270 | fix 271 | 272 | 1.16.0 (2017-09-19) 273 | ~~~~~~~~~~~~~~~~~~~ 274 | 275 | * Improve rendering of collections in metadata 276 | (`@rasmuspeders1 `_) 277 | 278 | 1.15.2 (2017-08-15) 279 | ~~~~~~~~~~~~~~~~~~~ 280 | 281 | * Always decode byte string in extra text 282 | 283 | * Thanks to `@ch-t `_ for reporting the issue and 284 | providing a fix 285 | 286 | 1.15.1 (2017-06-12) 287 | ~~~~~~~~~~~~~~~~~~~ 288 | 289 | * Fix pytest dependency to 3.0 or later 290 | 291 | * Thanks to `@silvana-i `_ for reporting the 292 | issue and to `@nicoddemus `_ for providing a 293 | fix 294 | 295 | 1.15.0 (2017-06-09) 296 | ~~~~~~~~~~~~~~~~~~~ 297 | 298 | * Fix encoding issue in longrepr values 299 | 300 | * Thanks to `@tomga `_ for reporting the issue and 301 | providing a fix 302 | 303 | * Add ability to specify images as file or URL 304 | 305 | * Thanks to `@BeyondEvil `_ for the PR 306 | 307 | 1.14.2 (2017-03-10) 308 | ~~~~~~~~~~~~~~~~~~~ 309 | 310 | * Always encode content for data URI 311 | 312 | * Thanks to `@micheletest `_ and 313 | `@BeyondEvil `_ for reporting the issue and 314 | confirming the fix 315 | 316 | 1.14.1 (2017-02-28) 317 | ~~~~~~~~~~~~~~~~~~~ 318 | 319 | * Present metadata without additional formatting to avoid issues due to 320 | unpredictable content types 321 | 322 | 1.14.0 (2017-02-27) 323 | ~~~~~~~~~~~~~~~~~~~ 324 | 325 | * Add hooks for modifying the test results table 326 | * Replace environment section with values from 327 | `pytest-metadata `_ 328 | * Fix encoding for asset files 329 | * Escape contents of log sections 330 | 331 | 1.13.0 (2016-12-19) 332 | ~~~~~~~~~~~~~~~~~~~ 333 | 334 | * Disable ANSI codes support by default due to dependency on 335 | `ansi2html `_ package with less 336 | permissive licensing 337 | 338 | 1.12.0 (2016-11-30) 339 | ~~~~~~~~~~~~~~~~~~~ 340 | 341 | * Add support for JPG and SVG images 342 | (`@bhzunami `_) 343 | * Add version number and PyPI link to report header 344 | (`@denisra `_) 345 | 346 | 1.11.1 (2016-11-25) 347 | ~~~~~~~~~~~~~~~~~~~ 348 | 349 | * Fix title of checkbox disappearing when unchecked 350 | (`@vashirov `_) 351 | 352 | 1.11.0 (2016-11-08) 353 | ~~~~~~~~~~~~~~~~~~~ 354 | 355 | * Add support for ANSI codes in logs 356 | (`@premkarat `_) 357 | 358 | 1.10.1 (2016-09-23) 359 | ~~~~~~~~~~~~~~~~~~~ 360 | 361 | * Fix corrupt image asset files 362 | * Remove image links from self-contained report 363 | * Fix issue with unexpected passes not being reported in pytest 3.0 364 | 365 | 1.10.0 (2016-08-09) 366 | ~~~~~~~~~~~~~~~~~~~ 367 | 368 | * Hide filter checkboxes when JavaScript is disabled 369 | (`@RibeiroAna `_) 370 | * Removed rerun outcome unless the plugin is active 371 | (`@RibeiroAna `_) 372 | * Introduce ``--self-contained-html`` option to store CSS and assets inline 373 | (`@RibeiroAna `_) 374 | * Save images, text, and JSON extras as files in an assets directory 375 | (`@RibeiroAna `_) 376 | * Use an external CSS file 377 | (`@RibeiroAna `_) 378 | * Set initial sort order in the HTML 379 | (`@RibeiroAna `_) 380 | * Allow visibility of extra details to be toggled 381 | (`@leitzler `_) 382 | 383 | 1.9.0 (2016-07-04) 384 | ~~~~~~~~~~~~~~~~~~ 385 | 386 | * Split pytest_sessionfinish into generate and save methods 387 | (`@karandesai-96 `_) 388 | * Show tests rerun by pytest-rerunfailures plugin 389 | (`@RibeiroAna `_) 390 | * Added a feature to filter tests by outcome 391 | (`@RibeiroAna `_) 392 | 393 | 1.8.1 (2016-05-24) 394 | ~~~~~~~~~~~~~~~~~~ 395 | 396 | * Include captured output for passing tests 397 | 398 | 1.8.0 (2016-02-24) 399 | ~~~~~~~~~~~~~~~~~~ 400 | 401 | * Remove duplication from the environment section 402 | * Dropped support for Python 3.2 403 | * Indicated setup and teardown in report 404 | * Fixed colour of errors in report 405 | 406 | 1.7 (2015-10-19) 407 | ~~~~~~~~~~~~~~~~ 408 | 409 | * Fixed INTERNALERROR when an xdist worker crashes 410 | (`@The-Compiler `_) 411 | * Added report sections including stdout and stderr to log 412 | 413 | 1.6 (2015-09-08) 414 | ~~~~~~~~~~~~~~~~ 415 | 416 | * Fixed environment details when using pytest-xdist 417 | 418 | 1.5.1 (2015-08-18) 419 | ~~~~~~~~~~~~~~~~~~ 420 | 421 | * Made environment fixture session scoped to avoid repeating content 422 | 423 | 1.5 (2015-08-18) 424 | ~~~~~~~~~~~~~~~~ 425 | 426 | * Replaced custom hook for setting environemnt section with a fixture 427 | 428 | 1.4 (2015-08-12) 429 | ~~~~~~~~~~~~~~~~ 430 | 431 | * Dropped support for pytest 2.6 432 | * Fixed unencodable strings for Python 3 433 | (`@The-Compiler `_) 434 | 435 | 1.3.2 (2015-07-27) 436 | ~~~~~~~~~~~~~~~~~~ 437 | 438 | * Prevented additional row if log has no content or there is no extra HTML 439 | 440 | 1.3.1 (2015-05-26) 441 | ~~~~~~~~~~~~~~~~~~ 442 | 443 | * Fixed encoding issue in Python 3 444 | 445 | 1.3 (2015-05-26) 446 | ~~~~~~~~~~~~~~~~ 447 | 448 | * Show extra content regardless of test result 449 | * Added support for extra content in JSON format 450 | 451 | 1.2 (2015-05-20) 452 | ~~~~~~~~~~~~~~~~ 453 | 454 | * Changed default sort order to test result 455 | (`@The-Compiler `_) 456 | 457 | 1.1 (2015-05-08) 458 | ~~~~~~~~~~~~~~~~ 459 | 460 | * Added Python 3 support 461 | 462 | 1.0 (2015-04-20) 463 | ~~~~~~~~~~~~~~~~ 464 | 465 | * Initial release 466 | 467 | .. _Semantic Versioning: https://semver.org 468 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | import os 12 | import sys 13 | 14 | sys.path.insert(0, os.path.abspath("../src/")) 15 | 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = "pytest-html" 20 | copyright = "2020, Dave Hunt" # noqa: A001 21 | author = "Dave Hunt" 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = ["sphinx.ext.autodoc"] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = [] 33 | 34 | # List of patterns, relative to source directory, that match files and 35 | # directories to ignore when looking for source files. 36 | # This pattern also affects html_static_path and html_extra_path. 37 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 38 | 39 | 40 | # -- Options for HTML output ------------------------------------------------- 41 | 42 | # The theme to use for HTML and HTML Help pages. See the documentation for 43 | # a list of builtin themes. 44 | # 45 | html_theme = "alabaster" 46 | html_theme_options = { 47 | "github_user": "pytest-dev", 48 | "github_repo": "pytest-html", 49 | "github_banner": "true", 50 | "github_type": "star", 51 | } 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = [] 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | 62 | # -- Options for the autodoc extension --------------------------------------- 63 | 64 | autodoc_member_order = "alphabetical" 65 | -------------------------------------------------------------------------------- /docs/deprecations.rst: -------------------------------------------------------------------------------- 1 | Deprecations 2 | ============ 3 | 4 | Deprecation policy 5 | ------------------ 6 | 7 | If not otherwise explicitly stated, deprecations are removed in the next major version. 8 | 9 | Deprecations 10 | ------------ 11 | 12 | duration_formatter 13 | ~~~~~~~~~~~~~~~~~~ 14 | 15 | Deprecated in ``4.0.0`` 16 | 17 | *'duration_formatter' has been removed and no longer has any effect!* 18 | 19 | Reason 20 | ^^^^^^ 21 | 22 | With the rewrite of the plugin where most of the logic was moved to javascript, 23 | the disconnect between time formatting between python and javascript made it 24 | untenable to support dynamically setting the format. 25 | 26 | The decision was made to have ``ms`` for durations under 1 second, 27 | and ``HH:mm:ss`` for 1 second and above. 28 | 29 | Mitigation 30 | ^^^^^^^^^^ 31 | 32 | Currently none. 33 | 34 | render_collapsed 35 | ~~~~~~~~~~~~~~~~ 36 | 37 | Deprecated in ``4.0.0`` 38 | 39 | *'render_collapsed = True' is deprecated and support will be removed in the next major release. 40 | Please use 'render_collapsed = all' instead.* 41 | 42 | Reason 43 | ^^^^^^ 44 | 45 | We've changed the ini-config to better match the query param, so now the ini-config takes the same 46 | values as the query param. For valid values please see :ref:`render-collapsed`. 47 | 48 | Mitigation 49 | ^^^^^^^^^^ 50 | 51 | Setting ``render_collapsed`` to ``all`` is equivalent to previously setting it to ``True``. 52 | 53 | .. _report-extra: 54 | 55 | report.extra 56 | ~~~~~~~~~~~~ 57 | 58 | Deprecated in ``4.0.0`` 59 | 60 | *The 'report.extra' attribute is deprecated and will be removed in a future release, 61 | use 'report.extras' instead.* 62 | 63 | Reason 64 | ^^^^^^ 65 | 66 | The ``extra`` attribute is of type ``list``, hence more appropriately named ``extras``. 67 | 68 | Mitigation 69 | ^^^^^^^^^^ 70 | 71 | Rename ``extra`` to ``extras``. 72 | 73 | extra fixture 74 | ~~~~~~~~~~~~~ 75 | 76 | Deprecated in ``4.0.0`` 77 | 78 | *The 'extra' fixture is deprecated and will be removed in a future release, 79 | use 'extras' instead.* 80 | 81 | Reason 82 | ^^^^^^ 83 | 84 | See :ref:`report-extra` 85 | 86 | Mitigation 87 | ^^^^^^^^^^ 88 | 89 | Rename ``extra`` to ``extras``. 90 | 91 | cell list assignment 92 | ~~~~~~~~~~~~~~~~~~~~ 93 | 94 | Deprecated in ``4.0.0`` 95 | 96 | *list-type assignment is deprecated and support will be removed in a future release. 97 | Please use 'insert()' instead.* 98 | 99 | Reason 100 | ^^^^^^ 101 | 102 | The `cells` argument in the table manipulation hooks (see :ref:`modifying-results-table`) was 103 | previously of type `list` but is now an object. 104 | 105 | Mitigation 106 | ^^^^^^^^^^ 107 | 108 | Replace ``cells[4] = value`` with ``cells.insert(4, value)``. 109 | 110 | py module 111 | ~~~~~~~~~ 112 | 113 | Deprecated in ``4.0.0`` 114 | 115 | *The 'py' module is deprecated and support will be removed in a future release.* 116 | 117 | Reason 118 | ^^^^^^ 119 | 120 | The ``py`` module is in maintenance mode and has been removed as a dependency. 121 | 122 | Mitigation 123 | ^^^^^^^^^^ 124 | 125 | Any usage of the ``html`` module from ``py.xml``, should be replaced with something 126 | that returns the HTML as a string. 127 | 128 | From: 129 | 130 | .. code-block:: python 131 | 132 | import pytest 133 | from py.xml import html 134 | 135 | 136 | def pytest_html_results_table_header(cells): 137 | cells.insert(2, html.th("Description")) 138 | cells.insert(1, html.th("Time", class_="sortable time", data_column_type="time")) 139 | 140 | To: 141 | 142 | .. code-block:: python 143 | 144 | import pytest 145 | 146 | 147 | def pytest_html_results_table_header(cells): 148 | cells.insert(2, "Description") 149 | cells.insert(1, 'Time') 150 | 151 | Note that you can keep using the `py` module by simple wrapping it in ``str``: 152 | 153 | .. code-block:: python 154 | 155 | import pytest 156 | from py.xml import html 157 | 158 | 159 | def pytest_html_results_table_header(cells): 160 | cells.insert(2, str(html.th("Description"))) 161 | cells.insert( 162 | 1, str(html.th("Time", class_="sortable time", data_column_type="time")) 163 | ) 164 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | To contribute to `pytest-html` you can use `Hatch`_ to manage a python virtual environment and 5 | `pre-commit`_ to help you with styling and formatting. 6 | 7 | To setup the virtual environment and pre-commit, run: 8 | 9 | .. code-block:: bash 10 | 11 | $ hatch -e test run pre-commit install 12 | 13 | If you're not using `Hatch`_, run the following to install `pre-commit`_: 14 | 15 | .. code-block:: bash 16 | 17 | $ pip install pre-commit 18 | $ pre-commit install 19 | 20 | 21 | Automated Testing 22 | ----------------- 23 | 24 | All pull requests and merges are tested in `GitHub Actions`_ which are defined inside ``.github`` folder. 25 | 26 | To retrigger CI to run again for a pull request, you either use dropdown option, close and reopen pull-request 27 | or to just update the branch containing it. 28 | 29 | You can do this with `git commit --allow-empty` 30 | 31 | Running Tests 32 | ------------- 33 | 34 | Python 35 | ~~~~~~ 36 | 37 | You will need `Tox`_ and `Docker`_ installed to run the tests against the supported Python versions. If you're using `Hatch`_ 38 | it will be installed for you. 39 | 40 | The integration tests requires `Docker`_ as we have to render the report. 41 | This is then done using `Selenium`_ and `BeautifulSoup`_ 42 | 43 | To start the image needed, run: 44 | 45 | .. code-block:: bash 46 | 47 | $ ./start 48 | 49 | Sometimes the image becomes unresponsive and needs a restart: 50 | 51 | .. code-block:: bash 52 | 53 | $ ./start down && ./start 54 | 55 | You can watch the tests in your browser at `localhost:7900`, the password is `secret`. 56 | 57 | To run the tests with `Hatch`_, run: 58 | 59 | .. code-block:: bash 60 | 61 | $ hatch -e test run tox 62 | 63 | Otherwise, to install and run, do: 64 | 65 | .. code-block:: bash 66 | 67 | $ pip install tox 68 | $ tox 69 | 70 | JavaScript 71 | ~~~~~~~~~~ 72 | 73 | You will need `npm`_ installed to run the JavaScript tests. Internally, we use `Mocha`_, `Chai`_, `Sinon`_ to run the tests. 74 | 75 | Once `npm`_ is installed, you can install all needed dependencies by running: 76 | 77 | .. code-block:: bash 78 | 79 | $ npm install 80 | 81 | Run the following to execute the tests: 82 | 83 | .. code-block:: bash 84 | 85 | $ npm run unit 86 | 87 | Documentation 88 | ------------- 89 | 90 | Documentation is hosted on `Read the Docs`_, and is written in `RST`_. Remember to add any new files to the :code:`toctree` 91 | section in :code:`index.rst`. 92 | 93 | To build your documentation, run: 94 | 95 | .. code-block:: bash 96 | 97 | $ tox -e docs 98 | 99 | You can then run a local webserver to verify your changes compiled correctly. 100 | 101 | SASS/SCSS/CSS 102 | ------------- 103 | 104 | You will need `npm`_ installed to compile the CSS, which is generated via `SASS/SCSS`_. 105 | 106 | Once `npm`_ is installed, you can install all needed dependencies by running: 107 | 108 | .. code-block:: bash 109 | 110 | $ npm install 111 | 112 | Run the following to build the application: 113 | 114 | .. code-block:: bash 115 | 116 | $ npm run build 117 | 118 | Releasing a new version 119 | ----------------------- 120 | 121 | Follow these steps to release a new version of the project: 122 | 123 | #. Update your local master with the upstream master (``git pull --rebase upstream master``) 124 | #. Create a new branch 125 | #. Update `the changelog`_ with the new version, today's date, and all changes/new features 126 | #. Commit and push the new branch and then create a new pull request 127 | #. Wait for tests and reviews and then merge the branch 128 | #. Once merged, update your local master again (``git pull --rebase upstream master``) 129 | #. Tag the release with the new release version (``git tag ``) 130 | #. Push the tag (``git push upstream --tags``) 131 | #. Done. Check `Github Actions`_ for release progress. 132 | 133 | .. _GitHub Actions: https://github.com/pytest-dev/pytest-html/actions 134 | .. _Mocha: https://mochajs.org/ 135 | .. _npm: https://www.npmjs.com 136 | .. _Hatch: https://hatch.pypa.io/latest/ 137 | .. _pre-commit: https://pre-commit.com 138 | .. _Chai: https://www.chaijs.com/ 139 | .. _Sinon: https://sinonjs.org/ 140 | .. _Read The Docs: https://readthedocs.com 141 | .. _RST: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html 142 | .. _SASS/SCSS: https://sass-lang.com 143 | .. _the changelog: https://pytest-html.readthedocs.io/en/latest/changelog.html 144 | .. _Tox: https://tox.readthedocs.io 145 | .. _Docker: https://www.docker.com/ 146 | .. _Selenium: https://www.selenium.dev/ 147 | .. _BeautifulSoup: https://beautiful-soup-4.readthedocs.io/en/latest/ 148 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pytest-html documentation master file, created by 2 | sphinx-quickstart on Sun Dec 6 20:48:43 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | pytest-html 7 | =========== 8 | 9 | pytest-html is a plugin for `pytest`_ that generates a HTML report for test results. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | installing 16 | user_guide 17 | api_reference 18 | development 19 | changelog 20 | deprecations 21 | 22 | .. _pytest: http://pytest.org 23 | -------------------------------------------------------------------------------- /docs/installing.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | pytest-html will work with Python >=3.6 or PyPy3. 8 | 9 | Installing pytest-html 10 | ---------------------- 11 | 12 | To install pytest-html using `pip`_: 13 | 14 | .. code-block:: bash 15 | 16 | $ pip install pytest-html 17 | 18 | To install from source: 19 | 20 | .. code-block:: bash 21 | 22 | $ pip install -e . 23 | 24 | .. _pip: https://pip.pypa.io/ 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | sphinx<9.0.0 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile --resolver=backtracking requirements.in 6 | # 7 | alabaster==1.0.0 8 | # via sphinx 9 | babel==2.17.0 10 | # via sphinx 11 | certifi==2024.7.4 12 | # via requests 13 | charset-normalizer==3.1.0 14 | # via requests 15 | docutils==0.21.2 16 | # via 17 | # sphinx 18 | # sphinx-rtd-theme 19 | idna==3.7 20 | # via requests 21 | imagesize==1.4.1 22 | # via sphinx 23 | jinja2==3.1.6 24 | # via sphinx 25 | markupsafe==2.1.2 26 | # via jinja2 27 | packaging==23.1 28 | # via sphinx 29 | pygments==2.19.1 30 | # via sphinx 31 | requests==2.32.2 32 | # via sphinx 33 | roman-numerals-py==3.1.0 34 | # via sphinx 35 | snowballstemmer==2.2.0 36 | # via sphinx 37 | sphinx==8.2.3 38 | # via 39 | # -r docs/requirements.in 40 | # sphinx-rtd-theme 41 | # sphinxcontrib-jquery 42 | sphinx-rtd-theme==3.0.2 43 | # via -r docs/requirements.in 44 | sphinxcontrib-applehelp==2.0.0 45 | # via sphinx 46 | sphinxcontrib-devhelp==2.0.0 47 | # via sphinx 48 | sphinxcontrib-htmlhelp==2.1.0 49 | # via sphinx 50 | sphinxcontrib-jquery==4.1 51 | # via sphinx-rtd-theme 52 | sphinxcontrib-jsmath==1.0.1 53 | # via sphinx 54 | sphinxcontrib-qthelp==2.0.0 55 | # via sphinx 56 | sphinxcontrib-serializinghtml==2.0.0 57 | # via sphinx 58 | urllib3==2.2.2 59 | # via requests 60 | -------------------------------------------------------------------------------- /docs/user_guide.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | ANSI codes 5 | ---------- 6 | 7 | Note that ANSI code support depends on the `ansi2html`_ package. Due to the use 8 | of a less permissive license, this package is not included as a dependency. If 9 | you have this package installed, then ANSI codes will be converted to HTML in 10 | your report. 11 | 12 | Report streaming 13 | ---------------- 14 | 15 | In order to stream the result, basically generating the report for each finished test 16 | instead of waiting until the full run is finished, you can set the ``generate_report_on_test`` 17 | ini-value: 18 | 19 | .. code-block:: ini 20 | 21 | [pytest] 22 | generate_report_on_test = True 23 | 24 | Creating a self-contained report 25 | -------------------------------- 26 | 27 | In order to respect the `Content Security Policy (CSP)`_, several assets such as 28 | CSS and images are stored separately by default. You can alternatively create a 29 | self-contained report, which can be more convenient when sharing your results. 30 | This can be done in the following way: 31 | 32 | .. code-block:: bash 33 | 34 | $ pytest --html=report.html --self-contained-html 35 | 36 | Images added as files or links are going to be linked as external resources, 37 | meaning that the standalone report HTML file may not display these images 38 | as expected. 39 | 40 | The plugin will issue a warning when adding files or links to the standalone report. 41 | 42 | Enhancing reports 43 | ----------------- 44 | 45 | Appearance 46 | ~~~~~~~~~~ 47 | 48 | Custom CSS (Cascasding Style Sheets) can be passed on the command line using 49 | the :code:`--css` option. These will be applied in the order specified, and can 50 | be used to change the appearance of the report. 51 | 52 | .. code-block:: bash 53 | 54 | $ pytest --html=report.html --css=highcontrast.css --css=accessible.css 55 | 56 | Report Title 57 | ~~~~~~~~~~~~ 58 | 59 | By default the report title will be the filename of the report, you can edit it by using the :code:`pytest_html_report_title` hook: 60 | 61 | .. code-block:: python 62 | 63 | def pytest_html_report_title(report): 64 | report.title = "My very own title!" 65 | 66 | Environment 67 | ~~~~~~~~~~~ 68 | 69 | The *Environment* section is provided by the `pytest-metadata`_ plugin, and can be accessed 70 | via the :code:`pytest_configure` and :code:`pytest_sessionfinish` hooks: 71 | 72 | To modify the *Environment* section **before** tests are run, use :code:`pytest_configure`: 73 | 74 | .. code-block:: python 75 | 76 | from pytest_metadata.plugin import metadata_key 77 | 78 | 79 | def pytest_configure(config): 80 | config.stash[metadata_key]["foo"] = "bar" 81 | 82 | To modify the *Environment* section **after** tests are run, use :code:`pytest_sessionfinish`: 83 | 84 | .. code-block:: python 85 | 86 | import pytest 87 | from pytest_metadata.plugin import metadata_key 88 | 89 | 90 | @pytest.hookimpl(tryfirst=True) 91 | def pytest_sessionfinish(session, exitstatus): 92 | session.config.stash[metadata_key]["foo"] = "bar" 93 | 94 | Note that in the above example `@pytest.hookimpl(tryfirst=True)`_ is important, as this ensures that a best effort attempt is made to run your 95 | :code:`pytest_sessionfinish` **before** any other plugins ( including :code:`pytest-html` and :code:`pytest-metadata` ) run theirs. 96 | If this line is omitted, then the *Environment* table will **not** be updated since the :code:`pytest_sessionfinish` of the plugins will execute first, 97 | and thus not pick up your change. 98 | 99 | The generated table will be sorted alphabetically unless the metadata is a :code:`collections.OrderedDict`. 100 | 101 | It is possible to redact variables from the environment table. Redacted variables will have their names displayed, but their values grayed out. 102 | This can be achieved by setting :code:`environment_table_redact_list` in your INI configuration file (e.g.: :code:`pytest.ini`). 103 | :code:`environment_table_redact_list` is a :code:`linelist` of regexes. Any environment table variable that matches a regex in this list has its value redacted. 104 | 105 | For example, the following will redact all environment table variables that match the regexes :code:`^foo$`, :code:`.*redact.*`, or :code:`bar`: 106 | 107 | .. code-block:: ini 108 | 109 | [pytest] 110 | environment_table_redact_list = ^foo$ 111 | .*redact.* 112 | bar 113 | 114 | Additional summary information 115 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 116 | 117 | You can edit the *Summary* section by using the :code:`pytest_html_results_summary` hook: 118 | 119 | .. code-block:: python 120 | 121 | def pytest_html_results_summary(prefix, summary, postfix): 122 | prefix.extend(["

foo: bar

"]) 123 | 124 | Extra content 125 | ~~~~~~~~~~~~~ 126 | 127 | You can add details to the HTML report by creating an 'extras' list on the 128 | report object. Here are the types of extra content that can be added: 129 | 130 | ========== ============================================ 131 | Type Example 132 | ========== ============================================ 133 | Raw HTML ``extras.html('
Additional HTML
')`` 134 | `JSON`_ ``extras.json({'name': 'pytest'})`` 135 | Plain text ``extras.text('Add some simple Text')`` 136 | URL ``extras.url('http://www.example.com/')`` 137 | Image ``extras.image(image, mime_type='image/gif', extension='gif')`` 138 | Image ``extras.image('/path/to/file.png')`` 139 | Image ``extras.image('http://some_image.png')`` 140 | ========== ============================================ 141 | 142 | **Note**: When adding an image from file, the path can be either absolute 143 | or relative. 144 | 145 | **Note**: When using ``--self-contained-html``, images added as files or links 146 | may not work as expected, see section `Creating a self-contained report`_ for 147 | more info. 148 | 149 | There are also convenient types for several image formats: 150 | 151 | ============ ==================== 152 | Image format Example 153 | ============ ==================== 154 | PNG ``extras.png(image)`` 155 | JPEG ``extras.jpg(image)`` 156 | SVG ``extras.svg(image)`` 157 | ============ ==================== 158 | 159 | The following example adds the various types of extras using a 160 | :code:`pytest_runtest_makereport` hook, which can be implemented in a plugin or 161 | conftest.py file: 162 | 163 | .. code-block:: python 164 | 165 | import pytest 166 | import pytest_html 167 | 168 | 169 | @pytest.hookimpl(hookwrapper=True) 170 | def pytest_runtest_makereport(item, call): 171 | outcome = yield 172 | report = outcome.get_result() 173 | extras = getattr(report, "extras", []) 174 | if report.when == "call": 175 | # always add url to report 176 | extras.append(pytest_html.extras.url("http://www.example.com/")) 177 | xfail = hasattr(report, "wasxfail") 178 | if (report.skipped and xfail) or (report.failed and not xfail): 179 | # only add additional html on failure 180 | extras.append(pytest_html.extras.html("
Additional HTML
")) 181 | report.extras = extras 182 | 183 | You can also specify the :code:`name` argument for all types other than :code:`html` which will change the title of the 184 | created hyper link: 185 | 186 | .. code-block:: python 187 | 188 | extras.append(pytest_html.extras.text("some string", name="Different title")) 189 | 190 | It is also possible to use the fixture :code:`extras` to add content directly 191 | in a test function without implementing hooks. These will generally end up 192 | before any extras added by plugins. 193 | 194 | .. code-block:: python 195 | 196 | import pytest_html 197 | 198 | 199 | def test_extra(extras): 200 | extras.append(pytest_html.extras.text("some string")) 201 | 202 | 203 | .. _modifying-results-table: 204 | 205 | Modifying the results table 206 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 207 | 208 | You can modify the columns of the report by implementing custom hooks for the header and rows. 209 | The following example :code:`conftest.py` adds a description column with the test function docstring, 210 | adds a sortable time column, and removes the links column: 211 | 212 | .. code-block:: python 213 | 214 | import pytest 215 | from datetime import datetime 216 | 217 | 218 | def pytest_html_results_table_header(cells): 219 | cells.insert(2, "Description") 220 | cells.insert(1, 'Time') 221 | 222 | 223 | def pytest_html_results_table_row(report, cells): 224 | cells.insert(2, f"{report.description}") 225 | cells.insert(1, f'{datetime.utcnow()}') 226 | 227 | 228 | @pytest.hookimpl(hookwrapper=True) 229 | def pytest_runtest_makereport(item, call): 230 | outcome = yield 231 | report = outcome.get_result() 232 | report.description = str(item.function.__doc__) 233 | 234 | You can also remove results by implementing the 235 | :code:`pytest_html_results_table_row` hook and removing all cells. The 236 | following example removes all passed results from the report: 237 | 238 | .. code-block:: python 239 | 240 | def pytest_html_results_table_row(report, cells): 241 | if report.passed: 242 | del cells[:] 243 | 244 | The log output and additional HTML can be modified by implementing the 245 | :code:`pytest_html_results_html` hook. The following example replaces all 246 | additional HTML and log output with a notice that the log is empty: 247 | 248 | .. code-block:: python 249 | 250 | def pytest_html_results_table_html(report, data): 251 | if report.passed: 252 | del data[:] 253 | data.append("
No log output captured.
") 254 | 255 | Display options 256 | --------------- 257 | 258 | .. _render-collapsed: 259 | 260 | Auto Collapsing Table Rows 261 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 262 | 263 | By default, all rows in the **Results** table will be expanded except those that have :code:`Passed`. 264 | 265 | This behavior can be customized with a query parameter: :code:`?collapsed=Passed,XFailed,Skipped`. 266 | If you want all rows to be collapsed you can pass :code:`?collapsed=All`. 267 | By setting the query parameter to empty string :code:`?collapsed=""` **none** of the rows will be collapsed. 268 | 269 | Note that the query parameter is case insensitive, so passing :code:`PASSED` and :code:`passed` has the same effect. 270 | 271 | You can also set the collapsed behaviour by setting :code:`render_collapsed` in a configuration file (pytest.ini, setup.cfg, etc). 272 | Note that the query parameter takes precedence. 273 | 274 | .. code-block:: ini 275 | 276 | [pytest] 277 | render_collapsed = failed,error 278 | 279 | Controlling Test Result Visibility 280 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 281 | 282 | By default, all tests are visible, regardless of their results. It is possible to control which tests are visible on 283 | page load by passing the :code:`visible` query parameter. To use this parameter, please pass a comma separated list 284 | of test results you wish to be visible. For example, passing :code:`?visible=passed,skipped` will show only those 285 | tests in the report that have outcome :code:`passed` or :code:`skipped`. 286 | 287 | Note that this match is case insensitive, so passing :code:`PASSED` and :code:`passed` has the same effect. 288 | 289 | The following values may be passed: 290 | 291 | * :code:`passed` 292 | * :code:`skipped` 293 | * :code:`failed` 294 | * :code:`error` 295 | * :code:`xfailed` 296 | * :code:`xpassed` 297 | * :code:`rerun` 298 | 299 | Results Table Sorting 300 | ~~~~~~~~~~~~~~~~~~~~~ 301 | 302 | You can change which column the results table is sorted on, on page load by passing the :code:`sort` query parameter. 303 | 304 | You can also set the initial sorting by setting :code:`initial_sort` in a configuration file (pytest.ini, setup.cfg, etc). 305 | Note that the query parameter takes precedence. 306 | 307 | The following values may be passed: 308 | 309 | * :code:`result` 310 | * :code:`testId` 311 | * :code:`duration` 312 | * :code:`original` 313 | 314 | Note that the values are case *sensitive*. 315 | 316 | ``original`` means that a best effort is made to sort the table in the order of execution. 317 | If tests are run in parallel (with `pytest-xdist`_ for example), then the order may not be 318 | in the correct order. 319 | 320 | Formatting the Duration Column 321 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 322 | 323 | The formatting of the timestamp used in the :code:`Durations` column can be modified by using the 324 | :code:`pytest_html_duration_format` hook. The default timestamp will be `nnn ms` for durations 325 | less than one second and `hh:mm:ss` for durations equal to or greater than one second. 326 | 327 | Below is an example of a :code:`conftest.py` file setting :code:`pytest_html_duration_format`: 328 | 329 | .. code-block:: python 330 | 331 | import datetime 332 | 333 | 334 | def pytest_html_duration_format(duration): 335 | duration_timedelta = datetime.timedelta(seconds=duration) 336 | time = datetime.datetime(1, 1, 1) + duration_timedelta 337 | return time.strftime("%H:%M:%S") 338 | 339 | **NOTE**: The behavior of sorting the duration column is not guaranteed when providing a custom format. 340 | 341 | **NOTE**: The formatting of the total duration is not affected by this hook. 342 | 343 | .. _@pytest.hookimpl(tryfirst=True): https://docs.pytest.org/en/stable/writing_plugins.html#hook-function-ordering-call-example 344 | .. _ansi2html: https://pypi.python.org/pypi/ansi2html/ 345 | .. _Content Security Policy (CSP): https://developer.mozilla.org/docs/Web/Security/CSP/ 346 | .. _JSON: https://json.org/ 347 | .. _pytest-metadata: https://pypi.python.org/pypi/pytest-metadata/ 348 | .. _pytest-xdist: https://pypi.python.org/pypi/pytest-xdist/ 349 | .. _time.strftime: https://docs.python.org/3/library/time.html#time.strftime 350 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "npm run build:css && npm run build:jsapp", 4 | "build:css": "sass --no-source-map --no-error-css src/layout/css/style.scss src/pytest_html/resources/style.css", 5 | "build:jsapp": "browserify ./src/pytest_html/scripts/index.js > ./src/pytest_html/resources/app.js", 6 | "lint": "eslint src/pytest_html/scripts/ testing/", 7 | "unit": "nyc mocha testing/**/unittest.js --require mock-local-storage", 8 | "all": "npm run lint && npm run unit && npm run build:css && npm run build:jsapp" 9 | }, 10 | "devDependencies": { 11 | "browserify": "^17.0.1", 12 | "chai": "^4.3.6", 13 | "eslint": "^8.20.0", 14 | "eslint-config-google": "^0.14.0", 15 | "mocha": "^11.5.0", 16 | "mock-local-storage": "^1.1.24", 17 | "nyc": "^17.1.0", 18 | "sass": "^1.89.1", 19 | "sinon": "^20.0.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.3", 5 | "hatchling>=1.13", 6 | ] 7 | 8 | [project] 9 | name = "pytest-html" 10 | description = "pytest plugin for generating HTML reports" 11 | readme = "README.rst" 12 | keywords = [ 13 | "html", 14 | "pytest", 15 | "report", 16 | ] 17 | license = "MPL-2.0" 18 | authors = [ 19 | { name = "Dave Hunt", email = "dhunt@mozilla.com" }, 20 | { name = "Jim Brannlund", email = "jimbrannlund@fastmail.com" }, 21 | ] 22 | requires-python = ">=3.9" 23 | classifiers = [ 24 | "Development Status :: 5 - Production/Stable", 25 | "Framework :: Pytest", 26 | "Intended Audience :: Developers", 27 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 28 | "Natural Language :: English", 29 | "Operating System :: MacOS :: MacOS X", 30 | "Operating System :: Microsoft :: Windows", 31 | "Operating System :: POSIX", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | "Programming Language :: Python :: Implementation :: CPython", 39 | "Programming Language :: Python :: Implementation :: PyPy", 40 | "Topic :: Software Development :: Quality Assurance", 41 | "Topic :: Software Development :: Testing", 42 | "Topic :: Utilities", 43 | ] 44 | dynamic = [ 45 | "version", 46 | ] 47 | 48 | dependencies = [ 49 | "jinja2>=3", 50 | "pytest>=7", 51 | "pytest-metadata>=2", 52 | ] 53 | optional-dependencies.docs = [ 54 | "pip-tools>=6.13", 55 | ] 56 | optional-dependencies.test = [ 57 | "assertpy>=1.1", 58 | "beautifulsoup4>=4.11.1", 59 | "black>=22.1", 60 | "flake8>=4.0.1", 61 | "pre-commit>=2.17", 62 | "pytest-mock>=3.7", 63 | "pytest-rerunfailures>=11.1.2", 64 | "pytest-xdist>=2.4", 65 | "selenium>=4.3", 66 | "tox>=3.24.5", 67 | ] 68 | urls.Homepage = "https://github.com/pytest-dev/pytest-html" 69 | urls.Source = "https://github.com/pytest-dev/pytest-html" 70 | urls.Tracker = "https://github.com/pytest-dev/pytest-html/issues" 71 | entry-points.pytest11.html = "pytest_html.plugin" 72 | entry-points.pytest11.html_fixtures = "pytest_html.fixtures" 73 | 74 | [tool.hatch.envs.test] 75 | features = [ 76 | "test", 77 | ] 78 | 79 | [tool.hatch.version] 80 | source = "vcs" 81 | 82 | [tool.hatch.build.targets.wheel] 83 | exclude = [ 84 | "src/pytest_html/scripts/*", 85 | ] 86 | 87 | [tool.hatch.build.targets.sdist] 88 | exclude = [ 89 | "/.github", 90 | ] 91 | 92 | [tool.hatch.build.hooks.vcs] 93 | version-file = "src/pytest_html/__version.py" 94 | 95 | [tool.hatch.build.hooks.custom] 96 | path = "scripts/npm.py" 97 | 98 | [tool.mypy] 99 | check_untyped_defs = false # TODO 100 | disallow_any_generics = true 101 | disallow_incomplete_defs = true 102 | disallow_untyped_calls = true 103 | disallow_untyped_decorators = true 104 | disallow_untyped_defs = false # TODO 105 | ignore_missing_imports = true 106 | no_implicit_optional = true 107 | no_implicit_reexport = true 108 | show_error_codes = true 109 | strict_equality = true 110 | warn_redundant_casts = true 111 | warn_return_any = true 112 | warn_unreachable = true 113 | warn_unused_configs = true 114 | 115 | [tool.djlint] 116 | profile = "jinja" 117 | ignore = "H005,H016,H030,H031,H006,H013" 118 | -------------------------------------------------------------------------------- /scripts/npm.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | 4 | from hatchling.builders.hooks.plugin.interface import BuildHookInterface 5 | 6 | 7 | class NpmBuildHook(BuildHookInterface): 8 | def initialize(self, version, build_data): 9 | is_source = Path(self.root, ".git").exists() 10 | app_js_exists = Path( 11 | self.root, "src", "pytest_html", "resources", "app.js" 12 | ).exists() 13 | if is_source or not app_js_exists: 14 | subprocess.run("npm ci", capture_output=True, check=True, shell=True) 15 | subprocess.run("npm run build", capture_output=True, check=True, shell=True) 16 | -------------------------------------------------------------------------------- /src/.gitattributes: -------------------------------------------------------------------------------- 1 | 2 | # see https://github.com/github/linguist#generated-code 3 | pytest_html/resources/style.css linguist-generated 4 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | # file generated by setuptools_scm. don't track in version control 2 | pytest_html/__version.py 3 | 4 | # don't track built file 5 | app.js 6 | -------------------------------------------------------------------------------- /src/layout/css/style.scss: -------------------------------------------------------------------------------- 1 | $font-size-text: 12px; 2 | $font-size-h1: 24px; 3 | $font-size-h2: 16px; 4 | 5 | $border-width: 1px; 6 | 7 | $spacing: 5px; 8 | 9 | body { 10 | font-family: Helvetica, Arial, sans-serif; 11 | font-size: $font-size-text; 12 | /* do not increase min-width as some may use split screens */ 13 | min-width: 800px; 14 | color: #999; 15 | } 16 | 17 | h1 { 18 | font-size: $font-size-h1; 19 | color: black; 20 | } 21 | 22 | h2 { 23 | font-size: $font-size-h2; 24 | color: black; 25 | } 26 | 27 | p { 28 | color: black; 29 | } 30 | 31 | a { 32 | color: #999; 33 | } 34 | 35 | table { 36 | border-collapse: collapse; 37 | } 38 | 39 | /****************************** 40 | * SUMMARY INFORMATION 41 | ******************************/ 42 | 43 | #environment { 44 | td { 45 | padding: $spacing; 46 | border: $border-width solid #e6e6e6; 47 | vertical-align: top; 48 | } 49 | tr:nth-child(odd) { 50 | background-color: #f6f6f6; 51 | } 52 | ul { 53 | margin: 0; 54 | padding: 0 20px; 55 | } 56 | } 57 | 58 | /****************************** 59 | * TEST RESULT COLORS 60 | ******************************/ 61 | span.passed, 62 | .passed .col-result { 63 | color: green; 64 | } 65 | 66 | span.skipped, 67 | span.xfailed, 68 | span.rerun, 69 | .skipped .col-result, 70 | .xfailed .col-result, 71 | .rerun .col-result { 72 | color: orange; 73 | } 74 | 75 | span.error, 76 | span.failed, 77 | span.xpassed, 78 | .error .col-result, 79 | .failed .col-result, 80 | .xpassed .col-result { 81 | color: red; 82 | } 83 | 84 | .col-links__extra { 85 | margin-right: 3px; 86 | } 87 | 88 | /****************************** 89 | * RESULTS TABLE 90 | * 91 | * 1. Table Layout 92 | * 2. Extra 93 | * 3. Sorting items 94 | * 95 | ******************************/ 96 | 97 | /*------------------ 98 | * 1. Table Layout 99 | *------------------*/ 100 | 101 | #results-table { 102 | border: $border-width solid #e6e6e6; 103 | color: #999; 104 | font-size: $font-size-text; 105 | width: 100%; 106 | 107 | th, 108 | td { 109 | padding: $spacing; 110 | border: $border-width solid #e6e6e6; 111 | text-align: left; 112 | } 113 | 114 | th { 115 | font-weight: bold; 116 | } 117 | } 118 | 119 | /*------------------ 120 | * 2. Extra 121 | *------------------*/ 122 | 123 | $extra-height: 240px; 124 | $extra-media-width: 320px; 125 | 126 | .logwrapper { 127 | max-height: $extra-height - 2 * $spacing; 128 | overflow-y: scroll; 129 | background-color: #e6e6e6; 130 | &.expanded { 131 | max-height: none; 132 | .logexpander { 133 | &:after { 134 | content: 'collapse [-]'; 135 | } 136 | } 137 | } 138 | .logexpander { 139 | z-index: 1; 140 | position: sticky; 141 | top: 10px; 142 | width: max-content; 143 | border: 1px solid; 144 | border-radius: 3px; 145 | padding: 5px 7px; 146 | margin: 10px 0 10px calc(100% - 80px); 147 | cursor: pointer; 148 | background-color: #e6e6e6; 149 | &:after { 150 | content: 'expand [+]'; 151 | } 152 | &:hover { 153 | color: #000; 154 | border-color: #000; 155 | } 156 | } 157 | .log { 158 | min-height: 40px; 159 | position: relative; 160 | top: -50px; 161 | height: calc(100% + 50px); 162 | border: $border-width solid #e6e6e6; 163 | color: black; 164 | display: block; 165 | font-family: 'Courier New', Courier, monospace; 166 | padding: $spacing; 167 | padding-right: 80px; 168 | white-space: pre-wrap; 169 | } 170 | } 171 | div.media { 172 | border: $border-width solid #e6e6e6; 173 | float: right; 174 | height: $extra-height; 175 | margin: 0 $spacing; 176 | overflow: hidden; 177 | width: $extra-media-width; 178 | } 179 | 180 | .media-container { 181 | display: grid; 182 | grid-template-columns: 25px auto 25px; 183 | align-items: center; 184 | flex: 1 1; 185 | overflow: hidden; 186 | height: 200px; 187 | } 188 | .media-container--fullscreen { 189 | grid-template-columns: 0px auto 0px; 190 | } 191 | .media-container__nav--right, 192 | .media-container__nav--left { 193 | text-align: center; 194 | cursor: pointer; 195 | } 196 | 197 | .media-container__viewport { 198 | cursor: pointer; 199 | text-align: center; 200 | height: inherit; 201 | img, 202 | video { 203 | object-fit: cover; 204 | width: 100%; 205 | max-height: 100%; 206 | } 207 | } 208 | 209 | .media__name, 210 | .media__counter { 211 | display: flex; 212 | flex-direction: row; 213 | justify-content: space-around; 214 | flex: 0 0 25px; 215 | align-items: center; 216 | } 217 | 218 | @mixin rowToggle { 219 | color: #bbb; 220 | font-style: italic; 221 | cursor: pointer; 222 | } 223 | 224 | .collapsible td:not(.col-links) { 225 | cursor: pointer; 226 | &:hover::after { 227 | @include rowToggle; 228 | } 229 | } 230 | 231 | .col-result { 232 | width: 130px; 233 | &:hover::after { 234 | content: ' (hide details)'; 235 | } 236 | } 237 | .col-result.collapsed { 238 | &:hover::after { 239 | content: ' (show details)'; 240 | } 241 | } 242 | 243 | #environment-header h2 { 244 | &:hover::after { 245 | content: ' (hide details)'; 246 | @include rowToggle; 247 | font-size: $font-size-text; 248 | } 249 | } 250 | #environment-header.collapsed h2 { 251 | &:hover::after { 252 | content: ' (show details)'; 253 | @include rowToggle; 254 | font-size: $font-size-text; 255 | } 256 | } 257 | 258 | /*------------------ 259 | * 3. Sorting items 260 | *------------------*/ 261 | .sortable { 262 | cursor: pointer; 263 | &.desc { 264 | &:after { 265 | content: ' '; 266 | position: relative; 267 | left: 5px; 268 | bottom: -12.5px; 269 | border: 10px solid #4caf50; 270 | border-bottom: 0; 271 | border-left-color: transparent; 272 | border-right-color: transparent; 273 | } 274 | } 275 | &.asc { 276 | &:after { 277 | content: ' '; 278 | position: relative; 279 | left: 5px; 280 | bottom: 12.5px; 281 | border: 10px solid #4caf50; 282 | border-top: 0; 283 | border-left-color: transparent; 284 | border-right-color: transparent; 285 | } 286 | } 287 | } 288 | 289 | .sort-icon { 290 | // font-size: 0px; 291 | // float: left; 292 | // margin-right: $spacing; 293 | // margin-top: $spacing; 294 | 295 | // /*triangle*/ 296 | // $triangle-width: 8px; 297 | // width: 0; 298 | // height: 0; 299 | // border-left: $triangle-width solid transparent; 300 | // border-right: $triangle-width solid transparent; 301 | 302 | // .asc { 303 | // /*finish triangle*/ 304 | // border-bottom: $triangle-width solid #999; 305 | // } 306 | 307 | // .desc { 308 | // /*finish triangle*/ 309 | // border-top: $triangle-width solid #999; 310 | // } 311 | } 312 | 313 | .hidden { 314 | display: none; 315 | } 316 | 317 | .summary { 318 | &__data { 319 | flex: 0 0 550px; 320 | } 321 | &__reload { 322 | flex: 1 1; 323 | display: flex; 324 | justify-content: center; 325 | &__button { 326 | flex: 0 0 300px; 327 | display: flex; 328 | color: white; 329 | font-weight: bold; 330 | background-color: #4caf50; 331 | text-align: center; 332 | justify-content: center; 333 | align-items: center; 334 | border-radius: 3px; 335 | cursor: pointer; 336 | &.hidden { 337 | @extend .hidden; 338 | } 339 | &:hover { 340 | background-color: #46a049; 341 | } 342 | } 343 | } 344 | &__spacer { 345 | flex: 0 0 550px; 346 | } 347 | } 348 | .controls { 349 | display: flex; 350 | justify-content: space-between; 351 | } 352 | .filters, 353 | .collapse { 354 | display: flex; 355 | align-items: center; 356 | button { 357 | color: #999; 358 | border: none; 359 | background: none; 360 | cursor: pointer; 361 | text-decoration: underline; 362 | &:hover { 363 | color: #ccc; 364 | } 365 | } 366 | } 367 | .filter__label { 368 | margin-right: 10px; 369 | } 370 | -------------------------------------------------------------------------------- /src/pytest_html/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from . import __version # type: ignore 3 | 4 | __version__ = __version.version 5 | except ImportError: 6 | # package is not built with setuptools_scm 7 | __version__ = "unknown" 8 | 9 | __pypi_url__ = "https://pypi.python.org/pypi/pytest-html" 10 | -------------------------------------------------------------------------------- /src/pytest_html/basereport.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import datetime 5 | import json 6 | import math 7 | import os 8 | import re 9 | import time 10 | import warnings 11 | from collections import defaultdict 12 | from html import escape 13 | from pathlib import Path 14 | 15 | import pytest 16 | 17 | from pytest_html import __version__ 18 | from pytest_html import extras 19 | 20 | 21 | class BaseReport: 22 | def __init__(self, report_path, config, report_data, template, css): 23 | self._report_path = ( 24 | Path.cwd() / Path(os.path.expandvars(report_path)).expanduser() 25 | ) 26 | self._report_path.parent.mkdir(parents=True, exist_ok=True) 27 | self._config = config 28 | self._template = template 29 | self._css = css 30 | self._max_asset_filename_length = int( 31 | config.getini("max_asset_filename_length") 32 | ) 33 | 34 | self._reports = defaultdict(dict) 35 | self._report = report_data 36 | self._report.title = self._report_path.name 37 | self._suite_start_time = time.time() 38 | 39 | @property 40 | def css(self): 41 | # implement in subclasses 42 | return 43 | 44 | def _asset_filename(self, test_id, extra_index, test_index, file_extension): 45 | return "{}_{}_{}.{}".format( 46 | re.sub(r"[^\w.]", "_", test_id), 47 | str(extra_index), 48 | str(test_index), 49 | file_extension, 50 | )[-self._max_asset_filename_length :] 51 | 52 | def _generate_report(self, self_contained=False): 53 | generated = datetime.datetime.now() 54 | test_data = self._report.data 55 | test_data = json.dumps(test_data) 56 | rendered_report = self._template.render( 57 | title=self._report.title, 58 | date=generated.strftime("%d-%b-%Y"), 59 | time=generated.strftime("%H:%M:%S"), 60 | version=__version__, 61 | styles=self.css, 62 | run_count=self._run_count(), 63 | running_state=self._report.running_state, 64 | self_contained=self_contained, 65 | outcomes=self._report.outcomes, 66 | test_data=test_data, 67 | table_head=self._report.table_header, 68 | additional_summary=self._report.additional_summary, 69 | ) 70 | 71 | self._write_report(rendered_report) 72 | 73 | def _generate_environment(self): 74 | try: 75 | from pytest_metadata.plugin import metadata_key 76 | 77 | metadata = self._config.stash[metadata_key] 78 | except ImportError: 79 | # old version of pytest-metadata 80 | metadata = self._config._metadata 81 | warnings.warn( 82 | "'pytest-metadata < 3.0.0' is deprecated and support will be dropped in next major version", 83 | DeprecationWarning, 84 | ) 85 | 86 | for key in metadata.keys(): 87 | value = metadata[key] 88 | if self._is_redactable_environment_variable(key): 89 | black_box_ascii_value = 0x2593 90 | metadata[key] = "".join(chr(black_box_ascii_value) for _ in str(value)) 91 | 92 | return metadata 93 | 94 | def _is_redactable_environment_variable(self, environment_variable): 95 | redactable_regexes = self._config.getini("environment_table_redact_list") 96 | for redactable_regex in redactable_regexes: 97 | if re.match(redactable_regex, environment_variable): 98 | return True 99 | 100 | return False 101 | 102 | def _data_content(self, *args, **kwargs): 103 | pass 104 | 105 | def _media_content(self, *args, **kwargs): 106 | pass 107 | 108 | def _process_extras(self, report, test_id): 109 | test_index = hasattr(report, "rerun") and report.rerun + 1 or 0 110 | report_extras = getattr(report, "extras", []) 111 | for extra_index, extra in enumerate(report_extras): 112 | content = extra["content"] 113 | asset_name = self._asset_filename( 114 | test_id.encode("utf-8").decode("unicode_escape"), 115 | extra_index, 116 | test_index, 117 | extra["extension"], 118 | ) 119 | if extra["format_type"] == extras.FORMAT_JSON: 120 | content = json.dumps(content) 121 | extra["content"] = self._data_content( 122 | content, asset_name=asset_name, mime_type=extra["mime_type"] 123 | ) 124 | 125 | if extra["format_type"] == extras.FORMAT_TEXT: 126 | if isinstance(content, bytes): 127 | content = content.decode("utf-8") 128 | extra["content"] = self._data_content( 129 | content, asset_name=asset_name, mime_type=extra["mime_type"] 130 | ) 131 | 132 | if extra["format_type"] in [extras.FORMAT_IMAGE, extras.FORMAT_VIDEO]: 133 | extra["content"] = self._media_content( 134 | content, asset_name=asset_name, mime_type=extra["mime_type"] 135 | ) 136 | 137 | return report_extras 138 | 139 | def _write_report(self, rendered_report): 140 | with self._report_path.open("w", encoding="utf-8") as f: 141 | f.write(rendered_report) 142 | 143 | def _run_count(self): 144 | relevant_outcomes = ["passed", "failed", "xpassed", "xfailed"] 145 | counts = 0 146 | for outcome in self._report.outcomes.keys(): 147 | if outcome in relevant_outcomes: 148 | counts += self._report.outcomes[outcome]["value"] 149 | 150 | plural = counts > 1 151 | duration = _format_duration(self._report.total_duration) 152 | 153 | if self._report.running_state == "finished": 154 | return f"{counts} {'tests' if plural else 'test'} took {duration}." 155 | 156 | return f"{counts}/{self._report.collected_items} {'tests' if plural else 'test'} done." 157 | 158 | def _hydrate_data(self, data, cells): 159 | for index, cell in enumerate(cells): 160 | # extract column name and data if column is sortable 161 | if "sortable" in self._report.table_header[index]: 162 | name_match = re.search(r"col-(\w+)", cell) 163 | data_match = re.search(r"(.*?)", cell) 164 | if name_match and data_match: 165 | data[name_match.group(1)] = data_match.group(1) 166 | 167 | @pytest.hookimpl(trylast=True) 168 | def pytest_sessionstart(self, session): 169 | self._report.set_data("environment", self._generate_environment()) 170 | 171 | session.config.hook.pytest_html_report_title(report=self._report) 172 | 173 | headers = self._report.table_header 174 | session.config.hook.pytest_html_results_table_header(cells=headers) 175 | self._report.table_header = _fix_py(headers) 176 | 177 | self._report.running_state = "started" 178 | if self._config.getini("generate_report_on_test"): 179 | self._generate_report() 180 | 181 | @pytest.hookimpl(trylast=True) 182 | def pytest_sessionfinish(self, session): 183 | session.config.hook.pytest_html_results_summary( 184 | prefix=self._report.additional_summary["prefix"], 185 | summary=self._report.additional_summary["summary"], 186 | postfix=self._report.additional_summary["postfix"], 187 | session=session, 188 | ) 189 | self._report.running_state = "finished" 190 | suite_stop_time = time.time() 191 | self._report.total_duration = suite_stop_time - self._suite_start_time 192 | self._generate_report() 193 | 194 | @pytest.hookimpl(trylast=True) 195 | def pytest_terminal_summary(self, terminalreporter): 196 | terminalreporter.write_sep( 197 | "-", 198 | f"Generated html report: {self._report_path.as_uri()}", 199 | ) 200 | 201 | @pytest.hookimpl(trylast=True) 202 | def pytest_collectreport(self, report): 203 | if report.failed: 204 | self._process_report(report, 0, []) 205 | 206 | @pytest.hookimpl(trylast=True) 207 | def pytest_collection_finish(self, session): 208 | self._report.collected_items = len(session.items) 209 | 210 | @pytest.hookimpl(trylast=True) 211 | def pytest_runtest_logreport(self, report): 212 | if hasattr(report, "duration_formatter"): 213 | warnings.warn( 214 | "'duration_formatter' has been removed and no longer has any effect!" 215 | "Please use the 'pytest_html_duration_format' hook instead.", 216 | DeprecationWarning, 217 | ) 218 | 219 | # "reruns" makes this code a mess. 220 | # We store each combination of when and outcome 221 | # exactly once, unless that outcome is a "rerun" 222 | # then we store all of them. 223 | key = (report.when, report.outcome) 224 | if report.outcome == "rerun": 225 | if key not in self._reports[report.nodeid]: 226 | self._reports[report.nodeid][key] = list() 227 | self._reports[report.nodeid][key].append(report) 228 | else: 229 | self._reports[report.nodeid][key] = [report] 230 | 231 | finished = report.when == "teardown" and report.outcome != "rerun" 232 | if not finished: 233 | return 234 | 235 | # Calculate total duration for a single test. 236 | # This is needed to add the "teardown" duration 237 | # to tests total duration. 238 | test_duration = 0 239 | for key, reports in self._reports[report.nodeid].items(): 240 | _, outcome = key 241 | if outcome != "rerun": 242 | test_duration += reports[0].duration 243 | 244 | processed_extras = [] 245 | for key, reports in self._reports[report.nodeid].items(): 246 | when, _ = key 247 | for each in reports: 248 | test_id = report.nodeid 249 | if when != "call": 250 | test_id += f"::{when}" 251 | processed_extras += self._process_extras(each, test_id) 252 | 253 | for key, reports in self._reports[report.nodeid].items(): 254 | when, _ = key 255 | for each in reports: 256 | dur = test_duration if when == "call" else each.duration 257 | self._process_report(each, dur, processed_extras) 258 | 259 | if self._config.getini("generate_report_on_test"): 260 | self._generate_report() 261 | 262 | def _process_report(self, report, duration, processed_extras): 263 | outcome = _process_outcome(report) 264 | try: 265 | # hook returns as list for some reason 266 | formatted_duration = self._config.hook.pytest_html_duration_format( 267 | duration=duration 268 | )[0] 269 | except IndexError: 270 | formatted_duration = _format_duration(duration) 271 | 272 | test_id = report.nodeid 273 | if report.when != "call": 274 | test_id += f"::{report.when}" 275 | 276 | data = { 277 | "extras": processed_extras, 278 | } 279 | 280 | links = [ 281 | extra 282 | for extra in data["extras"] 283 | if extra["format_type"] in ["json", "text", "url"] 284 | ] 285 | cells = [ 286 | f'{outcome}', 287 | f'{test_id}', 288 | f'{formatted_duration}', 289 | f'{_process_links(links)}', 290 | ] 291 | self._config.hook.pytest_html_results_table_row(report=report, cells=cells) 292 | if not cells: 293 | return 294 | 295 | cells = _fix_py(cells) 296 | self._hydrate_data(data, cells) 297 | data["resultsTableRow"] = cells 298 | 299 | processed_logs = _process_logs(report) 300 | self._config.hook.pytest_html_results_table_html( 301 | report=report, data=processed_logs 302 | ) 303 | 304 | self._report.add_test(data, report, outcome, processed_logs) 305 | 306 | 307 | def _format_duration(duration): 308 | if duration < 1: 309 | return f"{round(duration * 1000)} ms" 310 | 311 | hours = math.floor(duration / 3600) 312 | remaining_seconds = duration % 3600 313 | minutes = math.floor(remaining_seconds / 60) 314 | remaining_seconds = remaining_seconds % 60 315 | seconds = round(remaining_seconds) 316 | 317 | return f"{hours:02d}:{minutes:02d}:{seconds:02d}" 318 | 319 | 320 | def _is_error(report): 321 | return ( 322 | report.when in ["setup", "teardown", "collect"] and report.outcome == "failed" 323 | ) 324 | 325 | 326 | def _process_logs(report): 327 | log = [] 328 | if report.longreprtext: 329 | log.append(escape(report.longreprtext) + "\n") 330 | # Don't add captured output to reruns 331 | if report.outcome != "rerun": 332 | for section in report.sections: 333 | header, content = map(escape, section) 334 | log.append(f"{' ' + header + ' ':-^80}\n{content}") 335 | 336 | # weird formatting related to logs 337 | if "log" in header: 338 | log.append("") 339 | if "call" in header: 340 | log.append("") 341 | if not log: 342 | log.append("No log output captured.") 343 | return log 344 | 345 | 346 | def _process_outcome(report): 347 | if _is_error(report): 348 | return "Error" 349 | if hasattr(report, "wasxfail"): 350 | if report.outcome in ["passed", "failed"]: 351 | return "XPassed" 352 | if report.outcome == "skipped": 353 | return "XFailed" 354 | 355 | return report.outcome.capitalize() 356 | 357 | 358 | def _process_links(links): 359 | a_tag = '{name}' 360 | return "".join([a_tag.format_map(link) for link in links]) 361 | 362 | 363 | def _fix_py(cells): 364 | # backwards-compat 365 | new_cells = [] 366 | for html in cells: 367 | if not isinstance(html, str): 368 | if html.__module__.startswith("py."): 369 | warnings.warn( 370 | "The 'py' module is deprecated and support " 371 | "will be removed in a future release.", 372 | DeprecationWarning, 373 | ) 374 | html = str(html) 375 | html = html.replace("col=", "data-column-type=") 376 | new_cells.append(html) 377 | return new_cells 378 | -------------------------------------------------------------------------------- /src/pytest_html/extras.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | from typing import Optional 5 | 6 | FORMAT_HTML = "html" 7 | FORMAT_IMAGE = "image" 8 | FORMAT_JSON = "json" 9 | FORMAT_TEXT = "text" 10 | FORMAT_URL = "url" 11 | FORMAT_VIDEO = "video" 12 | 13 | 14 | def extra( 15 | content: str, 16 | format_type: str, 17 | name: Optional[str] = None, 18 | mime_type: Optional[str] = None, 19 | extension: Optional[str] = None, 20 | ) -> dict[str, Optional[str]]: 21 | return { 22 | "name": name, 23 | "format_type": format_type, 24 | "content": content, 25 | "mime_type": mime_type, 26 | "extension": extension, 27 | } 28 | 29 | 30 | def html(content: str) -> dict[str, Optional[str]]: 31 | return extra(content, FORMAT_HTML) 32 | 33 | 34 | def image( 35 | content: str, 36 | name: str = "Image", 37 | mime_type: str = "image/png", 38 | extension: str = "png", 39 | ) -> dict[str, Optional[str]]: 40 | return extra(content, FORMAT_IMAGE, name, mime_type, extension) 41 | 42 | 43 | def png(content: str, name: str = "Image") -> dict[str, Optional[str]]: 44 | return image(content, name, mime_type="image/png", extension="png") 45 | 46 | 47 | def jpg(content: str, name: str = "Image") -> dict[str, Optional[str]]: 48 | return image(content, name, mime_type="image/jpeg", extension="jpg") 49 | 50 | 51 | def svg(content: str, name: str = "Image") -> dict[str, Optional[str]]: 52 | return image(content, name, mime_type="image/svg+xml", extension="svg") 53 | 54 | 55 | def json(content: str, name: str = "JSON") -> dict[str, Optional[str]]: 56 | return extra(content, FORMAT_JSON, name, "application/json", "json") 57 | 58 | 59 | def text(content: str, name: str = "Text") -> dict[str, Optional[str]]: 60 | return extra(content, FORMAT_TEXT, name, "text/plain", "txt") 61 | 62 | 63 | def url(content: str, name: str = "URL") -> dict[str, Optional[str]]: 64 | return extra(content, FORMAT_URL, name) 65 | 66 | 67 | def video( 68 | content: str, 69 | name: str = "Video", 70 | mime_type: str = "video/mp4", 71 | extension: str = "mp4", 72 | ) -> dict[str, Optional[str]]: 73 | return extra(content, FORMAT_VIDEO, name, mime_type, extension) 74 | 75 | 76 | def mp4(content: str, name: str = "Video") -> dict[str, Optional[str]]: 77 | return video(content, name) 78 | -------------------------------------------------------------------------------- /src/pytest_html/fixtures.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import warnings 5 | 6 | import pytest 7 | 8 | extras_stash_key = pytest.StashKey[list]() 9 | 10 | 11 | @pytest.fixture 12 | def extra(pytestconfig): 13 | """DEPRECATED: Add details to the HTML reports. 14 | 15 | .. code-block:: python 16 | 17 | import pytest_html 18 | 19 | 20 | def test_foo(extra): 21 | extra.append(pytest_html.extras.url("https://www.example.com/")) 22 | """ 23 | warnings.warn( 24 | "The 'extra' fixture is deprecated and will be removed in a future release" 25 | ", use 'extras' instead.", 26 | DeprecationWarning, 27 | ) 28 | pytestconfig.stash[extras_stash_key] = [] 29 | yield pytestconfig.stash[extras_stash_key] 30 | del pytestconfig.stash[extras_stash_key][:] 31 | 32 | 33 | @pytest.fixture 34 | def extras(pytestconfig): 35 | """Add details to the HTML reports. 36 | 37 | .. code-block:: python 38 | 39 | import pytest_html 40 | 41 | 42 | def test_foo(extras): 43 | extras.append(pytest_html.extras.url("https://www.example.com/")) 44 | """ 45 | pytestconfig.stash[extras_stash_key] = [] 46 | yield pytestconfig.stash[extras_stash_key] 47 | del pytestconfig.stash[extras_stash_key][:] 48 | -------------------------------------------------------------------------------- /src/pytest_html/hooks.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | def pytest_html_report_title(report): 7 | """Called before adding the title to the report""" 8 | 9 | 10 | def pytest_html_results_summary(prefix, summary, postfix, session): 11 | """Called before adding the summary section to the report""" 12 | 13 | 14 | def pytest_html_results_table_header(cells): 15 | """Called after building results table header.""" 16 | 17 | 18 | def pytest_html_results_table_row(report, cells): 19 | """Called after building results table row.""" 20 | 21 | 22 | def pytest_html_results_table_html(report, data): 23 | """Called after building results table additional HTML.""" 24 | 25 | 26 | def pytest_html_duration_format(duration): 27 | """Called before using the default duration formatting.""" 28 | -------------------------------------------------------------------------------- /src/pytest_html/plugin.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import os 5 | import warnings 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from pytest_html import extras # noqa: F401 11 | from pytest_html.fixtures import extras_stash_key 12 | from pytest_html.report import Report 13 | from pytest_html.report_data import ReportData 14 | from pytest_html.selfcontained_report import SelfContainedReport 15 | from pytest_html.util import _process_css 16 | from pytest_html.util import _read_template 17 | 18 | 19 | def pytest_addhooks(pluginmanager): 20 | from pytest_html import hooks 21 | 22 | pluginmanager.add_hookspecs(hooks) 23 | 24 | 25 | def pytest_addoption(parser): 26 | group = parser.getgroup("terminal reporting") 27 | group.addoption( 28 | "--html", 29 | action="store", 30 | dest="htmlpath", 31 | metavar="path", 32 | default=None, 33 | help="create html report file at given path.", 34 | ) 35 | group.addoption( 36 | "--self-contained-html", 37 | action="store_true", 38 | help="create a self-contained html file containing all " 39 | "necessary styles, scripts, and images - this means " 40 | "that the report may not render or function where CSP " 41 | "restrictions are in place (see " 42 | "https://developer.mozilla.org/docs/Web/Security/CSP)", 43 | ) 44 | group.addoption( 45 | "--css", 46 | action="append", 47 | metavar="path", 48 | default=[], 49 | help="append given css file content to report style file.", 50 | ) 51 | parser.addini( 52 | "render_collapsed", 53 | type="string", 54 | default="passed", 55 | help="row(s) to render collapsed on open.", 56 | ) 57 | parser.addini( 58 | "max_asset_filename_length", 59 | default=255, 60 | help="set the maximum filename length for assets " 61 | "attached to the html report.", 62 | ) 63 | parser.addini( 64 | "environment_table_redact_list", 65 | type="linelist", 66 | help="a list of regexes corresponding to environment " 67 | "table variables whose values should be redacted from the report", 68 | ) 69 | parser.addini( 70 | "initial_sort", 71 | type="string", 72 | default="result", 73 | help="column to initially sort on.", 74 | ) 75 | parser.addini( 76 | "generate_report_on_test", 77 | type="bool", 78 | default=False, 79 | help="the HTML report will be generated after each test " 80 | "instead of at the end of the run.", 81 | ) 82 | 83 | 84 | def pytest_configure(config): 85 | html_path = config.getoption("htmlpath") 86 | if html_path: 87 | extra_css = [ 88 | Path(os.path.expandvars(css)).expanduser() 89 | for css in config.getoption("css") 90 | ] 91 | missing_css_files = [] 92 | for css_path in extra_css: 93 | if not css_path.exists(): 94 | missing_css_files.append(str(css_path)) 95 | 96 | if missing_css_files: 97 | os_error = ( 98 | f"Missing CSS file{'s' if len(missing_css_files) > 1 else ''}:" 99 | f" {', '.join(missing_css_files)}" 100 | ) 101 | raise OSError(os_error) 102 | 103 | if not hasattr(config, "workerinput"): 104 | # prevent opening html_path on worker nodes (xdist) 105 | resources_path = Path(__file__).parent.joinpath("resources") 106 | default_css = Path(resources_path, "style.css") 107 | template = _read_template([resources_path]) 108 | processed_css = _process_css(default_css, extra_css) 109 | report_data = ReportData(config) 110 | if config.getoption("self_contained_html"): 111 | html = SelfContainedReport( 112 | html_path, config, report_data, template, processed_css 113 | ) 114 | else: 115 | html = Report(html_path, config, report_data, template, processed_css) 116 | 117 | config.pluginmanager.register(html) 118 | 119 | 120 | def pytest_unconfigure(config): 121 | html = config.pluginmanager.getplugin("html") 122 | if html: 123 | config.pluginmanager.unregister(html) 124 | 125 | 126 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 127 | def pytest_runtest_makereport(item, call): 128 | outcome = yield 129 | report = outcome.get_result() 130 | if report.when == "call": 131 | deprecated_extra = getattr(report, "extra", []) 132 | if deprecated_extra: 133 | warnings.warn( 134 | "The 'report.extra' attribute is deprecated and will be removed in a future release" 135 | ", use 'report.extras' instead.", 136 | DeprecationWarning, 137 | ) 138 | fixture_extras = item.config.stash.get(extras_stash_key, []) 139 | plugin_extras = getattr(report, "extras", []) 140 | report.extras = fixture_extras + plugin_extras + deprecated_extra 141 | -------------------------------------------------------------------------------- /src/pytest_html/report.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | from pathlib import Path 4 | 5 | from pytest_html.basereport import BaseReport 6 | 7 | # This Source Code Form is subject to the terms of the Mozilla Public 8 | # License, v. 2.0. If a copy of the MPL was not distributed with this 9 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | 11 | 12 | class Report(BaseReport): 13 | def __init__(self, report_path, config, report_data, template, css): 14 | super().__init__(report_path, config, report_data, template, css) 15 | self._assets_path = Path(self._report_path.parent, "assets") 16 | self._assets_path.mkdir(parents=True, exist_ok=True) 17 | self._css_path = Path(self._assets_path, "style.css") 18 | 19 | with self._css_path.open("w", encoding="utf-8") as f: 20 | f.write(self._css) 21 | 22 | @property 23 | def css(self): 24 | return Path(self._assets_path.name, "style.css") 25 | 26 | def _data_content(self, content, asset_name, *args, **kwargs): 27 | content = content.encode("utf-8") 28 | return self._write_content(content, asset_name) 29 | 30 | def _media_content(self, content, asset_name, *args, **kwargs): 31 | try: 32 | media_data = base64.b64decode(content.encode("utf-8"), validate=True) 33 | return self._write_content(media_data, asset_name) 34 | except binascii.Error: 35 | # if not base64 content, just return as it's a file or link 36 | return content 37 | 38 | def _write_content(self, content, asset_name): 39 | content_relative_path = Path(self._assets_path, asset_name) 40 | content_relative_path.write_bytes(content) 41 | return str(content_relative_path.relative_to(self._report_path.parent)) 42 | -------------------------------------------------------------------------------- /src/pytest_html/report_data.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import warnings 5 | from collections import defaultdict 6 | from html import escape 7 | 8 | from pytest_html.util import _handle_ansi 9 | 10 | 11 | class ReportData: 12 | def __init__(self, config): 13 | self._config = config 14 | 15 | self._additional_summary = { 16 | "prefix": [], 17 | "summary": [], 18 | "postfix": [], 19 | } 20 | 21 | self._collected_items = 0 22 | self._total_duration = 0 23 | self._running_state = "not_started" 24 | 25 | self._outcomes = { 26 | "failed": {"label": "Failed", "value": 0}, 27 | "passed": {"label": "Passed", "value": 0}, 28 | "skipped": {"label": "Skipped", "value": 0}, 29 | "xfailed": {"label": "Expected failures", "value": 0}, 30 | "xpassed": {"label": "Unexpected passes", "value": 0}, 31 | "error": {"label": "Errors", "value": 0}, 32 | "rerun": {"label": "Reruns", "value": 0}, 33 | } 34 | 35 | self._results_table_header = [ 36 | 'Result', 37 | 'Test', 38 | 'Duration', 39 | "Links", 40 | ] 41 | 42 | self._data = { 43 | "environment": {}, 44 | "tests": defaultdict(list), 45 | } 46 | 47 | collapsed = config.getini("render_collapsed") 48 | if collapsed.lower() == "true": 49 | warnings.warn( 50 | "'render_collapsed = True' is deprecated and support " 51 | "will be removed in the next major release. " 52 | "Please use 'render_collapsed = all' instead.", 53 | DeprecationWarning, 54 | ) 55 | collapsed = "all" 56 | 57 | self._data["renderCollapsed"] = [ 58 | outcome.lower() for outcome in collapsed.split(",") 59 | ] 60 | 61 | initial_sort = config.getini("initial_sort") 62 | self._data["initialSort"] = initial_sort 63 | 64 | @property 65 | def additional_summary(self): 66 | return self._additional_summary 67 | 68 | @additional_summary.setter 69 | def additional_summary(self, value): 70 | self._additional_summary = value 71 | 72 | @property 73 | def collected_items(self): 74 | return self._collected_items 75 | 76 | @collected_items.setter 77 | def collected_items(self, count): 78 | self._collected_items = count 79 | 80 | @property 81 | def config(self): 82 | return self._config 83 | 84 | @property 85 | def data(self): 86 | return self._data 87 | 88 | @property 89 | def outcomes(self): 90 | return self._outcomes 91 | 92 | @outcomes.setter 93 | def outcomes(self, outcome): 94 | self._outcomes[outcome.lower()]["value"] += 1 95 | 96 | @property 97 | def running_state(self): 98 | return self._running_state 99 | 100 | @running_state.setter 101 | def running_state(self, state): 102 | self._running_state = state 103 | 104 | @property 105 | def table_header(self): 106 | return self._results_table_header 107 | 108 | @table_header.setter 109 | def table_header(self, header): 110 | self._results_table_header = header 111 | 112 | @property 113 | def title(self): 114 | return self._data["title"] 115 | 116 | @title.setter 117 | def title(self, title): 118 | self._data["title"] = title 119 | 120 | @property 121 | def total_duration(self): 122 | return self._total_duration 123 | 124 | @total_duration.setter 125 | def total_duration(self, duration): 126 | self._total_duration = duration 127 | 128 | def set_data(self, key, value): 129 | self._data[key] = value 130 | 131 | def add_test(self, test_data, report, outcome, logs): 132 | # regardless of pass or fail we must add teardown logging to "call" 133 | if report.when == "teardown": 134 | self.append_teardown_log(report) 135 | 136 | # passed "setup" and "teardown" are not added to the html 137 | if report.when in ["call", "collect"] or ( 138 | report.when in ["setup", "teardown"] and report.outcome != "passed" 139 | ): 140 | test_data["log"] = _handle_ansi("\n".join(logs)) 141 | self.outcomes = outcome 142 | self._data["tests"][report.nodeid].append(test_data) 143 | 144 | def append_teardown_log(self, report): 145 | log = [] 146 | if self._data["tests"][report.nodeid]: 147 | # Last index is "call" 148 | test = self._data["tests"][report.nodeid][-1] 149 | for section in report.sections: 150 | header, content = map(escape, section) 151 | if "teardown" in header: 152 | log.append(f"{' ' + header + ' ':-^80}\n{content}") 153 | test["log"] += _handle_ansi("\n".join(log)) 154 | -------------------------------------------------------------------------------- /src/pytest_html/resources/index.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | {%- if self_contained %} 7 | 10 | {% else %} 11 | 12 | {%- endif %} 13 | 14 | 15 |

{{ title }}

16 |

Report generated on {{ date }} at {{ time }} by pytest-html 17 | v{{ version }}

18 |
19 |

Environment

20 |
21 |
22 | 23 | 29 | 36 | 65 | 66 |
67 |
68 |

Summary

69 |
70 | {%- for p in additional_summary['prefix'] %} 71 | {{ p|safe }} 72 | {%- endfor %} 73 |
74 |

{{ run_count }}

75 |

(Un)check the boxes to filter the results.

76 |
77 |
78 |
There are still tests running.
Reload this page to get the latest results!
79 |
80 |
81 |
82 |
83 |
84 | {%- for result, values in outcomes.items() %} 85 | 86 | {{ values["value"] }} {{ values["label"] }}{{ "," if result != "rerun" }} 87 | {%- endfor %} 88 |
89 |
90 |  /  91 |
92 |
93 |
94 |
95 | {%- for s in additional_summary['summary'] %} 96 | {{ s|safe }} 97 | {%- endfor %} 98 |
99 |
100 | {%- for p in additional_summary['postfix'] %} 101 | {{ p|safe }} 102 | {%- endfor %} 103 |
104 |
105 | 106 | 107 | 108 | {%- for th in table_head %} 109 | {{ th|safe }} 110 | {%- endfor %} 111 | 112 | 113 |
114 |
115 |
116 | 119 |
120 | 121 | 122 | -------------------------------------------------------------------------------- /src/pytest_html/resources/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Helvetica, Arial, sans-serif; 3 | font-size: 12px; 4 | /* do not increase min-width as some may use split screens */ 5 | min-width: 800px; 6 | color: #999; 7 | } 8 | 9 | h1 { 10 | font-size: 24px; 11 | color: black; 12 | } 13 | 14 | h2 { 15 | font-size: 16px; 16 | color: black; 17 | } 18 | 19 | p { 20 | color: black; 21 | } 22 | 23 | a { 24 | color: #999; 25 | } 26 | 27 | table { 28 | border-collapse: collapse; 29 | } 30 | 31 | /****************************** 32 | * SUMMARY INFORMATION 33 | ******************************/ 34 | #environment td { 35 | padding: 5px; 36 | border: 1px solid #e6e6e6; 37 | vertical-align: top; 38 | } 39 | #environment tr:nth-child(odd) { 40 | background-color: #f6f6f6; 41 | } 42 | #environment ul { 43 | margin: 0; 44 | padding: 0 20px; 45 | } 46 | 47 | /****************************** 48 | * TEST RESULT COLORS 49 | ******************************/ 50 | span.passed, 51 | .passed .col-result { 52 | color: green; 53 | } 54 | 55 | span.skipped, 56 | span.xfailed, 57 | span.rerun, 58 | .skipped .col-result, 59 | .xfailed .col-result, 60 | .rerun .col-result { 61 | color: orange; 62 | } 63 | 64 | span.error, 65 | span.failed, 66 | span.xpassed, 67 | .error .col-result, 68 | .failed .col-result, 69 | .xpassed .col-result { 70 | color: red; 71 | } 72 | 73 | .col-links__extra { 74 | margin-right: 3px; 75 | } 76 | 77 | /****************************** 78 | * RESULTS TABLE 79 | * 80 | * 1. Table Layout 81 | * 2. Extra 82 | * 3. Sorting items 83 | * 84 | ******************************/ 85 | /*------------------ 86 | * 1. Table Layout 87 | *------------------*/ 88 | #results-table { 89 | border: 1px solid #e6e6e6; 90 | color: #999; 91 | font-size: 12px; 92 | width: 100%; 93 | } 94 | #results-table th, 95 | #results-table td { 96 | padding: 5px; 97 | border: 1px solid #e6e6e6; 98 | text-align: left; 99 | } 100 | #results-table th { 101 | font-weight: bold; 102 | } 103 | 104 | /*------------------ 105 | * 2. Extra 106 | *------------------*/ 107 | .logwrapper { 108 | max-height: 230px; 109 | overflow-y: scroll; 110 | background-color: #e6e6e6; 111 | } 112 | .logwrapper.expanded { 113 | max-height: none; 114 | } 115 | .logwrapper.expanded .logexpander:after { 116 | content: "collapse [-]"; 117 | } 118 | .logwrapper .logexpander { 119 | z-index: 1; 120 | position: sticky; 121 | top: 10px; 122 | width: max-content; 123 | border: 1px solid; 124 | border-radius: 3px; 125 | padding: 5px 7px; 126 | margin: 10px 0 10px calc(100% - 80px); 127 | cursor: pointer; 128 | background-color: #e6e6e6; 129 | } 130 | .logwrapper .logexpander:after { 131 | content: "expand [+]"; 132 | } 133 | .logwrapper .logexpander:hover { 134 | color: #000; 135 | border-color: #000; 136 | } 137 | .logwrapper .log { 138 | min-height: 40px; 139 | position: relative; 140 | top: -50px; 141 | height: calc(100% + 50px); 142 | border: 1px solid #e6e6e6; 143 | color: black; 144 | display: block; 145 | font-family: "Courier New", Courier, monospace; 146 | padding: 5px; 147 | padding-right: 80px; 148 | white-space: pre-wrap; 149 | } 150 | 151 | div.media { 152 | border: 1px solid #e6e6e6; 153 | float: right; 154 | height: 240px; 155 | margin: 0 5px; 156 | overflow: hidden; 157 | width: 320px; 158 | } 159 | 160 | .media-container { 161 | display: grid; 162 | grid-template-columns: 25px auto 25px; 163 | align-items: center; 164 | flex: 1 1; 165 | overflow: hidden; 166 | height: 200px; 167 | } 168 | 169 | .media-container--fullscreen { 170 | grid-template-columns: 0px auto 0px; 171 | } 172 | 173 | .media-container__nav--right, 174 | .media-container__nav--left { 175 | text-align: center; 176 | cursor: pointer; 177 | } 178 | 179 | .media-container__viewport { 180 | cursor: pointer; 181 | text-align: center; 182 | height: inherit; 183 | } 184 | .media-container__viewport img, 185 | .media-container__viewport video { 186 | object-fit: cover; 187 | width: 100%; 188 | max-height: 100%; 189 | } 190 | 191 | .media__name, 192 | .media__counter { 193 | display: flex; 194 | flex-direction: row; 195 | justify-content: space-around; 196 | flex: 0 0 25px; 197 | align-items: center; 198 | } 199 | 200 | .collapsible td:not(.col-links) { 201 | cursor: pointer; 202 | } 203 | .collapsible td:not(.col-links):hover::after { 204 | color: #bbb; 205 | font-style: italic; 206 | cursor: pointer; 207 | } 208 | 209 | .col-result { 210 | width: 130px; 211 | } 212 | .col-result:hover::after { 213 | content: " (hide details)"; 214 | } 215 | 216 | .col-result.collapsed:hover::after { 217 | content: " (show details)"; 218 | } 219 | 220 | #environment-header h2:hover::after { 221 | content: " (hide details)"; 222 | color: #bbb; 223 | font-style: italic; 224 | cursor: pointer; 225 | font-size: 12px; 226 | } 227 | 228 | #environment-header.collapsed h2:hover::after { 229 | content: " (show details)"; 230 | color: #bbb; 231 | font-style: italic; 232 | cursor: pointer; 233 | font-size: 12px; 234 | } 235 | 236 | /*------------------ 237 | * 3. Sorting items 238 | *------------------*/ 239 | .sortable { 240 | cursor: pointer; 241 | } 242 | .sortable.desc:after { 243 | content: " "; 244 | position: relative; 245 | left: 5px; 246 | bottom: -12.5px; 247 | border: 10px solid #4caf50; 248 | border-bottom: 0; 249 | border-left-color: transparent; 250 | border-right-color: transparent; 251 | } 252 | .sortable.asc:after { 253 | content: " "; 254 | position: relative; 255 | left: 5px; 256 | bottom: 12.5px; 257 | border: 10px solid #4caf50; 258 | border-top: 0; 259 | border-left-color: transparent; 260 | border-right-color: transparent; 261 | } 262 | 263 | .hidden, .summary__reload__button.hidden { 264 | display: none; 265 | } 266 | 267 | .summary__data { 268 | flex: 0 0 550px; 269 | } 270 | .summary__reload { 271 | flex: 1 1; 272 | display: flex; 273 | justify-content: center; 274 | } 275 | .summary__reload__button { 276 | flex: 0 0 300px; 277 | display: flex; 278 | color: white; 279 | font-weight: bold; 280 | background-color: #4caf50; 281 | text-align: center; 282 | justify-content: center; 283 | align-items: center; 284 | border-radius: 3px; 285 | cursor: pointer; 286 | } 287 | .summary__reload__button:hover { 288 | background-color: #46a049; 289 | } 290 | .summary__spacer { 291 | flex: 0 0 550px; 292 | } 293 | 294 | .controls { 295 | display: flex; 296 | justify-content: space-between; 297 | } 298 | 299 | .filters, 300 | .collapse { 301 | display: flex; 302 | align-items: center; 303 | } 304 | .filters button, 305 | .collapse button { 306 | color: #999; 307 | border: none; 308 | background: none; 309 | cursor: pointer; 310 | text-decoration: underline; 311 | } 312 | .filters button:hover, 313 | .collapse button:hover { 314 | color: #ccc; 315 | } 316 | 317 | .filter__label { 318 | margin-right: 10px; 319 | } 320 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/datamanager.js: -------------------------------------------------------------------------------- 1 | const { getCollapsedCategory, setCollapsedIds } = require('./storage.js') 2 | 3 | class DataManager { 4 | setManager(data) { 5 | const collapsedCategories = [...getCollapsedCategory(data.renderCollapsed)] 6 | const collapsedIds = [] 7 | const tests = Object.values(data.tests).flat().map((test, index) => { 8 | const collapsed = collapsedCategories.includes(test.result.toLowerCase()) 9 | const id = `test_${index}` 10 | if (collapsed) { 11 | collapsedIds.push(id) 12 | } 13 | return { 14 | ...test, 15 | id, 16 | collapsed, 17 | } 18 | }) 19 | const dataBlob = { ...data, tests } 20 | this.data = { ...dataBlob } 21 | this.renderData = { ...dataBlob } 22 | setCollapsedIds(collapsedIds) 23 | } 24 | 25 | get allData() { 26 | return { ...this.data } 27 | } 28 | 29 | resetRender() { 30 | this.renderData = { ...this.data } 31 | } 32 | 33 | setRender(data) { 34 | this.renderData.tests = [...data] 35 | } 36 | 37 | toggleCollapsedItem(id) { 38 | this.renderData.tests = this.renderData.tests.map((test) => 39 | test.id === id ? { ...test, collapsed: !test.collapsed } : test, 40 | ) 41 | } 42 | 43 | set allCollapsed(collapsed) { 44 | this.renderData = { ...this.renderData, tests: [...this.renderData.tests.map((test) => ( 45 | { ...test, collapsed } 46 | ))] } 47 | } 48 | 49 | get testSubset() { 50 | return [...this.renderData.tests] 51 | } 52 | 53 | get environment() { 54 | return this.renderData.environment 55 | } 56 | 57 | get initialSort() { 58 | return this.data.initialSort 59 | } 60 | } 61 | 62 | module.exports = { 63 | manager: new DataManager(), 64 | } 65 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/dom.js: -------------------------------------------------------------------------------- 1 | const mediaViewer = require('./mediaviewer.js') 2 | const templateEnvRow = document.getElementById('template_environment_row') 3 | const templateResult = document.getElementById('template_results-table__tbody') 4 | 5 | function htmlToElements(html) { 6 | const temp = document.createElement('template') 7 | temp.innerHTML = html 8 | return temp.content.childNodes 9 | } 10 | 11 | const find = (selector, elem) => { 12 | if (!elem) { 13 | elem = document 14 | } 15 | return elem.querySelector(selector) 16 | } 17 | 18 | const findAll = (selector, elem) => { 19 | if (!elem) { 20 | elem = document 21 | } 22 | return [...elem.querySelectorAll(selector)] 23 | } 24 | 25 | const dom = { 26 | getStaticRow: (key, value) => { 27 | const envRow = templateEnvRow.content.cloneNode(true) 28 | const isObj = typeof value === 'object' && value !== null 29 | const values = isObj ? Object.keys(value).map((k) => `${k}: ${value[k]}`) : null 30 | 31 | const valuesElement = htmlToElements( 32 | values ? `
    ${values.map((val) => `
  • ${val}
  • `).join('')}
      ` : `
      ${value}
      `)[0] 33 | const td = findAll('td', envRow) 34 | td[0].textContent = key 35 | td[1].appendChild(valuesElement) 36 | 37 | return envRow 38 | }, 39 | getResultTBody: ({ testId, id, log, extras, resultsTableRow, tableHtml, result, collapsed }) => { 40 | const resultBody = templateResult.content.cloneNode(true) 41 | resultBody.querySelector('tbody').classList.add(result.toLowerCase()) 42 | resultBody.querySelector('tbody').id = testId 43 | resultBody.querySelector('.collapsible').dataset.id = id 44 | 45 | resultsTableRow.forEach((html) => { 46 | const t = document.createElement('template') 47 | t.innerHTML = html 48 | resultBody.querySelector('.collapsible').appendChild(t.content) 49 | }) 50 | 51 | if (log) { 52 | // Wrap lines starting with "E" with span.error to color those lines red 53 | const wrappedLog = log.replace(/^E.*$/gm, (match) => `${match}`) 54 | resultBody.querySelector('.log').innerHTML = wrappedLog 55 | } else { 56 | resultBody.querySelector('.log').remove() 57 | } 58 | 59 | if (collapsed) { 60 | resultBody.querySelector('.collapsible > .col-result')?.classList.add('collapsed') 61 | resultBody.querySelector('.extras-row').classList.add('hidden') 62 | } else { 63 | resultBody.querySelector('.collapsible > .col-result')?.classList.remove('collapsed') 64 | } 65 | 66 | const media = [] 67 | extras?.forEach(({ name, format_type, content }) => { 68 | if (['image', 'video'].includes(format_type)) { 69 | media.push({ path: content, name, format_type }) 70 | } 71 | 72 | if (format_type === 'html') { 73 | resultBody.querySelector('.extraHTML').insertAdjacentHTML('beforeend', `
      ${content}
      `) 74 | } 75 | }) 76 | mediaViewer.setup(resultBody, media) 77 | 78 | // Add custom html from the pytest_html_results_table_html hook 79 | tableHtml?.forEach((item) => { 80 | resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item) 81 | }) 82 | 83 | return resultBody 84 | }, 85 | } 86 | 87 | module.exports = { 88 | dom, 89 | htmlToElements, 90 | find, 91 | findAll, 92 | } 93 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/filter.js: -------------------------------------------------------------------------------- 1 | const { manager } = require('./datamanager.js') 2 | const { doSort } = require('./sort.js') 3 | const storageModule = require('./storage.js') 4 | 5 | const getFilteredSubSet = (filter) => 6 | manager.allData.tests.filter(({ result }) => filter.includes(result.toLowerCase())) 7 | 8 | const doInitFilter = () => { 9 | const currentFilter = storageModule.getVisible() 10 | const filteredSubset = getFilteredSubSet(currentFilter) 11 | manager.setRender(filteredSubset) 12 | } 13 | 14 | const doFilter = (type, show) => { 15 | if (show) { 16 | storageModule.showCategory(type) 17 | } else { 18 | storageModule.hideCategory(type) 19 | } 20 | 21 | const currentFilter = storageModule.getVisible() 22 | const filteredSubset = getFilteredSubSet(currentFilter) 23 | manager.setRender(filteredSubset) 24 | 25 | const sortColumn = storageModule.getSort() 26 | doSort(sortColumn, true) 27 | } 28 | 29 | module.exports = { 30 | doFilter, 31 | doInitFilter, 32 | } 33 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/index.js: -------------------------------------------------------------------------------- 1 | const { redraw, bindEvents, renderStatic } = require('./main.js') 2 | const { doInitFilter } = require('./filter.js') 3 | const { doInitSort } = require('./sort.js') 4 | const { manager } = require('./datamanager.js') 5 | const data = JSON.parse(document.getElementById('data-container').dataset.jsonblob) 6 | 7 | function init() { 8 | manager.setManager(data) 9 | doInitFilter() 10 | doInitSort() 11 | renderStatic() 12 | redraw() 13 | bindEvents() 14 | } 15 | 16 | init() 17 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/main.js: -------------------------------------------------------------------------------- 1 | const { dom, find, findAll } = require('./dom.js') 2 | const { manager } = require('./datamanager.js') 3 | const { doSort } = require('./sort.js') 4 | const { doFilter } = require('./filter.js') 5 | const { 6 | getVisible, 7 | getCollapsedIds, 8 | setCollapsedIds, 9 | getSort, 10 | getSortDirection, 11 | possibleFilters, 12 | } = require('./storage.js') 13 | 14 | const removeChildren = (node) => { 15 | while (node.firstChild) { 16 | node.removeChild(node.firstChild) 17 | } 18 | } 19 | 20 | const renderStatic = () => { 21 | const renderEnvironmentTable = () => { 22 | const environment = manager.environment 23 | const rows = Object.keys(environment).map((key) => dom.getStaticRow(key, environment[key])) 24 | const table = document.getElementById('environment') 25 | removeChildren(table) 26 | rows.forEach((row) => table.appendChild(row)) 27 | } 28 | renderEnvironmentTable() 29 | } 30 | 31 | const addItemToggleListener = (elem) => { 32 | elem.addEventListener('click', ({ target }) => { 33 | const id = target.parentElement.dataset.id 34 | manager.toggleCollapsedItem(id) 35 | 36 | const collapsedIds = getCollapsedIds() 37 | if (collapsedIds.includes(id)) { 38 | const updated = collapsedIds.filter((item) => item !== id) 39 | setCollapsedIds(updated) 40 | } else { 41 | collapsedIds.push(id) 42 | setCollapsedIds(collapsedIds) 43 | } 44 | redraw() 45 | }) 46 | } 47 | 48 | const renderContent = (tests) => { 49 | const sortAttr = getSort(manager.initialSort) 50 | const sortAsc = JSON.parse(getSortDirection()) 51 | const rows = tests.map(dom.getResultTBody) 52 | const table = document.getElementById('results-table') 53 | const tableHeader = document.getElementById('results-table-head') 54 | 55 | const newTable = document.createElement('table') 56 | newTable.id = 'results-table' 57 | 58 | // remove all sorting classes and set the relevant 59 | findAll('.sortable', tableHeader).forEach((elem) => elem.classList.remove('asc', 'desc')) 60 | tableHeader.querySelector(`.sortable[data-column-type="${sortAttr}"]`)?.classList.add(sortAsc ? 'desc' : 'asc') 61 | newTable.appendChild(tableHeader) 62 | 63 | if (!rows.length) { 64 | const emptyTable = document.getElementById('template_results-table__body--empty').content.cloneNode(true) 65 | newTable.appendChild(emptyTable) 66 | } else { 67 | rows.forEach((row) => { 68 | if (!!row) { 69 | findAll('.collapsible td:not(.col-links', row).forEach(addItemToggleListener) 70 | find('.logexpander', row).addEventListener('click', 71 | (evt) => evt.target.parentNode.classList.toggle('expanded'), 72 | ) 73 | newTable.appendChild(row) 74 | } 75 | }) 76 | } 77 | 78 | table.replaceWith(newTable) 79 | } 80 | 81 | const renderDerived = () => { 82 | const currentFilter = getVisible() 83 | possibleFilters.forEach((result) => { 84 | const input = document.querySelector(`input[data-test-result="${result}"]`) 85 | input.checked = currentFilter.includes(result) 86 | }) 87 | } 88 | 89 | const bindEvents = () => { 90 | const filterColumn = (evt) => { 91 | const { target: element } = evt 92 | const { testResult } = element.dataset 93 | 94 | doFilter(testResult, element.checked) 95 | const collapsedIds = getCollapsedIds() 96 | const updated = manager.renderData.tests.map((test) => { 97 | return { 98 | ...test, 99 | collapsed: collapsedIds.includes(test.id), 100 | } 101 | }) 102 | manager.setRender(updated) 103 | redraw() 104 | } 105 | 106 | const header = document.getElementById('environment-header') 107 | header.addEventListener('click', () => { 108 | const table = document.getElementById('environment') 109 | table.classList.toggle('hidden') 110 | header.classList.toggle('collapsed') 111 | }) 112 | 113 | findAll('input[name="filter_checkbox"]').forEach((elem) => { 114 | elem.addEventListener('click', filterColumn) 115 | }) 116 | 117 | findAll('.sortable').forEach((elem) => { 118 | elem.addEventListener('click', (evt) => { 119 | const { target: element } = evt 120 | const { columnType } = element.dataset 121 | doSort(columnType) 122 | redraw() 123 | }) 124 | }) 125 | 126 | document.getElementById('show_all_details').addEventListener('click', () => { 127 | manager.allCollapsed = false 128 | setCollapsedIds([]) 129 | redraw() 130 | }) 131 | document.getElementById('hide_all_details').addEventListener('click', () => { 132 | manager.allCollapsed = true 133 | const allIds = manager.renderData.tests.map((test) => test.id) 134 | setCollapsedIds(allIds) 135 | redraw() 136 | }) 137 | } 138 | 139 | const redraw = () => { 140 | const { testSubset } = manager 141 | 142 | renderContent(testSubset) 143 | renderDerived() 144 | } 145 | 146 | module.exports = { 147 | redraw, 148 | bindEvents, 149 | renderStatic, 150 | } 151 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/mediaviewer.js: -------------------------------------------------------------------------------- 1 | class MediaViewer { 2 | constructor(assets) { 3 | this.assets = assets 4 | this.index = 0 5 | } 6 | 7 | nextActive() { 8 | this.index = this.index === this.assets.length - 1 ? 0 : this.index + 1 9 | return [this.activeFile, this.index] 10 | } 11 | 12 | prevActive() { 13 | this.index = this.index === 0 ? this.assets.length - 1 : this.index -1 14 | return [this.activeFile, this.index] 15 | } 16 | 17 | get currentIndex() { 18 | return this.index 19 | } 20 | 21 | get activeFile() { 22 | return this.assets[this.index] 23 | } 24 | } 25 | 26 | 27 | const setup = (resultBody, assets) => { 28 | if (!assets.length) { 29 | resultBody.querySelector('.media').classList.add('hidden') 30 | return 31 | } 32 | 33 | const mediaViewer = new MediaViewer(assets) 34 | const container = resultBody.querySelector('.media-container') 35 | const leftArrow = resultBody.querySelector('.media-container__nav--left') 36 | const rightArrow = resultBody.querySelector('.media-container__nav--right') 37 | const mediaName = resultBody.querySelector('.media__name') 38 | const counter = resultBody.querySelector('.media__counter') 39 | const imageEl = resultBody.querySelector('img') 40 | const sourceEl = resultBody.querySelector('source') 41 | const videoEl = resultBody.querySelector('video') 42 | 43 | const setImg = (media, index) => { 44 | if (media?.format_type === 'image') { 45 | imageEl.src = media.path 46 | 47 | imageEl.classList.remove('hidden') 48 | videoEl.classList.add('hidden') 49 | } else if (media?.format_type === 'video') { 50 | sourceEl.src = media.path 51 | 52 | videoEl.classList.remove('hidden') 53 | imageEl.classList.add('hidden') 54 | } 55 | 56 | mediaName.innerText = media?.name 57 | counter.innerText = `${index + 1} / ${assets.length}` 58 | } 59 | setImg(mediaViewer.activeFile, mediaViewer.currentIndex) 60 | 61 | const moveLeft = () => { 62 | const [media, index] = mediaViewer.prevActive() 63 | setImg(media, index) 64 | } 65 | const doRight = () => { 66 | const [media, index] = mediaViewer.nextActive() 67 | setImg(media, index) 68 | } 69 | const openImg = () => { 70 | window.open(mediaViewer.activeFile.path, '_blank') 71 | } 72 | if (assets.length === 1) { 73 | container.classList.add('media-container--fullscreen') 74 | } else { 75 | leftArrow.addEventListener('click', moveLeft) 76 | rightArrow.addEventListener('click', doRight) 77 | } 78 | imageEl.addEventListener('click', openImg) 79 | } 80 | 81 | module.exports = { 82 | setup, 83 | } 84 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/sort.js: -------------------------------------------------------------------------------- 1 | const { manager } = require('./datamanager.js') 2 | const storageModule = require('./storage.js') 3 | 4 | const genericSort = (list, key, ascending, customOrder) => { 5 | let sorted 6 | if (customOrder) { 7 | sorted = list.sort((a, b) => { 8 | const aValue = a.result.toLowerCase() 9 | const bValue = b.result.toLowerCase() 10 | 11 | const aIndex = customOrder.findIndex((item) => item.toLowerCase() === aValue) 12 | const bIndex = customOrder.findIndex((item) => item.toLowerCase() === bValue) 13 | 14 | // Compare the indices to determine the sort order 15 | return aIndex - bIndex 16 | }) 17 | } else { 18 | sorted = list.sort((a, b) => a[key] === b[key] ? 0 : a[key] > b[key] ? 1 : -1) 19 | } 20 | 21 | if (ascending) { 22 | sorted.reverse() 23 | } 24 | return sorted 25 | } 26 | 27 | const durationSort = (list, ascending) => { 28 | const parseDuration = (duration) => { 29 | if (duration.includes(':')) { 30 | // If it's in the format "HH:mm:ss" 31 | const [hours, minutes, seconds] = duration.split(':').map(Number) 32 | return (hours * 3600 + minutes * 60 + seconds) * 1000 33 | } else { 34 | // If it's in the format "nnn ms" 35 | return parseInt(duration) 36 | } 37 | } 38 | const sorted = list.sort((a, b) => parseDuration(a['duration']) - parseDuration(b['duration'])) 39 | if (ascending) { 40 | sorted.reverse() 41 | } 42 | return sorted 43 | } 44 | 45 | const doInitSort = () => { 46 | const type = storageModule.getSort(manager.initialSort) 47 | const ascending = storageModule.getSortDirection() 48 | const list = manager.testSubset 49 | const initialOrder = ['Error', 'Failed', 'Rerun', 'XFailed', 'XPassed', 'Skipped', 'Passed'] 50 | 51 | storageModule.setSort(type) 52 | storageModule.setSortDirection(ascending) 53 | 54 | if (type?.toLowerCase() === 'original') { 55 | manager.setRender(list) 56 | } else { 57 | let sortedList 58 | switch (type) { 59 | case 'duration': 60 | sortedList = durationSort(list, ascending) 61 | break 62 | case 'result': 63 | sortedList = genericSort(list, type, ascending, initialOrder) 64 | break 65 | default: 66 | sortedList = genericSort(list, type, ascending) 67 | break 68 | } 69 | manager.setRender(sortedList) 70 | } 71 | } 72 | 73 | const doSort = (type, skipDirection) => { 74 | const newSortType = storageModule.getSort(manager.initialSort) !== type 75 | const currentAsc = storageModule.getSortDirection() 76 | let ascending 77 | if (skipDirection) { 78 | ascending = currentAsc 79 | } else { 80 | ascending = newSortType ? false : !currentAsc 81 | } 82 | storageModule.setSort(type) 83 | storageModule.setSortDirection(ascending) 84 | 85 | const list = manager.testSubset 86 | const sortedList = type === 'duration' ? durationSort(list, ascending) : genericSort(list, type, ascending) 87 | manager.setRender(sortedList) 88 | } 89 | 90 | module.exports = { 91 | doInitSort, 92 | doSort, 93 | } 94 | -------------------------------------------------------------------------------- /src/pytest_html/scripts/storage.js: -------------------------------------------------------------------------------- 1 | const possibleFilters = [ 2 | 'passed', 3 | 'skipped', 4 | 'failed', 5 | 'error', 6 | 'xfailed', 7 | 'xpassed', 8 | 'rerun', 9 | ] 10 | 11 | const getVisible = () => { 12 | const url = new URL(window.location.href) 13 | const settings = new URLSearchParams(url.search).get('visible') 14 | const lower = (item) => { 15 | const lowerItem = item.toLowerCase() 16 | if (possibleFilters.includes(lowerItem)) { 17 | return lowerItem 18 | } 19 | return null 20 | } 21 | return settings === null ? 22 | possibleFilters : 23 | [...new Set(settings?.split(',').map(lower).filter((item) => item))] 24 | } 25 | 26 | const hideCategory = (categoryToHide) => { 27 | const url = new URL(window.location.href) 28 | const visibleParams = new URLSearchParams(url.search).get('visible') 29 | const currentVisible = visibleParams ? visibleParams.split(',') : [...possibleFilters] 30 | const settings = [...new Set(currentVisible)].filter((f) => f !== categoryToHide).join(',') 31 | 32 | url.searchParams.set('visible', settings) 33 | window.history.pushState({}, null, unescape(url.href)) 34 | } 35 | 36 | const showCategory = (categoryToShow) => { 37 | if (typeof window === 'undefined') { 38 | return 39 | } 40 | const url = new URL(window.location.href) 41 | const currentVisible = new URLSearchParams(url.search).get('visible')?.split(',').filter(Boolean) || 42 | [...possibleFilters] 43 | const settings = [...new Set([categoryToShow, ...currentVisible])] 44 | const noFilter = possibleFilters.length === settings.length || !settings.length 45 | 46 | noFilter ? url.searchParams.delete('visible') : url.searchParams.set('visible', settings.join(',')) 47 | window.history.pushState({}, null, unescape(url.href)) 48 | } 49 | 50 | const getSort = (initialSort) => { 51 | const url = new URL(window.location.href) 52 | let sort = new URLSearchParams(url.search).get('sort') 53 | if (!sort) { 54 | sort = initialSort || 'result' 55 | } 56 | return sort 57 | } 58 | 59 | const setSort = (type) => { 60 | const url = new URL(window.location.href) 61 | url.searchParams.set('sort', type) 62 | window.history.pushState({}, null, unescape(url.href)) 63 | } 64 | 65 | const getCollapsedCategory = (renderCollapsed) => { 66 | let categories 67 | if (typeof window !== 'undefined') { 68 | const url = new URL(window.location.href) 69 | const collapsedItems = new URLSearchParams(url.search).get('collapsed') 70 | switch (true) { 71 | case !renderCollapsed && collapsedItems === null: 72 | categories = ['passed'] 73 | break 74 | case collapsedItems?.length === 0 || /^["']{2}$/.test(collapsedItems): 75 | categories = [] 76 | break 77 | case /^all$/.test(collapsedItems) || collapsedItems === null && /^all$/.test(renderCollapsed): 78 | categories = [...possibleFilters] 79 | break 80 | default: 81 | categories = collapsedItems?.split(',').map((item) => item.toLowerCase()) || renderCollapsed 82 | break 83 | } 84 | } else { 85 | categories = [] 86 | } 87 | return categories 88 | } 89 | 90 | const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc')) || false 91 | const setSortDirection = (ascending) => sessionStorage.setItem('sortAsc', ascending) 92 | 93 | const getCollapsedIds = () => JSON.parse(sessionStorage.getItem('collapsedIds')) || [] 94 | const setCollapsedIds = (list) => sessionStorage.setItem('collapsedIds', JSON.stringify(list)) 95 | 96 | module.exports = { 97 | getVisible, 98 | hideCategory, 99 | showCategory, 100 | getCollapsedIds, 101 | setCollapsedIds, 102 | getSort, 103 | setSort, 104 | getSortDirection, 105 | setSortDirection, 106 | getCollapsedCategory, 107 | possibleFilters, 108 | } 109 | -------------------------------------------------------------------------------- /src/pytest_html/selfcontained_report.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import base64 5 | import binascii 6 | import warnings 7 | 8 | from pytest_html.basereport import BaseReport 9 | 10 | 11 | class SelfContainedReport(BaseReport): 12 | def __init__(self, report_path, config, report_data, template, css): 13 | super().__init__(report_path, config, report_data, template, css) 14 | 15 | @property 16 | def css(self): 17 | return self._css 18 | 19 | def _data_content(self, content, mime_type, *args, **kwargs): 20 | charset = "utf-8" 21 | data = base64.b64encode(content.encode(charset)).decode(charset) 22 | return f"data:{mime_type};charset={charset};base64,{data}" 23 | 24 | def _media_content(self, content, mime_type, *args, **kwargs): 25 | try: 26 | # test if content is base64 27 | base64.b64decode(content.encode("utf-8"), validate=True) 28 | return f"data:{mime_type};base64,{content}" 29 | except binascii.Error: 30 | # if not base64, issue warning and just return as it's a file or link 31 | warnings.warn( 32 | "Self-contained HTML report " 33 | "includes link to external " 34 | f"resource: {content}" 35 | ) 36 | return content 37 | 38 | def _generate_report(self, *args, **kwargs): 39 | super()._generate_report(self_contained=True) 40 | -------------------------------------------------------------------------------- /src/pytest_html/util.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | from functools import partial 5 | 6 | from jinja2 import Environment 7 | from jinja2 import FileSystemLoader 8 | from jinja2 import select_autoescape 9 | 10 | try: 11 | from ansi2html import Ansi2HTMLConverter, style 12 | 13 | converter = Ansi2HTMLConverter(inline=False, escaped=False) 14 | _handle_ansi = partial(converter.convert, full=False) 15 | _ansi_styles = style.get_styles() 16 | except ImportError: 17 | from _pytest.logging import _remove_ansi_escape_sequences 18 | 19 | _handle_ansi = _remove_ansi_escape_sequences 20 | _ansi_styles = [] 21 | 22 | 23 | def _read_template(search_paths, template_name="index.jinja2"): 24 | env = Environment( 25 | loader=FileSystemLoader(search_paths), 26 | autoescape=select_autoescape( 27 | enabled_extensions=("jinja2",), 28 | ), 29 | ) 30 | return env.get_template(template_name) 31 | 32 | 33 | def _process_css(default_css, extra_css): 34 | with open(default_css, encoding="utf-8") as f: 35 | css = f.read() 36 | 37 | # Add user-provided CSS 38 | for path in extra_css: 39 | css += "\n/******************************" 40 | css += "\n * CUSTOM CSS" 41 | css += f"\n * {path}" 42 | css += "\n ******************************/\n\n" 43 | with open(path, encoding="utf-8") as f: 44 | css += f.read() 45 | 46 | # ANSI support 47 | if _ansi_styles: 48 | ansi_css = [ 49 | "\n/******************************", 50 | " * ANSI2HTML STYLES", 51 | " ******************************/\n", 52 | ] 53 | ansi_css.extend([str(r) for r in _ansi_styles]) 54 | css += "\n".join(ansi_css) 55 | 56 | return css 57 | -------------------------------------------------------------------------------- /start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $(uname) == "Darwin" ]]; then 4 | volume="/private/var/folders:/reports/private/var/folders" 5 | else 6 | volume="${TMPDIR:-/tmp}:/reports${TMPDIR:-/tmp}" 7 | fi 8 | 9 | if [[ "${1}" == "down" ]]; then 10 | docker compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) down 11 | else 12 | docker compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) up -d 13 | fi 14 | -------------------------------------------------------------------------------- /testing/test_e2e.py: -------------------------------------------------------------------------------- 1 | # write a test that sorts the table and asserts the order. 2 | # sort default columns and custom sortable column 3 | import os 4 | import urllib.parse 5 | 6 | import pytest 7 | import selenium.webdriver.support.expected_conditions as ec 8 | from assertpy import assert_that 9 | from selenium import webdriver 10 | from selenium.webdriver.common.by import By 11 | from selenium.webdriver.support.wait import WebDriverWait 12 | 13 | pytest_plugins = ("pytester",) 14 | 15 | 16 | @pytest.fixture 17 | def driver(pytester): 18 | chrome_options = webdriver.ChromeOptions() 19 | if os.environ.get("CI", False): 20 | chrome_options.add_argument("--headless") 21 | chrome_options.add_argument("--window-size=1920x1080") 22 | driver = webdriver.Remote( 23 | command_executor="http://127.0.0.1:4444", options=chrome_options 24 | ) 25 | 26 | yield driver 27 | driver.quit() 28 | 29 | 30 | @pytest.fixture 31 | def path(pytester): 32 | def func(path="report.html", cmd_flags=None): 33 | cmd_flags = cmd_flags or [] 34 | 35 | path = pytester.path.joinpath(path) 36 | pytester.runpytest("--html", path, *cmd_flags) 37 | 38 | # Begin workaround 39 | # See: https://github.com/pytest-dev/pytest/issues/10738 40 | path.chmod(0o755) 41 | for parent in path.parents: 42 | try: 43 | os.chmod(parent, 0o755) 44 | except PermissionError: 45 | continue 46 | # End workaround 47 | 48 | return path 49 | 50 | return func 51 | 52 | 53 | def _encode_query_params(params): 54 | return urllib.parse.urlencode(params) 55 | 56 | 57 | def _parse_result_table(driver): 58 | table = driver.find_element(By.ID, "results-table") 59 | headers = table.find_elements(By.CSS_SELECTOR, "thead th") 60 | rows = table.find_elements(By.CSS_SELECTOR, "tbody tr.collapsible") 61 | table_data = [] 62 | for row in rows: 63 | data_dict = {} 64 | 65 | cells = row.find_elements(By.TAG_NAME, "td") 66 | for header, cell in zip(headers, cells): 67 | data_dict[header.text.lower()] = cell.text 68 | 69 | table_data.append(data_dict) 70 | 71 | return table_data 72 | 73 | 74 | def test_visible(pytester, path, driver): 75 | pytester.makepyfile( 76 | """ 77 | def test_pass_one(): pass 78 | def test_pass_two(): pass 79 | """ 80 | ) 81 | 82 | driver.get(f"file:///reports{path()}") 83 | WebDriverWait(driver, 5).until( 84 | ec.visibility_of_all_elements_located((By.CSS_SELECTOR, "#results-table")) 85 | ) 86 | result = driver.find_elements(By.CSS_SELECTOR, "tr.collapsible") 87 | assert_that(result).is_length(2) 88 | 89 | query_params = _encode_query_params({"visible": ""}) 90 | driver.get(f"file:///reports{path()}?{query_params}") 91 | WebDriverWait(driver, 5).until( 92 | ec.visibility_of_all_elements_located((By.CSS_SELECTOR, "#results-table")) 93 | ) 94 | result = driver.find_elements(By.CSS_SELECTOR, "tr.collapsible") 95 | assert_that(result).is_length(0) 96 | 97 | 98 | def test_custom_sorting(pytester, path, driver): 99 | pytester.makeconftest( 100 | """ 101 | def pytest_html_results_table_header(cells): 102 | cells.append( 103 | 'Alpha' 104 | ) 105 | 106 | def pytest_html_results_table_row(report, cells): 107 | data = report.nodeid.split("_")[-1] 108 | cells.append(f'{data}') 109 | """ 110 | ) 111 | pytester.makepyfile( 112 | """ 113 | def test_AAA(): pass 114 | def test_BBB(): pass 115 | """ 116 | ) 117 | query_params = _encode_query_params({"sort": "alpha"}) 118 | driver.get(f"file:///reports{path()}?{query_params}") 119 | WebDriverWait(driver, 5).until( 120 | ec.visibility_of_all_elements_located((By.CSS_SELECTOR, "#results-table")) 121 | ) 122 | 123 | rows = _parse_result_table(driver) 124 | assert_that(rows).is_length(2) 125 | assert_that(rows[0]["test"]).contains("AAA") 126 | assert_that(rows[0]["alpha"]).is_equal_to("AAA") 127 | assert_that(rows[1]["test"]).contains("BBB") 128 | assert_that(rows[1]["alpha"]).is_equal_to("BBB") 129 | 130 | driver.find_element(By.CSS_SELECTOR, "th[data-column-type='alpha']").click() 131 | # we might need some wait here to ensure sorting happened 132 | rows = _parse_result_table(driver) 133 | assert_that(rows).is_length(2) 134 | assert_that(rows[0]["test"]).contains("BBB") 135 | assert_that(rows[0]["alpha"]).is_equal_to("BBB") 136 | assert_that(rows[1]["test"]).contains("AAA") 137 | assert_that(rows[1]["alpha"]).is_equal_to("AAA") 138 | -------------------------------------------------------------------------------- /testing/test_integration.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import importlib.resources 3 | import json 4 | import os 5 | import random 6 | import re 7 | import urllib.parse 8 | from base64 import b64encode 9 | from pathlib import Path 10 | 11 | import pytest 12 | from assertpy import assert_that 13 | from bs4 import BeautifulSoup 14 | from selenium import webdriver 15 | 16 | pytest_plugins = ("pytester",) 17 | 18 | OUTCOMES = { 19 | "passed": "Passed", 20 | "skipped": "Skipped", 21 | "failed": "Failed", 22 | "error": "Errors", 23 | "xfailed": "Expected failures", 24 | "xpassed": "Unexpected passes", 25 | "rerun": "Reruns", 26 | } 27 | 28 | 29 | def run(pytester, path="report.html", cmd_flags=None, query_params=None): 30 | cmd_flags = cmd_flags or [] 31 | query_params = urllib.parse.urlencode(query_params) if query_params else {} 32 | 33 | path = pytester.path.joinpath(path) 34 | pytester.runpytest("--html", path, *cmd_flags) 35 | 36 | chrome_options = webdriver.ChromeOptions() 37 | if os.environ.get("CI", False): 38 | chrome_options.add_argument("--headless") 39 | chrome_options.add_argument("--window-size=1920x1080") 40 | driver = webdriver.Remote( 41 | command_executor="http://127.0.0.1:4444", options=chrome_options 42 | ) 43 | try: 44 | # Begin workaround 45 | # See: https://github.com/pytest-dev/pytest/issues/10738 46 | path.chmod(0o755) 47 | for parent in path.parents: 48 | try: 49 | os.chmod(parent, 0o755) 50 | except PermissionError: 51 | continue 52 | # End workaround 53 | 54 | driver.get(f"file:///reports{path}?{query_params}") 55 | soup = BeautifulSoup(driver.page_source, "html.parser") 56 | 57 | # remove all templates as they bork the BS parsing 58 | for template in soup("template"): 59 | template.decompose() 60 | 61 | return soup 62 | finally: 63 | driver.quit() 64 | 65 | 66 | def assert_results( 67 | page, 68 | passed=0, 69 | skipped=0, 70 | failed=0, 71 | error=0, 72 | xfailed=0, 73 | xpassed=0, 74 | rerun=0, 75 | total_tests=None, 76 | ): 77 | args = locals() 78 | number_of_tests = 0 79 | for outcome, number in args.items(): 80 | if outcome == "total_tests": 81 | continue 82 | if isinstance(number, int): 83 | number_of_tests += number 84 | result = get_text(page, f"span[class={outcome}]") 85 | assert_that(result).matches(rf"{number} {OUTCOMES[outcome]}") 86 | 87 | 88 | def get_element(page, selector): 89 | return page.select_one(selector) 90 | 91 | 92 | def get_text(page, selector): 93 | return get_element(page, selector).string 94 | 95 | 96 | def is_collapsed(page, test_name): 97 | return get_element(page, f"tbody[id$='{test_name}'] .collapsed") 98 | 99 | 100 | def get_log(page, test_id=None): 101 | # TODO(jim) move to get_text (use .contents) 102 | if test_id: 103 | log = get_element(page, f"tbody[id$='{test_id}'] div[class='log']") 104 | else: 105 | log = get_element(page, "div[class='log']") 106 | all_text = "" 107 | for text in log.strings: 108 | all_text += text 109 | 110 | return all_text 111 | 112 | 113 | def file_content(): 114 | try: 115 | return ( 116 | importlib.resources.files("pytest_html") 117 | .joinpath("resources", "style.css") 118 | .read_bytes() 119 | .decode("utf-8") 120 | .strip() 121 | ) 122 | except AttributeError: 123 | # Needed for python < 3.9 124 | import pkg_resources 125 | 126 | return pkg_resources.resource_string( 127 | "pytest_html", os.path.join("resources", "style.css") 128 | ).decode("utf-8") 129 | 130 | 131 | class TestHTML: 132 | @pytest.mark.parametrize( 133 | "pause, expectation", 134 | [ 135 | (0.4, 400), 136 | (1, r"^((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$)"), 137 | ], 138 | ) 139 | def test_durations(self, pytester, pause, expectation): 140 | pytester.makepyfile( 141 | f""" 142 | import time 143 | def test_sleep(): 144 | time.sleep({pause}) 145 | """ 146 | ) 147 | page = run(pytester) 148 | duration = get_text(page, "#results-table td[class='col-duration']") 149 | total_duration = get_text(page, "p[class='run-count']") 150 | if pause < 1: 151 | assert_that(int(duration.replace("ms", ""))).is_between( 152 | expectation, expectation * 2 153 | ) 154 | assert_that(total_duration).matches(r"\d+\s+ms") 155 | else: 156 | assert_that(duration).matches(expectation) 157 | assert_that(total_duration).matches(r"\d{2}:\d{2}:\d{2}") 158 | 159 | def test_duration_format_hook(self, pytester): 160 | pytester.makeconftest( 161 | """ 162 | def pytest_html_duration_format(duration): 163 | return str(round(duration * 1000)) + " seconds" 164 | """ 165 | ) 166 | 167 | pytester.makepyfile("def test_pass(): pass") 168 | page = run(pytester) 169 | assert_results(page, passed=1) 170 | 171 | duration = get_text(page, "#results-table td[class='col-duration']") 172 | assert_that(duration).contains("seconds") 173 | 174 | def test_total_number_of_tests_zero(self, pytester): 175 | page = run(pytester) 176 | assert_results(page) 177 | 178 | total = get_text(page, "p[class='run-count']") 179 | assert_that(total).matches(r"0 test(?!s)") 180 | 181 | def test_total_number_of_tests_singular(self, pytester): 182 | pytester.makepyfile("def test_pass(): pass") 183 | page = run(pytester) 184 | assert_results(page, passed=1) 185 | 186 | total = get_text(page, "p[class='run-count']") 187 | assert_that(total).matches(r"1 test(?!s)") 188 | 189 | def test_total_number_of_tests_plural(self, pytester): 190 | pytester.makepyfile( 191 | """ 192 | def test_pass_one(): pass 193 | def test_pass_two(): pass 194 | """ 195 | ) 196 | page = run(pytester) 197 | assert_results(page, passed=2) 198 | 199 | total = get_text(page, "p[class='run-count']") 200 | assert_that(total).matches(r"2 tests(?!\S)") 201 | 202 | def test_pass(self, pytester): 203 | pytester.makepyfile("def test_pass(): pass") 204 | page = run(pytester) 205 | assert_results(page, passed=1) 206 | 207 | def test_skip(self, pytester): 208 | reason = str(random.random()) 209 | pytester.makepyfile( 210 | f""" 211 | import pytest 212 | def test_skip(): 213 | pytest.skip("{reason}") 214 | """ 215 | ) 216 | page = run(pytester) 217 | assert_results(page, skipped=1, total_tests=0) 218 | 219 | log = get_text(page, "div[class='log']") 220 | assert_that(log).contains(reason) 221 | 222 | def test_skip_function_marker(self, pytester): 223 | reason = str(random.random()) 224 | pytester.makepyfile( 225 | f""" 226 | import pytest 227 | @pytest.mark.skip(reason="{reason}") 228 | def test_skip(): 229 | assert True 230 | """ 231 | ) 232 | page = run(pytester) 233 | assert_results(page, skipped=1, total_tests=0) 234 | 235 | log = get_text(page, "div[class='log']") 236 | assert_that(log).contains(reason) 237 | 238 | def test_skip_class_marker(self, pytester): 239 | reason = str(random.random()) 240 | pytester.makepyfile( 241 | f""" 242 | import pytest 243 | @pytest.mark.skip(reason="{reason}") 244 | class TestSkip: 245 | def test_skip(): 246 | assert True 247 | """ 248 | ) 249 | page = run(pytester) 250 | assert_results(page, skipped=1, total_tests=0) 251 | 252 | log = get_text(page, "div[class='log']") 253 | assert_that(log).contains(reason) 254 | 255 | def test_fail(self, pytester): 256 | pytester.makepyfile("def test_fail(): assert False") 257 | page = run(pytester) 258 | assert_results(page, failed=1) 259 | assert_that(get_log(page)).contains("AssertionError") 260 | assert_that(get_text(page, "div[class='log'] span.error")).matches( 261 | r"^E\s+assert False$" 262 | ) 263 | 264 | def test_xfail(self, pytester): 265 | reason = str(random.random()) 266 | pytester.makepyfile( 267 | f""" 268 | import pytest 269 | def test_xfail(): 270 | pytest.xfail("{reason}") 271 | """ 272 | ) 273 | page = run(pytester) 274 | assert_results(page, xfailed=1) 275 | assert_that(get_log(page)).contains(reason) 276 | 277 | def test_xfail_function_marker(self, pytester): 278 | reason = str(random.random()) 279 | pytester.makepyfile( 280 | f""" 281 | import pytest 282 | @pytest.mark.xfail(reason="{reason}") 283 | def test_xfail(): 284 | assert False 285 | """ 286 | ) 287 | page = run(pytester) 288 | assert_results(page, xfailed=1) 289 | assert_that(get_log(page)).contains(reason) 290 | 291 | def test_xfail_class_marker(self, pytester): 292 | pytester.makepyfile( 293 | """ 294 | import pytest 295 | @pytest.mark.xfail(reason="broken") 296 | class TestXFail: 297 | def test_xfail(self): 298 | assert False 299 | """ 300 | ) 301 | page = run(pytester) 302 | assert_results(page, xfailed=1) 303 | 304 | def test_xpass(self, pytester): 305 | pytester.makepyfile( 306 | """ 307 | import pytest 308 | @pytest.mark.xfail() 309 | def test_xpass(): 310 | assert True 311 | """ 312 | ) 313 | page = run(pytester) 314 | assert_results(page, xpassed=1) 315 | 316 | def test_xpass_class_marker(self, pytester): 317 | pytester.makepyfile( 318 | """ 319 | import pytest 320 | @pytest.mark.xfail() 321 | class TestXPass: 322 | def test_xpass(self): 323 | assert True 324 | """ 325 | ) 326 | page = run(pytester) 327 | assert_results(page, xpassed=1) 328 | 329 | def test_rerun(self, pytester): 330 | pytester.makepyfile( 331 | """ 332 | import pytest 333 | import time 334 | 335 | @pytest.mark.flaky(reruns=2) 336 | def test_example(): 337 | time.sleep(0.2) 338 | assert False 339 | """ 340 | ) 341 | 342 | page = run(pytester) 343 | assert_results(page, failed=1, rerun=2, total_tests=1) 344 | 345 | def test_conditional_xfails(self, pytester): 346 | pytester.makepyfile( 347 | """ 348 | import pytest 349 | @pytest.mark.xfail(False, reason='reason') 350 | def test_fail(): assert False 351 | @pytest.mark.xfail(False, reason='reason') 352 | def test_pass(): pass 353 | @pytest.mark.xfail(True, reason='reason') 354 | def test_xfail(): assert False 355 | @pytest.mark.xfail(True, reason='reason') 356 | def test_xpass(): pass 357 | """ 358 | ) 359 | page = run(pytester) 360 | assert_results(page, passed=1, failed=1, xfailed=1, xpassed=1) 361 | 362 | def test_setup_error(self, pytester): 363 | pytester.makepyfile( 364 | """ 365 | import pytest 366 | @pytest.fixture 367 | def arg(request): 368 | raise ValueError() 369 | def test_function(arg): 370 | pass 371 | """ 372 | ) 373 | page = run(pytester) 374 | assert_results(page, error=1, total_tests=0) 375 | 376 | col_name = get_text(page, "td[class='col-testId']") 377 | assert_that(col_name).contains("::setup") 378 | assert_that(get_log(page)).contains("ValueError") 379 | 380 | @pytest.mark.parametrize("title", ["", "Special Report"]) 381 | def test_report_title(self, pytester, title): 382 | pytester.makepyfile("def test_pass(): pass") 383 | 384 | if title: 385 | pytester.makeconftest( 386 | f""" 387 | import pytest 388 | def pytest_html_report_title(report): 389 | report.title = "{title}" 390 | """ 391 | ) 392 | 393 | expected_title = title if title else "report.html" 394 | page = run(pytester) 395 | assert_that(get_text(page, "#head-title")).is_equal_to(expected_title) 396 | assert_that(get_text(page, "h1[id='title']")).is_equal_to(expected_title) 397 | 398 | def test_resources_inline_css(self, pytester): 399 | pytester.makepyfile("def test_pass(): pass") 400 | page = run(pytester, cmd_flags=["--self-contained-html"]) 401 | 402 | content = file_content() 403 | 404 | assert_that(get_text(page, "head style").strip()).contains(content) 405 | 406 | def test_resources_css(self, pytester): 407 | pytester.makepyfile("def test_pass(): pass") 408 | page = run(pytester) 409 | 410 | assert_that(page.select_one("head link")["href"]).is_equal_to( 411 | str(Path("assets", "style.css")) 412 | ) 413 | 414 | def test_custom_content_in_summary(self, pytester): 415 | content = { 416 | "prefix": str(random.random()), 417 | "summary": str(random.random()), 418 | "postfix": str(random.random()), 419 | } 420 | 421 | pytester.makeconftest( 422 | f""" 423 | import pytest 424 | 425 | def pytest_html_results_summary(prefix, summary, postfix): 426 | prefix.append(r"

      prefix is {content['prefix']}

      ") 427 | summary.extend([r"

      summary is {content['summary']}

      "]) 428 | postfix.extend([r"

      postfix is {content['postfix']}

      "]) 429 | """ 430 | ) 431 | 432 | pytester.makepyfile("def test_pass(): pass") 433 | page = run(pytester) 434 | 435 | elements = page.select( 436 | ".additional-summary p" 437 | ) # ".summary__data p:not(.run-count):not(.filter)") 438 | assert_that(elements).is_length(3) 439 | for element in elements: 440 | key = re.search(r"(\w+).*", element.string).group(1) 441 | value = content.pop(key) 442 | assert_that(element.string).contains(value) 443 | 444 | def test_extra_html(self, pytester): 445 | content = str(random.random()) 446 | pytester.makeconftest( 447 | f""" 448 | import pytest 449 | 450 | @pytest.hookimpl(hookwrapper=True) 451 | def pytest_runtest_makereport(item, call): 452 | outcome = yield 453 | report = outcome.get_result() 454 | if report.when == 'call': 455 | from pytest_html import extras 456 | report.extras = [extras.html('
      {content}
      ')] 457 | """ 458 | ) 459 | 460 | pytester.makepyfile("def test_pass(): pass") 461 | page = run(pytester) 462 | 463 | assert_that(page.select_one(".extraHTML").string).is_equal_to(content) 464 | 465 | @pytest.mark.parametrize( 466 | "content, encoded", 467 | [("u'\u0081'", "woE="), ("'foo'", "Zm9v"), ("b'\\xe2\\x80\\x93'", "4oCT")], 468 | ) 469 | def test_extra_text(self, pytester, content, encoded): 470 | pytester.makeconftest( 471 | f""" 472 | import pytest 473 | @pytest.hookimpl(hookwrapper=True) 474 | def pytest_runtest_makereport(item, call): 475 | outcome = yield 476 | report = outcome.get_result() 477 | if report.when == 'call': 478 | from pytest_html import extras 479 | report.extras = [extras.text({content})] 480 | """ 481 | ) 482 | 483 | pytester.makepyfile("def test_pass(): pass") 484 | page = run(pytester, cmd_flags=["--self-contained-html"]) 485 | 486 | element = page.select_one("a[class='col-links__extra text']") 487 | assert_that(element.string).is_equal_to("Text") 488 | assert_that(element["href"]).is_equal_to( 489 | f"data:text/plain;charset=utf-8;base64,{encoded}" 490 | ) 491 | 492 | def test_extra_json(self, pytester): 493 | content = {str(random.random()): str(random.random())} 494 | pytester.makeconftest( 495 | f""" 496 | import pytest 497 | 498 | @pytest.hookimpl(hookwrapper=True) 499 | def pytest_runtest_makereport(item, call): 500 | outcome = yield 501 | report = outcome.get_result() 502 | if report.when == 'call': 503 | from pytest_html import extras 504 | report.extras = [extras.json({content})] 505 | """ 506 | ) 507 | 508 | pytester.makepyfile("def test_pass(): pass") 509 | page = run(pytester, cmd_flags=["--self-contained-html"]) 510 | 511 | content_str = json.dumps(content) 512 | data = b64encode(content_str.encode("utf-8")).decode("ascii") 513 | 514 | element = page.select_one("a[class='col-links__extra json']") 515 | assert_that(element.string).is_equal_to("JSON") 516 | assert_that(element["href"]).is_equal_to( 517 | f"data:application/json;charset=utf-8;base64,{data}" 518 | ) 519 | 520 | def test_extra_url(self, pytester): 521 | pytester.makeconftest( 522 | """ 523 | import pytest 524 | 525 | @pytest.hookimpl(hookwrapper=True) 526 | def pytest_runtest_makereport(item, call): 527 | outcome = yield 528 | report = outcome.get_result() 529 | from pytest_html import extras 530 | report.extras = [extras.url(f'{report.when}')] 531 | """ 532 | ) 533 | pytester.makepyfile("def test_pass(): pass") 534 | page = run(pytester) 535 | 536 | elements = page.select("a[class='col-links__extra url']") 537 | assert_that(elements).is_length(3) 538 | for each in zip(elements, ["setup", "call", "teardown"]): 539 | element, when = each 540 | assert_that(element.string).is_equal_to("URL") 541 | assert_that(element["href"]).is_equal_to(when) 542 | 543 | @pytest.mark.parametrize( 544 | "mime_type, extension", 545 | [ 546 | ("image/png", "png"), 547 | ("image/png", "image"), 548 | ("image/jpeg", "jpg"), 549 | ("image/svg+xml", "svg"), 550 | ], 551 | ) 552 | def test_extra_image(self, pytester, mime_type, extension): 553 | content = str(random.random()) 554 | charset = "utf-8" 555 | data = base64.b64encode(content.encode(charset)).decode(charset) 556 | 557 | pytester.makeconftest( 558 | f""" 559 | import pytest 560 | 561 | @pytest.hookimpl(hookwrapper=True) 562 | def pytest_runtest_makereport(item, call): 563 | outcome = yield 564 | report = outcome.get_result() 565 | if report.when == 'call': 566 | from pytest_html import extras 567 | report.extras = [extras.{extension}('{data}')] 568 | """ 569 | ) 570 | pytester.makepyfile("def test_pass(): pass") 571 | page = run(pytester, cmd_flags=["--self-contained-html"]) 572 | 573 | # element = page.select_one(".summary a[class='col-links__extra image']") 574 | src = f"data:{mime_type};base64,{data}" 575 | # assert_that(element.string).is_equal_to("Image") 576 | # assert_that(element["href"]).is_equal_to(src) 577 | 578 | element = page.select_one(".media img") 579 | assert_that(str(element)).is_equal_to(f'') 580 | 581 | @pytest.mark.parametrize("mime_type, extension", [("video/mp4", "mp4")]) 582 | def test_extra_video(self, pytester, mime_type, extension): 583 | content = str(random.random()) 584 | charset = "utf-8" 585 | data = base64.b64encode(content.encode(charset)).decode(charset) 586 | pytester.makeconftest( 587 | f""" 588 | import pytest 589 | @pytest.hookimpl(hookwrapper=True) 590 | def pytest_runtest_makereport(item, call): 591 | outcome = yield 592 | report = outcome.get_result() 593 | if report.when == 'call': 594 | from pytest_html import extras 595 | report.extras = [extras.{extension}('{data}')] 596 | """ 597 | ) 598 | pytester.makepyfile("def test_pass(): pass") 599 | page = run(pytester, cmd_flags=["--self-contained-html"]) 600 | 601 | # element = page.select_one(".summary a[class='col-links__extra video']") 602 | src = f"data:{mime_type};base64,{data}" 603 | # assert_that(element.string).is_equal_to("Video") 604 | # assert_that(element["href"]).is_equal_to(src) 605 | 606 | element = page.select_one(".media video") 607 | assert_that(str(element)).is_equal_to( 608 | f'' 609 | ) 610 | 611 | def test_xdist(self, pytester): 612 | pytester.makepyfile("def test_xdist(): pass") 613 | page = run(pytester, cmd_flags=["-n1"]) 614 | assert_results(page, passed=1) 615 | 616 | def test_results_table_hook_append(self, pytester): 617 | header_selector = "#results-table-head tr:nth-child(1) th:nth-child({})" 618 | row_selector = "#results-table tr:nth-child(1) td:nth-child({})" 619 | 620 | pytester.makeconftest( 621 | """ 622 | def pytest_html_results_table_header(cells): 623 | cells.append("Description") 624 | cells.append( 625 | 'Time' 626 | ) 627 | 628 | def pytest_html_results_table_row(report, cells): 629 | cells.append("A description") 630 | cells.append('A time') 631 | """ 632 | ) 633 | pytester.makepyfile("def test_pass(): pass") 634 | page = run(pytester) 635 | 636 | description_index = 5 637 | time_index = 6 638 | assert_that(get_text(page, header_selector.format(time_index))).is_equal_to( 639 | "Time" 640 | ) 641 | assert_that( 642 | get_text(page, header_selector.format(description_index)) 643 | ).is_equal_to("Description") 644 | 645 | assert_that(get_text(page, row_selector.format(time_index))).is_equal_to( 646 | "A time" 647 | ) 648 | assert_that(get_text(page, row_selector.format(description_index))).is_equal_to( 649 | "A description" 650 | ) 651 | 652 | def test_results_table_hook_insert(self, pytester): 653 | header_selector = "#results-table-head tr:nth-child(1) th:nth-child({})" 654 | row_selector = "#results-table tr:nth-child(1) td:nth-child({})" 655 | 656 | pytester.makeconftest( 657 | """ 658 | def pytest_html_results_table_header(cells): 659 | cells.insert(2, "Description") 660 | cells.insert( 661 | 1, 662 | 'Time' 663 | ) 664 | 665 | def pytest_html_results_table_row(report, cells): 666 | cells.insert(2, "A description") 667 | cells.insert(1, 'A time') 668 | """ 669 | ) 670 | pytester.makepyfile("def test_pass(): pass") 671 | page = run(pytester) 672 | 673 | description_index = 4 674 | time_index = 2 675 | assert_that(get_text(page, header_selector.format(time_index))).is_equal_to( 676 | "Time" 677 | ) 678 | assert_that( 679 | get_text(page, header_selector.format(description_index)) 680 | ).is_equal_to("Description") 681 | 682 | assert_that(get_text(page, row_selector.format(time_index))).is_equal_to( 683 | "A time" 684 | ) 685 | assert_that(get_text(page, row_selector.format(description_index))).is_equal_to( 686 | "A description" 687 | ) 688 | 689 | def test_results_table_hook_delete(self, pytester): 690 | pytester.makeconftest( 691 | """ 692 | def pytest_html_results_table_row(report, cells): 693 | if report.skipped: 694 | del cells[:] 695 | """ 696 | ) 697 | pytester.makepyfile( 698 | """ 699 | import pytest 700 | def test_skip(): 701 | pytest.skip('reason') 702 | 703 | def test_pass(): pass 704 | 705 | """ 706 | ) 707 | page = run(pytester) 708 | assert_results(page, passed=1) 709 | 710 | def test_results_table_hook_pop(self, pytester): 711 | pytester.makeconftest( 712 | """ 713 | def pytest_html_results_table_header(cells): 714 | cells.pop() 715 | 716 | def pytest_html_results_table_row(report, cells): 717 | cells.pop() 718 | """ 719 | ) 720 | pytester.makepyfile("def test_pass(): pass") 721 | page = run(pytester) 722 | 723 | header_columns = page.select("#results-table-head th") 724 | assert_that(header_columns).is_length(3) 725 | 726 | row_columns = page.select_one(".results-table-row").select("td:not(.extra)") 727 | assert_that(row_columns).is_length(3) 728 | 729 | @pytest.mark.parametrize("no_capture", ["", "-s"]) 730 | def test_standard_streams(self, pytester, no_capture): 731 | pytester.makepyfile( 732 | """ 733 | import pytest 734 | import sys 735 | @pytest.fixture 736 | def setup(): 737 | print("this is setup stdout") 738 | print("this is setup stderr", file=sys.stderr) 739 | yield 740 | print("this is teardown stdout") 741 | print("this is teardown stderr", file=sys.stderr) 742 | 743 | def test_streams(setup): 744 | print("this is call stdout") 745 | print("this is call stderr", file=sys.stderr) 746 | assert True 747 | """ 748 | ) 749 | page = run(pytester, "report.html", cmd_flags=[no_capture]) 750 | assert_results(page, passed=1) 751 | 752 | log = get_log(page) 753 | for when in ["setup", "call", "teardown"]: 754 | for stream in ["stdout", "stderr"]: 755 | if no_capture: 756 | assert_that(log).does_not_match(f"- Captured {stream} {when} -") 757 | assert_that(log).does_not_match(f"this is {when} {stream}") 758 | else: 759 | assert_that(log).matches(f"- Captured {stream} {when} -") 760 | assert_that(log).matches(f"this is {when} {stream}") 761 | 762 | def test_collect_error(self, pytester): 763 | error_msg = "Non existent module" 764 | pytester.makepyfile( 765 | f""" 766 | import pytest 767 | raise ImportError("{error_msg}") 768 | """ 769 | ) 770 | page = run(pytester) 771 | assert_results(page, error=1) 772 | 773 | log = get_log(page) 774 | assert_that(log).matches(rf"E\s+ImportError: {error_msg}") 775 | 776 | def test_report_display_utf8(self, pytester): 777 | pytester.makepyfile( 778 | """ 779 | import pytest 780 | @pytest.mark.parametrize("utf8", [("测试用例名称")]) 781 | def test_pass(utf8): 782 | assert True 783 | """ 784 | ) 785 | page = run(pytester) 786 | assert_results(page, passed=1) 787 | 788 | log = get_log(page) 789 | assert_that(log).does_not_match(r"测试用例名称") 790 | 791 | @pytest.mark.parametrize("outcome, occurrence", [(True, 1), (False, 2)]) 792 | def test_log_escaping(self, pytester, outcome, occurrence): 793 | """ 794 | Not the best test, but it does a simple verification 795 | that the string is escaped properly and not rendered as HTML 796 | """ 797 | texts = [ 798 | "0 Checking object and more", 799 | "1 Checking object < > and more", 800 | "2 Checking object <> and more", 801 | "3 Checking object < C > and more", 802 | "4 Checking object and more", 803 | "5 Checking object < and more", 804 | "6 Checking object < and more", 805 | "7 Checking object < C and more", 806 | "8 Checking object " and more', 808 | '10 Checking object "< >" and more', 809 | '11 Checking object "<>" and more', 810 | '12 Checking object "< C >" and more', 811 | '13 Checking object "" and more', 812 | ] 813 | test_file = "def test_escape():\n" 814 | for t in texts: 815 | test_file += f"\tprint('{t}')\n" 816 | test_file += f"\tassert {outcome}" 817 | pytester.makepyfile(test_file) 818 | 819 | page = run(pytester) 820 | assert_results(page, passed=1 if outcome else 0, failed=1 if not outcome else 0) 821 | 822 | log = get_log(page) 823 | for each in texts: 824 | count = log.count(each) 825 | assert_that(count).is_equal_to(occurrence) 826 | 827 | @pytest.mark.parametrize( 828 | "sort, order", 829 | [ 830 | (None, ["BBB", "AAA", "CCC"]), 831 | ("result", ["BBB", "AAA", "CCC"]), 832 | ("testId", ["AAA", "BBB", "CCC"]), 833 | ("duration", ["CCC", "BBB", "AAA"]), 834 | ("original", ["AAA", "BBB", "CCC"]), 835 | ], 836 | ) 837 | def test_initial_sort(self, pytester, sort, order): 838 | if sort is not None: 839 | pytester.makeini( 840 | f""" 841 | [pytest] 842 | initial_sort = {sort} 843 | """ 844 | ) 845 | 846 | pytester.makepyfile( 847 | """ 848 | import pytest 849 | from time import sleep 850 | 851 | def test_AAA(): 852 | sleep(0.3) 853 | assert True 854 | 855 | def test_BBB(): 856 | sleep(0.2) 857 | assert False 858 | 859 | def test_CCC(): 860 | sleep(0.1) 861 | assert True 862 | """ 863 | ) 864 | 865 | page = run(pytester) 866 | assert_results(page, passed=2, failed=1) 867 | 868 | result = page.select("td.col-testId") 869 | assert_that(result).is_length(3) 870 | for row, expected in zip(result, order): 871 | assert_that(row.string).contains(expected) 872 | 873 | def test_collapsed_class_when_results_table_order_changed(self, pytester): 874 | pytester.makeconftest( 875 | """ 876 | def pytest_html_results_table_header(cells): 877 | cells.append(cells.pop(0)) 878 | 879 | def pytest_html_results_table_row(report, cells): 880 | cells.append(cells.pop(0)) 881 | """ 882 | ) 883 | pytester.makepyfile("def test_pass(): pass") 884 | page = run(pytester) 885 | assert_results(page, passed=1) 886 | 887 | assert_that( 888 | get_text(page, "#results-table td[class='col-result collapsed']") 889 | ).is_true() 890 | 891 | 892 | class TestLogCapturing: 893 | LOG_LINE_REGEX = r"\s+this is {}" 894 | 895 | @pytest.fixture 896 | def log_cli(self, pytester): 897 | pytester.makeini( 898 | """ 899 | [pytest] 900 | log_cli = 1 901 | log_cli_level = INFO 902 | log_cli_date_format = %Y-%m-%d %H:%M:%S 903 | log_cli_format = %(asctime)s %(levelname)s: %(message)s 904 | """ 905 | ) 906 | 907 | @pytest.fixture 908 | def test_file(self): 909 | def formatter(assertion, setup="", teardown="", flaky=""): 910 | return f""" 911 | import pytest 912 | import logging 913 | @pytest.fixture 914 | def setup(): 915 | logging.info("this is setup") 916 | {setup} 917 | yield 918 | logging.info("this is teardown") 919 | {teardown} 920 | 921 | {flaky} 922 | def test_logging(setup): 923 | logging.info("this is test") 924 | assert {assertion} 925 | """ 926 | 927 | return formatter 928 | 929 | @pytest.mark.usefixtures("log_cli") 930 | def test_all_pass(self, test_file, pytester): 931 | pytester.makepyfile(test_file(assertion=True)) 932 | page = run(pytester) 933 | assert_results(page, passed=1) 934 | 935 | log = get_log(page) 936 | for when in ["setup", "test", "teardown"]: 937 | assert_that(log).matches(self.LOG_LINE_REGEX.format(when)) 938 | 939 | @pytest.mark.usefixtures("log_cli") 940 | def test_setup_error(self, test_file, pytester): 941 | pytester.makepyfile(test_file(assertion=True, setup="error")) 942 | page = run(pytester) 943 | assert_results(page, error=1) 944 | 945 | log = get_log(page) 946 | assert_that(log).matches(self.LOG_LINE_REGEX.format("setup")) 947 | assert_that(log).does_not_match(self.LOG_LINE_REGEX.format("test")) 948 | assert_that(log).does_not_match(self.LOG_LINE_REGEX.format("teardown")) 949 | 950 | @pytest.mark.usefixtures("log_cli") 951 | def test_test_fails(self, test_file, pytester): 952 | pytester.makepyfile(test_file(assertion=False)) 953 | page = run(pytester) 954 | assert_results(page, failed=1) 955 | 956 | log = get_log(page) 957 | for when in ["setup", "test", "teardown"]: 958 | assert_that(log).matches(self.LOG_LINE_REGEX.format(when)) 959 | 960 | @pytest.mark.usefixtures("log_cli") 961 | @pytest.mark.parametrize( 962 | "assertion, result", [(True, {"passed": 1}), (False, {"failed": 1})] 963 | ) 964 | def test_teardown_error(self, test_file, pytester, assertion, result): 965 | pytester.makepyfile(test_file(assertion=assertion, teardown="error")) 966 | page = run(pytester) 967 | assert_results(page, error=1, **result) 968 | 969 | for test_name in ["test_logging", "test_logging::teardown"]: 970 | log = get_log(page, test_name) 971 | for when in ["setup", "test", "teardown"]: 972 | assert_that(log).matches(self.LOG_LINE_REGEX.format(when)) 973 | 974 | def test_no_log(self, test_file, pytester): 975 | pytester.makepyfile(test_file(assertion=True)) 976 | page = run(pytester) 977 | assert_results(page, passed=1) 978 | 979 | log = get_log(page, "test_logging") 980 | assert_that(log).contains("No log output captured.") 981 | for when in ["setup", "test", "teardown"]: 982 | assert_that(log).does_not_match(self.LOG_LINE_REGEX.format(when)) 983 | 984 | @pytest.mark.usefixtures("log_cli") 985 | def test_rerun(self, test_file, pytester): 986 | pytester.makepyfile( 987 | test_file(assertion=False, flaky="@pytest.mark.flaky(reruns=2)") 988 | ) 989 | page = run(pytester, query_params={"visible": "failed"}) 990 | assert_results(page, failed=1, rerun=2) 991 | 992 | log = get_log(page) 993 | assert_that(log.count("Captured log setup")).is_equal_to(3) 994 | assert_that(log.count("Captured log teardown")).is_equal_to(5) 995 | 996 | 997 | class TestCollapsedQueryParam: 998 | @pytest.fixture 999 | def test_file(self): 1000 | return """ 1001 | import pytest 1002 | @pytest.fixture 1003 | def setup(): 1004 | error 1005 | 1006 | def test_error(setup): 1007 | assert True 1008 | 1009 | def test_pass(): 1010 | assert True 1011 | 1012 | def test_fail(): 1013 | assert False 1014 | """ 1015 | 1016 | def test_default(self, pytester, test_file): 1017 | pytester.makepyfile(test_file) 1018 | page = run(pytester) 1019 | assert_results(page, passed=1, failed=1, error=1) 1020 | 1021 | assert_that(is_collapsed(page, "test_pass")).is_true() 1022 | assert_that(is_collapsed(page, "test_fail")).is_false() 1023 | assert_that(is_collapsed(page, "test_error::setup")).is_false() 1024 | 1025 | @pytest.mark.parametrize("param", ["failed,error", "FAILED,eRRoR"]) 1026 | def test_specified(self, pytester, test_file, param): 1027 | pytester.makepyfile(test_file) 1028 | page = run(pytester, query_params={"collapsed": param}) 1029 | assert_results(page, passed=1, failed=1, error=1) 1030 | 1031 | assert_that(is_collapsed(page, "test_pass")).is_false() 1032 | assert_that(is_collapsed(page, "test_fail")).is_true() 1033 | assert_that(is_collapsed(page, "test_error::setup")).is_true() 1034 | 1035 | def test_all(self, pytester, test_file): 1036 | pytester.makepyfile(test_file) 1037 | page = run(pytester, query_params={"collapsed": "all"}) 1038 | assert_results(page, passed=1, failed=1, error=1) 1039 | 1040 | for test_name in ["test_pass", "test_fail", "test_error::setup"]: 1041 | assert_that(is_collapsed(page, test_name)).is_true() 1042 | 1043 | @pytest.mark.parametrize("param", ["", 'collapsed=""', "collapsed=''"]) 1044 | def test_falsy(self, pytester, test_file, param): 1045 | pytester.makepyfile(test_file) 1046 | page = run(pytester, query_params={"collapsed": param}) 1047 | assert_results(page, passed=1, failed=1, error=1) 1048 | 1049 | assert_that(is_collapsed(page, "test_pass")).is_false() 1050 | assert_that(is_collapsed(page, "test_fail")).is_false() 1051 | assert_that(is_collapsed(page, "test_error::setup")).is_false() 1052 | 1053 | @pytest.mark.parametrize("param", ["failed,error", "FAILED,eRRoR"]) 1054 | def test_render_collapsed(self, pytester, test_file, param): 1055 | pytester.makeini( 1056 | f""" 1057 | [pytest] 1058 | render_collapsed = {param} 1059 | """ 1060 | ) 1061 | pytester.makepyfile(test_file) 1062 | page = run(pytester) 1063 | assert_results(page, passed=1, failed=1, error=1) 1064 | 1065 | assert_that(is_collapsed(page, "test_pass")).is_false() 1066 | assert_that(is_collapsed(page, "test_fail")).is_true() 1067 | assert_that(is_collapsed(page, "test_error::setup")).is_true() 1068 | 1069 | def test_render_collapsed_precedence(self, pytester, test_file): 1070 | pytester.makeini( 1071 | """ 1072 | [pytest] 1073 | render_collapsed = failed,error 1074 | """ 1075 | ) 1076 | test_file += """ 1077 | def test_skip(): 1078 | pytest.skip('meh') 1079 | """ 1080 | pytester.makepyfile(test_file) 1081 | page = run(pytester, query_params={"collapsed": "skipped"}) 1082 | assert_results(page, passed=1, failed=1, error=1, skipped=1) 1083 | 1084 | assert_that(is_collapsed(page, "test_pass")).is_false() 1085 | assert_that(is_collapsed(page, "test_fail")).is_false() 1086 | assert_that(is_collapsed(page, "test_error::setup")).is_false() 1087 | assert_that(is_collapsed(page, "test_skip")).is_true() 1088 | -------------------------------------------------------------------------------- /testing/test_unit.py: -------------------------------------------------------------------------------- 1 | import importlib.resources 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | from assertpy import assert_that 7 | 8 | pytest_plugins = ("pytester",) 9 | 10 | 11 | def run(pytester, path="report.html", cmd_flags=None): 12 | cmd_flags = cmd_flags or [] 13 | path = pytester.path.joinpath(path) 14 | return pytester.runpytest("--html", path, *cmd_flags) 15 | 16 | 17 | def file_content(): 18 | return ( 19 | importlib.resources.files("pytest_html") 20 | .joinpath("assets", "style.css") 21 | .read_bytes() 22 | .decode("utf-8") 23 | .strip() 24 | ) 25 | 26 | 27 | def test_duration_format_deprecation_warning(pytester): 28 | pytester.makeconftest( 29 | """ 30 | import pytest 31 | @pytest.hookimpl(hookwrapper=True) 32 | def pytest_runtest_makereport(item, call): 33 | outcome = yield 34 | report = outcome.get_result() 35 | setattr(report, "duration_formatter", "%H:%M:%S.%f") 36 | """ 37 | ) 38 | pytester.makepyfile("def test_pass(): pass") 39 | result = run(pytester) 40 | result.assert_outcomes(passed=1) 41 | result.stdout.fnmatch_lines( 42 | [ 43 | "*DeprecationWarning: 'duration_formatter'*", 44 | ], 45 | ) 46 | 47 | 48 | def test_html_results_summary_hook(pytester): 49 | pytester.makeconftest( 50 | """ 51 | import pytest 52 | 53 | def pytest_html_results_summary(prefix, summary, postfix, session): 54 | print(prefix) 55 | print(summary) 56 | print(postfix) 57 | print(session) 58 | """ 59 | ) 60 | 61 | pytester.makepyfile("def test_pass(): pass") 62 | result = run(pytester) 63 | result.assert_outcomes(passed=1) 64 | 65 | 66 | def test_chdir(pytester): 67 | pytester.makepyfile( 68 | """ 69 | import pytest 70 | 71 | @pytest.fixture 72 | def changing_dir(tmp_path, monkeypatch): 73 | monkeypatch.chdir(tmp_path) 74 | 75 | def test_function(changing_dir): 76 | pass 77 | """ 78 | ) 79 | report_path = Path("reports") / "report.html" 80 | page = pytester.runpytest("--html", str(report_path)) 81 | assert page.ret == 0 82 | assert ( 83 | f"Generated html report: {(pytester.path / report_path).as_uri()}" 84 | ) in page.outlines[-2] 85 | 86 | 87 | @pytest.fixture 88 | def css_file_path(pytester): 89 | css_one = """ 90 | h1 { 91 | color: red; 92 | } 93 | """ 94 | css_two = """ 95 | h2 { 96 | color: blue; 97 | } 98 | """ 99 | css_dir = pytester.path / "extra_css" 100 | css_dir.mkdir() 101 | file_path = css_dir / "one.css" 102 | with open(file_path, "w") as f: 103 | f.write(css_one) 104 | 105 | pytester.makefile(".css", two=css_two) 106 | pytester.makepyfile("def test_pass(): pass") 107 | 108 | return file_path 109 | 110 | 111 | @pytest.fixture(params=[True, False]) 112 | def expandvar(request, css_file_path, monkeypatch): 113 | if request.param: 114 | monkeypatch.setenv("EXTRA_CSS", str(css_file_path)) 115 | return "%EXTRA_CSS%" if sys.platform == "win32" else "${EXTRA_CSS}" 116 | return css_file_path 117 | 118 | 119 | def test_custom_css(pytester, css_file_path, expandvar): 120 | result = run( 121 | pytester, "report.html", cmd_flags=["--css", expandvar, "--css", "two.css"] 122 | ) 123 | result.assert_outcomes(passed=1) 124 | 125 | path = pytester.path.joinpath("assets", "style.css") 126 | 127 | with open(str(path)) as f: 128 | css = f.read() 129 | assert_that(css).contains("* " + str(css_file_path)).contains("* two.css") 130 | 131 | 132 | def test_custom_css_selfcontained(pytester, css_file_path, expandvar): 133 | result = run( 134 | pytester, 135 | "report.html", 136 | cmd_flags=[ 137 | "--css", 138 | expandvar, 139 | "--css", 140 | "two.css", 141 | "--self-contained-html", 142 | ], 143 | ) 144 | result.assert_outcomes(passed=1) 145 | 146 | with open(pytester.path / "report.html") as f: 147 | html = f.read() 148 | assert_that(html).contains("* " + str(css_file_path)).contains("* two.css") 149 | -------------------------------------------------------------------------------- /testing/unittest.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const sinon = require('sinon') 3 | const { doInitFilter, doFilter } = require('../src/pytest_html/scripts/filter.js') 4 | const { doInitSort, doSort } = require('../src/pytest_html/scripts/sort.js') 5 | const dataModule = require('../src/pytest_html/scripts/datamanager.js') 6 | const storageModule = require('../src/pytest_html/scripts/storage.js') 7 | 8 | 9 | const setTestData = () => { 10 | const jsonData = { 11 | 'tests': 12 | [ 13 | { 14 | 'id': 'passed_1', 15 | 'result': 'passed', 16 | }, 17 | { 18 | 'id': 'failed_2', 19 | 'result': 'failed', 20 | }, 21 | { 22 | 'id': 'passed_3', 23 | 'result': 'passed', 24 | }, 25 | { 26 | 'id': 'passed_4', 27 | 'result': 'passed', 28 | }, 29 | { 30 | 'id': 'passed_5', 31 | 'result': 'passed', 32 | }, 33 | { 34 | 'id': 'passed_6', 35 | 'result': 'passed', 36 | }, 37 | ], 38 | } 39 | dataModule.manager.setManager(jsonData) 40 | } 41 | 42 | const mockWindow = (queryParam) => { 43 | const mock = { 44 | location: { 45 | href: `https://example.com/page?${queryParam}`, 46 | }, 47 | history: { 48 | pushState: sinon.stub(), 49 | }, 50 | } 51 | originalWindow = global.window 52 | global.window = mock 53 | } 54 | 55 | describe('Filter tests', () => { 56 | let getFilterMock 57 | let managerSpy 58 | 59 | beforeEach(setTestData) 60 | afterEach(() => [getFilterMock, managerSpy].forEach((fn) => fn.restore())) 61 | after(() => dataModule.manager.setManager({ tests: [] })) 62 | 63 | describe('doInitFilter', () => { 64 | it('has no stored filters', () => { 65 | getFilterMock = sinon.stub(storageModule, 'getVisible').returns([]) 66 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 67 | 68 | doInitFilter() 69 | expect(managerSpy.callCount).to.eql(1) 70 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([]) 71 | }) 72 | it('exclude passed', () => { 73 | getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['failed']) 74 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 75 | 76 | doInitFilter() 77 | expect(managerSpy.callCount).to.eql(1) 78 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql(['failed']) 79 | }) 80 | }) 81 | describe('doFilter', () => { 82 | let originalWindow 83 | 84 | after(() => global.window = originalWindow) 85 | 86 | it('removes all but passed', () => { 87 | mockWindow() 88 | getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['passed']) 89 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 90 | 91 | doFilter('passed', true) 92 | expect(managerSpy.callCount).to.eql(2) 93 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([ 94 | 'passed', 'passed', 'passed', 'passed', 'passed', 95 | ]) 96 | }) 97 | }) 98 | describe('getVisible', () => { 99 | let originalWindow 100 | 101 | after(() => global.window = originalWindow) 102 | 103 | it('returns all filters by default', () => { 104 | mockWindow() 105 | const visibleItems = storageModule.getVisible() 106 | expect(visibleItems).to.eql(storageModule.possibleFilters) 107 | }) 108 | 109 | it('returns specified filters', () => { 110 | mockWindow('visible=failed,error') 111 | const visibleItems = storageModule.getVisible() 112 | expect(visibleItems).to.eql(['failed', 'error']) 113 | }) 114 | 115 | it('handles case insensitive params', () => { 116 | mockWindow('visible=fAiLeD,ERROR,passed') 117 | const visibleItems = storageModule.getVisible() 118 | expect(visibleItems).to.eql(['failed', 'error', 'passed']) 119 | }) 120 | 121 | const falsy = [ 122 | { param: 'visible' }, 123 | { param: 'visible=' }, 124 | { param: 'visible=""' }, 125 | { param: 'visible=\'\'' }, 126 | ] 127 | falsy.forEach(({ param }) => { 128 | it(`returns no filters with ${param}`, () => { 129 | mockWindow(param) 130 | const visibleItems = storageModule.getVisible() 131 | expect(visibleItems).to.be.empty 132 | }) 133 | }) 134 | }) 135 | }) 136 | 137 | 138 | describe('Sort tests', () => { 139 | beforeEach(setTestData) 140 | after(() => dataModule.manager.setManager({ tests: [] })) 141 | describe('doInitSort', () => { 142 | let managerSpy 143 | let sortMock 144 | let sortDirectionMock 145 | let originalWindow 146 | 147 | before(() => mockWindow()) 148 | beforeEach(() => dataModule.manager.resetRender()) 149 | afterEach(() => [sortMock, sortDirectionMock, managerSpy].forEach((fn) => fn.restore())) 150 | after(() => global.window = originalWindow) 151 | it('has no stored sort', () => { 152 | sortMock = sinon.stub(storageModule, 'getSort').returns('result') 153 | sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null) 154 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 155 | 156 | doInitSort() 157 | expect(managerSpy.callCount).to.eql(1) 158 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([ 159 | 'failed', 'passed', 'passed', 'passed', 'passed', 'passed', 160 | ]) 161 | }) 162 | it('has stored sort preference', () => { 163 | sortMock = sinon.stub(storageModule, 'getSort').returns('result') 164 | sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(false) 165 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 166 | 167 | doInitSort() 168 | expect(managerSpy.callCount).to.eql(1) 169 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([ 170 | 'failed', 'passed', 'passed', 'passed', 'passed', 'passed', 171 | ]) 172 | }) 173 | it('keeps original test execution order', () => { 174 | sortMock = sinon.stub(storageModule, 'getSort').returns('original') 175 | sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(false) 176 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 177 | 178 | doInitSort() 179 | expect(managerSpy.callCount).to.eql(1) 180 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([ 181 | 'passed', 'failed', 'passed', 'passed', 'passed', 'passed', 182 | ]) 183 | }) 184 | }) 185 | describe('doSort', () => { 186 | let getSortMock 187 | let setSortMock 188 | let getSortDirectionMock 189 | let setSortDirection 190 | let managerSpy 191 | 192 | afterEach(() => [ 193 | getSortMock, setSortMock, getSortDirectionMock, setSortDirection, managerSpy, 194 | ].forEach((fn) => fn.restore())) 195 | it('sort on result', () => { 196 | getSortMock = sinon.stub(storageModule, 'getSort').returns(null) 197 | setSortMock = sinon.stub(storageModule, 'setSort') 198 | getSortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null) 199 | setSortDirection = sinon.stub(storageModule, 'setSortDirection') 200 | managerSpy = sinon.spy(dataModule.manager, 'setRender') 201 | 202 | doSort('result') 203 | expect(managerSpy.callCount).to.eql(1) 204 | expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([ 205 | 'failed', 'passed', 'passed', 'passed', 'passed', 'passed', 206 | ]) 207 | }) 208 | }) 209 | }) 210 | 211 | describe('Storage tests', () => { 212 | describe('getCollapsedCategory', () => { 213 | let originalWindow 214 | 215 | after(() => global.window = originalWindow) 216 | 217 | it('collapses passed by default', () => { 218 | mockWindow() 219 | const collapsedItems = storageModule.getCollapsedCategory() 220 | expect(collapsedItems).to.eql(['passed']) 221 | }) 222 | 223 | it('collapses specified outcomes', () => { 224 | mockWindow('collapsed=failed,error') 225 | const collapsedItems = storageModule.getCollapsedCategory() 226 | expect(collapsedItems).to.eql(['failed', 'error']) 227 | }) 228 | 229 | it('collapses all', () => { 230 | mockWindow('collapsed=all') 231 | const collapsedItems = storageModule.getCollapsedCategory() 232 | expect(collapsedItems).to.eql(storageModule.possibleFilters) 233 | }) 234 | 235 | it('handles case insensitive params', () => { 236 | mockWindow('collapsed=fAiLeD,ERROR,passed') 237 | const collapsedItems = storageModule.getCollapsedCategory() 238 | expect(collapsedItems).to.eql(['failed', 'error', 'passed']) 239 | }) 240 | 241 | const config = [ 242 | { value: ['failed', 'error'], expected: ['failed', 'error'] }, 243 | { value: ['all'], expected: storageModule.possibleFilters }, 244 | ] 245 | config.forEach(({ value, expected }) => { 246 | it(`handles python config: ${value}`, () => { 247 | mockWindow() 248 | const collapsedItems = storageModule.getCollapsedCategory(value) 249 | expect(collapsedItems).to.eql(expected) 250 | }) 251 | }) 252 | 253 | const precedence = [ 254 | { query: 'collapsed=xpassed,xfailed', value: ['failed', 'error'], expected: ['xpassed', 'xfailed'] }, 255 | { query: 'collapsed=all', value: ['failed', 'error'], expected: storageModule.possibleFilters }, 256 | { query: 'collapsed=xpassed,xfailed', value: ['all'], expected: ['xpassed', 'xfailed'] }, 257 | ] 258 | precedence.forEach(({ query, value, expected }, index) => { 259 | it(`handles python config precedence ${index + 1}`, () => { 260 | mockWindow(query) 261 | const collapsedItems = storageModule.getCollapsedCategory(value) 262 | expect(collapsedItems).to.eql(expected) 263 | }) 264 | }) 265 | 266 | const falsy = [ 267 | { param: 'collapsed' }, 268 | { param: 'collapsed=' }, 269 | { param: 'collapsed=""' }, 270 | { param: 'collapsed=\'\'' }, 271 | ] 272 | falsy.forEach(({ param }) => { 273 | it(`collapses none with ${param}`, () => { 274 | mockWindow(param) 275 | const collapsedItems = storageModule.getCollapsedCategory() 276 | expect(collapsedItems).to.be.empty 277 | }) 278 | }) 279 | }) 280 | }) 281 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = {3.9, 3.10, 3.10-cov, 3.11, 3.12, 3.13, pypy3.9}, docs, linting 8 | isolated_build = True 9 | 10 | [testenv] 11 | setenv = 12 | PYTHONDONTWRITEBYTECODE=1 13 | deps = 14 | assertpy 15 | beautifulsoup4 16 | pytest-xdist 17 | pytest-rerunfailures 18 | pytest-mock 19 | selenium 20 | ansi2html # soft-dependency 21 | cov: pytest-cov 22 | commands = 23 | !cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html {posargs} 24 | cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html --cov={envsitepackagesdir}/pytest_html --cov-report=term --cov-report=xml {posargs} 25 | 26 | [testenv:linting] 27 | skip_install = True 28 | basepython = python3 29 | deps = pre-commit 30 | commands = pre-commit run --all-files --show-diff-on-failure 31 | 32 | [testenv:devel] 33 | description = Tests with unreleased deps 34 | basepython = python3 35 | pip_pre = True 36 | deps = 37 | {[testenv]deps} 38 | ansi2html @ git+https://github.com/pycontribs/ansi2html.git 39 | pytest-rerunfailures @ git+https://github.com/pytest-dev/pytest-rerunfailures.git 40 | pytest @ git+https://github.com/pytest-dev/pytest.git 41 | 42 | [testenv:docs] 43 | # NOTE: The command for doc building was taken from readthedocs documentation 44 | # See https://docs.readthedocs.io/en/stable/builds.html#understanding-what-s-going-on 45 | basepython = python 46 | changedir = docs 47 | deps = sphinx 48 | commands = sphinx-build -b html . _build/html 49 | 50 | [flake8] 51 | max-line-length = 120 52 | exclude = .eggs,.tox 53 | # rationale here: 54 | # https://github.com/psf/black/blob/master/docs/the_black_code_style.md#slices 55 | extend-ignore = E203 56 | 57 | [pytest] 58 | testpaths = testing 59 | --------------------------------------------------------------------------------