From cdbe2885ad250afc38d286ce4322685f03bdd7e9 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Tue, 15 Dec 2020 18:15:00 -0500 Subject: [PATCH 01/19] remove phantomjs dependency (#424) --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index fb02d1b2..71bfa112 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "dependencies": { "grunt": "^1.3.0", "grunt-cli": "^1.3.2", - "grunt-contrib-qunit": "^4.0.0", - "phantomjs-prebuilt": "2.1.15" + "grunt-contrib-qunit": "^4.0.0" }, "devDependencies": { "sass": "^1.29.0" From e4c7bd63e47bb5cc867b11ff67ec28d91336c1f3 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Tue, 15 Dec 2020 21:28:29 -0500 Subject: [PATCH 02/19] properly classify all npm dependencies (#425) --- package.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 71bfa112..73cb2b55 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,9 @@ { "main": "Gruntfile.js", - "dependencies": { + "devDependencies": { "grunt": "^1.3.0", "grunt-cli": "^1.3.2", - "grunt-contrib-qunit": "^4.0.0" - }, - "devDependencies": { + "grunt-contrib-qunit": "^4.0.0", "sass": "^1.29.0" }, "scripts": { From 08446c9d575de617b51fb9c91556f0b7b10eb7a9 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Thu, 17 Dec 2020 22:46:57 -0500 Subject: [PATCH 03/19] Move the changelog to read the docs (#423) --- .pre-commit-config.yaml | 2 +- README.rst | 2 +- CHANGES.rst => docs/changelog.rst | 140 ++++++++++++++++++++---------- docs/development.rst | 3 +- docs/index.rst | 1 + 5 files changed, 100 insertions(+), 48 deletions(-) rename CHANGES.rst => docs/changelog.rst (86%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6e2213b..b5757483 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,7 +49,7 @@ repos: - id: rst name: rst entry: rst-lint --encoding utf-8 - files: ^(CHANGES.rst|development.rst|README.rst)$ + files: ^(README.rst)$ language: python additional_dependencies: [pygments, restructuredtext_lint] - repo: https://github.com/elidupuis/mirrors-sass-lint diff --git a/README.rst b/README.rst index 63dd4acd..7cf04078 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Resources --------- - `Documentation `_ -- `Release Notes `_ +- `Release Notes `_ - `Issue Tracker `_ - `Code `_ diff --git a/CHANGES.rst b/docs/changelog.rst similarity index 86% rename from CHANGES.rst rename to docs/changelog.rst index 58ef255a..ceeca76d 100644 --- a/CHANGES.rst +++ b/docs/changelog.rst @@ -1,19 +1,27 @@ -Release Notes -------------- +Changelog +========= -**3.2.0 (unreleased)** +Versions follow `Semantic Versioning`_ (``..``). + +Version History +--------------- + +3.2.0 (unreleased) +~~~~~~~~~~~~~~~~~~ * Make the report tab title reflect the report name. (`#412 `_) * Thanks to `@gnikonorov `_ for the PR -**3.1.1 (2020-12-13)** +3.1.1 (2020-12-13) +~~~~~~~~~~~~~~~~~~ * Fix issue with reporting of missing CSS files. (`#388 `_) * Thanks to `@prakhargurunani `_ for reporting and fixing! -**3.1.0 (2020-12-2)** +3.1.0 (2020-12-2) +~~~~~~~~~~~~~~~~~ * Stop attaching test reruns to final test report entries (`#374 `_) @@ -23,7 +31,8 @@ Release Notes * Thanks to `@brettnolan `_ for reporting and `@gnikonorov `_ for the fix -**3.0.0 (2020-10-28)** +3.0.0 (2020-10-28) +~~~~~~~~~~~~~~~~~~ * Respect ``--capture=no``, ``--show-capture=no``, and ``-s`` pytest flags (`#171 `_) @@ -45,13 +54,15 @@ Release Notes * Thanks to `@iwanb `_ for reporting and `@csm10495 `_ for the fix -**2.1.1 (2020-03-18)** +2.1.1 (2020-03-18) +~~~~~~~~~~~~~~~~~~ * Fix issue with funcargs causing failures. (`#282 `_) * Thanks to `@ssbarnea `_ for reporting and `@christiansandberg `_ for the fix -**2.1.0 (2020-03-09)** +2.1.0 (2020-03-09) +~~~~~~~~~~~~~~~~~~ * Added support for MP4 video format. (`#260 `_) @@ -73,7 +84,8 @@ Release Notes * Thanks to `@werdeil `_ for the PR -**2.0.1 (2019-10-05)** +2.0.1 (2019-10-05) +~~~~~~~~~~~~~~~~~~ * Properly check for presence of CSS file. (`#246 `_) @@ -87,13 +99,15 @@ Release Notes * Thanks to `@wanam `_ for reporting and fixing -**2.0.0 (2019-09-09)** +2.0.0 (2019-09-09) +~~~~~~~~~~~~~~~~~~ * Drop support for Python 2.7. We will continue to accept patches to ``1.22.x`` for the time being. * Thanks to `@hugovk `_ for the PR -**1.22.0 (2019-08-06)** +1.22.0 (2019-08-06) +~~~~~~~~~~~~~~~~~~~ * Refactor assets naming to be more readable and OS safe. @@ -105,13 +119,15 @@ Release Notes * Thanks to `@borntyping `_ for reporting and fixing! -**1.21.1 (2019-06-19)** +1.21.1 (2019-06-19) +~~~~~~~~~~~~~~~~~~~ * Fix issue with assets filenames being too long. * Thanks to `@D3X `_ for reporting and providing a fix -**1.21.0 (2019-06-17)** +1.21.0 (2019-06-17) +~~~~~~~~~~~~~~~~~~~ * Allow opening generated html report in browser (`@ssbarnea `_) @@ -119,7 +135,8 @@ Release Notes * Change assets naming method (`@SunInJuly `_) -**1.20.0 (2019-01-14)** +1.20.0 (2019-01-14) +~~~~~~~~~~~~~~~~~~~ * Tests running with Pytest 4.0 and Python 3.7 @@ -134,7 +151,8 @@ Release Notes * Refactor css config code (`@crazymerlyn `_) -**1.19.0 (2018-06-01)** +1.19.0 (2018-06-01) +~~~~~~~~~~~~~~~~~~~ * Allow collapsed outcomes to be configured by using a query parameter @@ -142,14 +160,16 @@ Release Notes enhancement and to `@jacebrowning `_ for providing a patch -**1.18.0 (2018-05-22)** +1.18.0 (2018-05-22) +~~~~~~~~~~~~~~~~~~~ * Preserve the order if metadata is ``OrderedDict`` * Thanks to `@jacebrowning `_ for suggesting this enhancement and providing a patch -**1.17.0 (2018-04-05)** +1.17.0 (2018-04-05) +~~~~~~~~~~~~~~~~~~~ * Add support for custom CSS (`#116 `_) @@ -173,7 +193,8 @@ Release Notes * Thanks to `@j19sch `_ for the PR -**1.16.1 (2018-01-04)** +1.16.1 (2018-01-04) +~~~~~~~~~~~~~~~~~~~ * Fix for including screenshots on Windows (`#124 `_) @@ -182,19 +203,22 @@ Release Notes issue and to `@pinkie1378 `_ for providing a fix -**1.16.0 (2017-09-19)** +1.16.0 (2017-09-19) +~~~~~~~~~~~~~~~~~~~ * Improve rendering of collections in metadata (`@rasmuspeders1 `_) -**1.15.2 (2017-08-15)** +1.15.2 (2017-08-15) +~~~~~~~~~~~~~~~~~~~ * Always decode byte string in extra text * Thanks to `@ch-t `_ for reporting the issue and providing a fix -**1.15.1 (2017-06-12)** +1.15.1 (2017-06-12) +~~~~~~~~~~~~~~~~~~~ * Fix pytest dependency to 3.0 or later @@ -202,7 +226,8 @@ Release Notes issue and to `@nicoddemus `_ for providing a fix -**1.15.0 (2017-06-09)** +1.15.0 (2017-06-09) +~~~~~~~~~~~~~~~~~~~ * Fix encoding issue in longrepr values @@ -213,7 +238,8 @@ Release Notes * Thanks to `@BeyondEvil `_ for the PR -**1.14.2 (2017-03-10)** +1.14.2 (2017-03-10) +~~~~~~~~~~~~~~~~~~~ * Always encode content for data URI @@ -221,12 +247,14 @@ Release Notes `@BeyondEvil `_ for reporting the issue and confirming the fix -**1.14.1 (2017-02-28)** +1.14.1 (2017-02-28) +~~~~~~~~~~~~~~~~~~~ * Present metadata without additional formatting to avoid issues due to unpredictable content types -**1.14.0 (2017-02-27)** +1.14.0 (2017-02-27) +~~~~~~~~~~~~~~~~~~~ * Add hooks for modifying the test results table * Replace environment section with values from @@ -234,36 +262,42 @@ Release Notes * Fix encoding for asset files * Escape contents of log sections -**1.13.0 (2016-12-19)** +1.13.0 (2016-12-19) +~~~~~~~~~~~~~~~~~~~ * Disable ANSI codes support by default due to dependency on `ansi2html `_ package with less permissive licensing -**1.12.0 (2016-11-30)** +1.12.0 (2016-11-30) +~~~~~~~~~~~~~~~~~~~ * Add support for JPG and SVG images (`@bhzunami `_) * Add version number and PyPI link to report header (`@denisra `_) -**1.11.1 (2016-11-25)** +1.11.1 (2016-11-25) +~~~~~~~~~~~~~~~~~~~ * Fix title of checkbox disappearing when unchecked (`@vashirov `_) -**1.11.0 (2016-11-08)** +1.11.0 (2016-11-08) +~~~~~~~~~~~~~~~~~~~ * Add support for ANSI codes in logs (`@premkarat `_) -**1.10.1 (2016-09-23)** +1.10.1 (2016-09-23) +~~~~~~~~~~~~~~~~~~~ * Fix corrupt image asset files * Remove image links from self-contained report * Fix issue with unexpected passes not being reported in pytest 3.0 -**1.10.0 (2016-08-09)** +1.10.0 (2016-08-09) +~~~~~~~~~~~~~~~~~~~ * Hide filter checkboxes when JavaScript is disabled (`@RibeiroAna `_) @@ -280,7 +314,8 @@ Release Notes * Allow visibility of extra details to be toggled (`@leitzler `_) -**1.9.0 (2016-07-04)** +1.9.0 (2016-07-04) +~~~~~~~~~~~~~~~~~~ * Split pytest_sessionfinish into generate and save methods (`@karandesai-96 `_) @@ -289,63 +324,78 @@ Release Notes * Added a feature to filter tests by outcome (`@RibeiroAna `_) -**1.8.1 (2016-05-24)** +1.8.1 (2016-05-24) +~~~~~~~~~~~~~~~~~~ * Include captured output for passing tests -**1.8.0 (2016-02-24)** +1.8.0 (2016-02-24) +~~~~~~~~~~~~~~~~~~ * Remove duplication from the environment section * Dropped support for Python 3.2 * Indicated setup and teardown in report * Fixed colour of errors in report -**1.7 (2015-10-19)** +1.7 (2015-10-19) +~~~~~~~~~~~~~~~~ * Fixed INTERNALERROR when an xdist worker crashes (`@The-Compiler `_) * Added report sections including stdout and stderr to log -**1.6 (2015-09-08)** +1.6 (2015-09-08) +~~~~~~~~~~~~~~~~ * Fixed environment details when using pytest-xdist -**1.5.1 (2015-08-18)** +1.5.1 (2015-08-18) +~~~~~~~~~~~~~~~~~~ * Made environment fixture session scoped to avoid repeating content -**1.5 (2015-08-18)** +1.5 (2015-08-18) +~~~~~~~~~~~~~~~~ * Replaced custom hook for setting environemnt section with a fixture -**1.4 (2015-08-12)** +1.4 (2015-08-12) +~~~~~~~~~~~~~~~~ * Dropped support for pytest 2.6 * Fixed unencodable strings for Python 3 (`@The-Compiler `_) -**1.3.2 (2015-07-27)** +1.3.2 (2015-07-27) +~~~~~~~~~~~~~~~~~~ * Prevented additional row if log has no content or there is no extra HTML -**1.3.1 (2015-05-26)** +1.3.1 (2015-05-26) +~~~~~~~~~~~~~~~~~~ * Fixed encoding issue in Python 3 -**1.3 (2015-05-26)** +1.3 (2015-05-26) +~~~~~~~~~~~~~~~~ * Show extra content regardless of test result * Added support for extra content in JSON format -**1.2 (2015-05-20)** +1.2 (2015-05-20) +~~~~~~~~~~~~~~~~ * Changed default sort order to test result (`@The-Compiler `_) -**1.1 (2015-05-08)** +1.1 (2015-05-08) +~~~~~~~~~~~~~~~~ * Added Python 3 support -**1.0 (2015-04-20)** +1.0 (2015-04-20) +~~~~~~~~~~~~~~~~ * Initial release + +.. _Semantic Versioning: https://semver.org diff --git a/docs/development.rst b/docs/development.rst index f7abf3e9..8e0220e1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -106,7 +106,7 @@ Follow these steps to release a new version of the project: #. Update your local master with the upstream master (``git pull --rebase upstream master``) #. Create a new branch -#. Update ``CHANGES.rst`` with the new version, today's date, and all changes/new features +#. Update `the changelog`_ with the new version, today's date, and all changes/new features #. Commit and push the new branch and then create a new pull request #. Wait for tests and reviews and then merge the branch #. Once merged, update your local master again (``git pull --rebase upstream master``) @@ -123,4 +123,5 @@ Follow these steps to release a new version of the project: .. _Read The Docs: https://readthedocs.com .. _RST: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html .. _SASS/SCSS: https://sass-lang.com +.. _the changelog: https://pytest-html.readthedocs.io/en/latest/changelog.html .. _Tox: https://tox.readthedocs.io diff --git a/docs/index.rst b/docs/index.rst index b223ba41..fde8d81b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,5 +16,6 @@ pytest-html is a plugin for `pytest`_ that generates a HTML report for test resu user_guide api_reference development + changelog .. _pytest: http://pytest.org From c9f442b127374826ddf8d0470793c19502e92713 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Fri, 18 Dec 2020 17:54:41 -0500 Subject: [PATCH 04/19] split plugin.py into smaller files (#427) --- src/pytest_html/html_report.py | 329 +++++++++++++++++ src/pytest_html/outcome.py | 33 ++ src/pytest_html/plugin.py | 653 +-------------------------------- src/pytest_html/result.py | 287 +++++++++++++++ src/pytest_html/util.py | 12 + testing/test_pytest_html.py | 7 +- 6 files changed, 667 insertions(+), 654 deletions(-) create mode 100644 src/pytest_html/html_report.py create mode 100644 src/pytest_html/outcome.py create mode 100644 src/pytest_html/result.py create mode 100644 src/pytest_html/util.py diff --git a/src/pytest_html/html_report.py b/src/pytest_html/html_report.py new file mode 100644 index 00000000..e1aa3973 --- /dev/null +++ b/src/pytest_html/html_report.py @@ -0,0 +1,329 @@ +import bisect +import datetime +import json +import os +import time +from collections import defaultdict +from collections import OrderedDict + +from py.xml import html +from py.xml import raw + +from . import __pypi_url__ +from . import __version__ +from .outcome import Outcome +from .result import TestResult +from .util import ansi_support + + +class HTMLReport: + def __init__(self, logfile, config): + logfile = os.path.expanduser(os.path.expandvars(logfile)) + self.logfile = os.path.abspath(logfile) + self.test_logs = [] + self.title = os.path.basename(self.logfile) + self.results = [] + self.errors = self.failed = 0 + self.passed = self.skipped = 0 + self.xfailed = self.xpassed = 0 + has_rerun = config.pluginmanager.hasplugin("rerunfailures") + self.rerun = 0 if has_rerun else None + self.self_contained = config.getoption("self_contained_html") + self.config = config + self.reports = defaultdict(list) + + def _appendrow(self, outcome, report): + result = TestResult(outcome, report, self.logfile, self.config) + if result.row_table is not None: + index = bisect.bisect_right(self.results, result) + self.results.insert(index, result) + tbody = html.tbody( + result.row_table, + class_="{} results-table-row".format(result.outcome.lower()), + ) + if result.row_extra is not None: + tbody.append(result.row_extra) + self.test_logs.insert(index, tbody) + + def append_passed(self, report): + if report.when == "call": + if hasattr(report, "wasxfail"): + self.xpassed += 1 + self._appendrow("XPassed", report) + else: + self.passed += 1 + self._appendrow("Passed", report) + + def append_failed(self, report): + if getattr(report, "when", None) == "call": + if hasattr(report, "wasxfail"): + # pytest < 3.0 marked xpasses as failures + self.xpassed += 1 + self._appendrow("XPassed", report) + else: + self.failed += 1 + self._appendrow("Failed", report) + else: + self.errors += 1 + self._appendrow("Error", report) + + def append_rerun(self, report): + self.rerun += 1 + self._appendrow("Rerun", report) + + def append_skipped(self, report): + if hasattr(report, "wasxfail"): + self.xfailed += 1 + self._appendrow("XFailed", report) + else: + self.skipped += 1 + self._appendrow("Skipped", report) + + def _generate_report(self, session): + suite_stop_time = time.time() + suite_time_delta = suite_stop_time - self.suite_start_time + numtests = self.passed + self.failed + self.xpassed + self.xfailed + generated = datetime.datetime.now() + + with open( + os.path.join(os.path.dirname(__file__), "resources", "style.css") + ) as style_css_fp: + self.style_css = style_css_fp.read() + + if ansi_support(): + ansi_css = [ + "\n/******************************", + " * ANSI2HTML STYLES", + " ******************************/\n", + ] + ansi_css.extend([str(r) for r in ansi_support().style.get_styles()]) + self.style_css += "\n".join(ansi_css) + + # Add user-provided CSS + for path in self.config.getoption("css"): + self.style_css += "\n/******************************" + self.style_css += "\n * CUSTOM CSS" + self.style_css += f"\n * {path}" + self.style_css += "\n ******************************/\n\n" + with open(path) as f: + self.style_css += f.read() + + css_href = "assets/style.css" + html_css = html.link(href=css_href, rel="stylesheet", type="text/css") + if self.self_contained: + html_css = html.style(raw(self.style_css)) + + session.config.hook.pytest_html_report_title(report=self) + + head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css) + + outcomes = [ + Outcome("passed", self.passed), + Outcome("skipped", self.skipped), + Outcome("failed", self.failed), + Outcome("error", self.errors, label="errors"), + Outcome("xfailed", self.xfailed, label="expected failures"), + Outcome("xpassed", self.xpassed, label="unexpected passes"), + ] + + if self.rerun is not None: + outcomes.append(Outcome("rerun", self.rerun)) + + summary = [ + html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "), + html.p( + "(Un)check the boxes to filter the results.", + class_="filter", + hidden="true", + ), + ] + + for i, outcome in enumerate(outcomes, start=1): + summary.append(outcome.checkbox) + summary.append(outcome.summary_item) + if i < len(outcomes): + summary.append(", ") + + cells = [ + html.th("Result", class_="sortable result initial-sort", col="result"), + html.th("Test", class_="sortable", col="name"), + html.th("Duration", class_="sortable", col="duration"), + html.th("Links", class_="sortable links", col="links"), + ] + session.config.hook.pytest_html_results_table_header(cells=cells) + + results = [ + html.h2("Results"), + html.table( + [ + html.thead( + html.tr(cells), + html.tr( + [ + html.th( + "No results found. Try to check the filters", + colspan=len(cells), + ) + ], + id="not-found-message", + hidden="true", + ), + id="results-table-head", + ), + self.test_logs, + ], + id="results-table", + ), + ] + + with open( + os.path.join(os.path.dirname(__file__), "resources", "main.js") + ) as main_js_fp: + main_js = main_js_fp.read() + + body = html.body( + html.script(raw(main_js)), + html.h1(self.title), + html.p( + "Report generated on {} at {} by ".format( + generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S") + ), + html.a("pytest-html", href=__pypi_url__), + f" v{__version__}", + ), + onLoad="init()", + ) + + body.extend(self._generate_environment(session.config)) + + summary_prefix, summary_postfix = [], [] + session.config.hook.pytest_html_results_summary( + prefix=summary_prefix, summary=summary, postfix=summary_postfix + ) + body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix) + + body.extend(results) + + doc = html.html(head, body) + + unicode_doc = "\n{}".format(doc.unicode(indent=2)) + + # Fix encoding issues, e.g. with surrogates + unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace") + return unicode_doc.decode("utf-8") + + def _generate_environment(self, config): + if not hasattr(config, "_metadata") or config._metadata is None: + return [] + + metadata = config._metadata + environment = [html.h2("Environment")] + rows = [] + + keys = [k for k in metadata.keys()] + if not isinstance(metadata, OrderedDict): + keys.sort() + + for key in keys: + value = metadata[key] + if isinstance(value, str) and value.startswith("http"): + value = html.a(value, href=value, target="_blank") + elif isinstance(value, (list, tuple, set)): + value = ", ".join(str(i) for i in sorted(map(str, value))) + elif isinstance(value, dict): + sorted_dict = {k: value[k] for k in sorted(value)} + value = json.dumps(sorted_dict) + raw_value_string = raw(str(value)) + rows.append(html.tr(html.td(key), html.td(raw_value_string))) + + environment.append(html.table(rows, id="environment")) + return environment + + def _save_report(self, report_content): + dir_name = os.path.dirname(self.logfile) + assets_dir = os.path.join(dir_name, "assets") + + os.makedirs(dir_name, exist_ok=True) + if not self.self_contained: + os.makedirs(assets_dir, exist_ok=True) + + with open(self.logfile, "w", encoding="utf-8") as f: + f.write(report_content) + if not self.self_contained: + style_path = os.path.join(assets_dir, "style.css") + with open(style_path, "w", encoding="utf-8") as f: + f.write(self.style_css) + + def _post_process_reports(self): + for test_name, test_reports in self.reports.items(): + report_outcome = "passed" + wasxfail = False + failure_when = None + full_text = "" + extras = [] + duration = 0.0 + + # in theory the last one should have all logs so we just go + # through them all to figure out the outcome, xfail, duration, + # extras, and when it swapped from pass + for test_report in test_reports: + if test_report.outcome == "rerun": + # reruns are separate test runs for all intensive purposes + self.append_rerun(test_report) + else: + full_text += test_report.longreprtext + extras.extend(getattr(test_report, "extra", [])) + duration += getattr(test_report, "duration", 0.0) + + if ( + test_report.outcome not in ("passed", "rerun") + and report_outcome == "passed" + ): + report_outcome = test_report.outcome + failure_when = test_report.when + + if hasattr(test_report, "wasxfail"): + wasxfail = True + + # the following test_report. = settings come at the end of us + # looping through all test_reports that make up a single + # case. + + # outcome on the right comes from the outcome of the various + # test_reports that make up this test case + # we are just carrying it over to the final report. + test_report.outcome = report_outcome + test_report.when = "call" + test_report.nodeid = test_name + test_report.longrepr = full_text + test_report.extra = extras + test_report.duration = duration + + if wasxfail: + test_report.wasxfail = True + + if test_report.outcome == "passed": + self.append_passed(test_report) + elif test_report.outcome == "skipped": + self.append_skipped(test_report) + elif test_report.outcome == "failed": + test_report.when = failure_when + self.append_failed(test_report) + + def pytest_runtest_logreport(self, report): + self.reports[report.nodeid].append(report) + + def pytest_collectreport(self, report): + if report.failed: + self.append_failed(report) + + def pytest_sessionstart(self, session): + self.suite_start_time = time.time() + + def pytest_sessionfinish(self, session): + self._post_process_reports() + report_content = self._generate_report(session) + self._save_report(report_content) + + def pytest_terminal_summary(self, terminalreporter): + terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}") diff --git a/src/pytest_html/outcome.py b/src/pytest_html/outcome.py new file mode 100644 index 00000000..1bb71acd --- /dev/null +++ b/src/pytest_html/outcome.py @@ -0,0 +1,33 @@ +from py.xml import html + + +class Outcome: + def __init__(self, outcome, total=0, label=None, test_result=None, class_html=None): + self.outcome = outcome + self.label = label or outcome + self.class_html = class_html or outcome + self.total = total + self.test_result = test_result or outcome + + self.generate_checkbox() + self.generate_summary_item() + + def generate_checkbox(self): + checkbox_kwargs = {"data-test-result": self.test_result.lower()} + if self.total == 0: + checkbox_kwargs["disabled"] = "true" + + self.checkbox = html.input( + type="checkbox", + checked="true", + onChange="filterTable(this)", + name="filter_checkbox", + class_="filter", + hidden="true", + **checkbox_kwargs, + ) + + def generate_summary_item(self): + self.summary_item = html.span( + f"{self.total} {self.label}", class_=self.class_html + ) diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index b89d2b56..6a0ef7a4 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -1,40 +1,12 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -import bisect -import datetime -import importlib -import json import os -import re -import time -import warnings -from base64 import b64decode -from base64 import b64encode -from collections import defaultdict -from collections import OrderedDict -from functools import lru_cache -from html import escape -from os.path import isfile import pytest -from _pytest.logging import _remove_ansi_escape_sequences -from py.xml import html -from py.xml import raw -from . import __pypi_url__ -from . import __version__ -from . import extras - - -@lru_cache() -def ansi_support(): - try: - # from ansi2html import Ansi2HTMLConverter, style # NOQA - return importlib.import_module("ansi2html") - except ImportError: - # ansi2html is not installed - pass +from . import extras # noqa: F401 +from .html_report import HTMLReport def pytest_addhooks(pluginmanager): @@ -136,624 +108,3 @@ def test_foo(extra): pytestconfig.extras = [] yield pytestconfig.extras del pytestconfig.extras[:] - - -def data_uri(content, mime_type="text/plain", charset="utf-8"): - data = b64encode(content.encode(charset)).decode("ascii") - return f"data:{mime_type};charset={charset};base64,{data}" - - -class HTMLReport: - def __init__(self, logfile, config): - logfile = os.path.expanduser(os.path.expandvars(logfile)) - self.logfile = os.path.abspath(logfile) - self.test_logs = [] - self.title = os.path.basename(self.logfile) - self.results = [] - self.errors = self.failed = 0 - self.passed = self.skipped = 0 - self.xfailed = self.xpassed = 0 - has_rerun = config.pluginmanager.hasplugin("rerunfailures") - self.rerun = 0 if has_rerun else None - self.self_contained = config.getoption("self_contained_html") - self.config = config - self.reports = defaultdict(list) - - class TestResult: - def __init__(self, outcome, report, logfile, config): - self.test_id = report.nodeid.encode("utf-8").decode("unicode_escape") - if getattr(report, "when", "call") != "call": - self.test_id = "::".join([report.nodeid, report.when]) - self.time = getattr(report, "duration", 0.0) - self.formatted_time = self._format_time(report) - self.outcome = outcome - self.additional_html = [] - self.links_html = [] - self.self_contained = config.getoption("self_contained_html") - self.max_asset_filename_length = int( - config.getini("max_asset_filename_length") - ) - self.logfile = logfile - self.config = config - self.row_table = self.row_extra = None - - test_index = hasattr(report, "rerun") and report.rerun + 1 or 0 - - for extra_index, extra in enumerate(getattr(report, "extra", [])): - self.append_extra_html(extra, extra_index, test_index) - - self.append_log_html( - report, - self.additional_html, - config.option.capture, - config.option.showcapture, - ) - - cells = [ - html.td(self.outcome, class_="col-result"), - html.td(self.test_id, class_="col-name"), - html.td(self.formatted_time, class_="col-duration"), - html.td(self.links_html, class_="col-links"), - ] - - self.config.hook.pytest_html_results_table_row(report=report, cells=cells) - - self.config.hook.pytest_html_results_table_html( - report=report, data=self.additional_html - ) - - if len(cells) > 0: - tr_class = None - if self.config.getini("render_collapsed"): - tr_class = "collapsed" - self.row_table = html.tr(cells) - self.row_extra = html.tr( - html.td(self.additional_html, class_="extra", colspan=len(cells)), - class_=tr_class, - ) - - def __lt__(self, other): - order = ( - "Error", - "Failed", - "Rerun", - "XFailed", - "XPassed", - "Skipped", - "Passed", - ) - return order.index(self.outcome) < order.index(other.outcome) - - def create_asset( - self, content, extra_index, test_index, file_extension, mode="w" - ): - asset_file_name = "{}_{}_{}.{}".format( - re.sub(r"[^\w\.]", "_", self.test_id), - str(extra_index), - str(test_index), - file_extension, - )[-self.max_asset_filename_length :] - asset_path = os.path.join( - os.path.dirname(self.logfile), "assets", asset_file_name - ) - - os.makedirs(os.path.dirname(asset_path), exist_ok=True) - - relative_path = f"assets/{asset_file_name}" - - kwargs = {"encoding": "utf-8"} if "b" not in mode else {} - with open(asset_path, mode, **kwargs) as f: - f.write(content) - return relative_path - - def append_extra_html(self, extra, extra_index, test_index): - href = None - if extra.get("format_type") == extras.FORMAT_IMAGE: - self._append_image(extra, extra_index, test_index) - - elif extra.get("format_type") == extras.FORMAT_HTML: - self.additional_html.append(html.div(raw(extra.get("content")))) - - elif extra.get("format_type") == extras.FORMAT_JSON: - content = json.dumps(extra.get("content")) - if self.self_contained: - href = data_uri(content, mime_type=extra.get("mime_type")) - else: - href = self.create_asset( - content, extra_index, test_index, extra.get("extension") - ) - - elif extra.get("format_type") == extras.FORMAT_TEXT: - content = extra.get("content") - if isinstance(content, bytes): - content = content.decode("utf-8") - if self.self_contained: - href = data_uri(content) - else: - href = self.create_asset( - content, extra_index, test_index, extra.get("extension") - ) - - elif extra.get("format_type") == extras.FORMAT_URL: - href = extra.get("content") - - elif extra.get("format_type") == extras.FORMAT_VIDEO: - self._append_video(extra, extra_index, test_index) - - if href is not None: - self.links_html.append( - html.a( - extra.get("name"), - class_=extra.get("format_type"), - href=href, - target="_blank", - ) - ) - self.links_html.append(" ") - - def _format_time(self, report): - # parse the report duration into its display version and return - # it to the caller - duration = getattr(report, "duration", None) - if duration is None: - return "" - - duration_formatter = getattr(report, "duration_formatter", None) - string_duration = str(duration) - if duration_formatter is None: - if "." in string_duration: - split_duration = string_duration.split(".") - split_duration[1] = split_duration[1][0:2] - - string_duration = ".".join(split_duration) - - return string_duration - else: - # support %f, since time.strftime doesn't support it out of the box - # keep a precision of 2 for legacy reasons - formatted_milliseconds = "00" - if "." in string_duration: - milliseconds = string_duration.split(".")[1] - formatted_milliseconds = milliseconds[0:2] - - duration_formatter = duration_formatter.replace( - "%f", formatted_milliseconds - ) - duration_as_gmtime = time.gmtime(report.duration) - return time.strftime(duration_formatter, duration_as_gmtime) - - def _populate_html_log_div(self, log, report): - if report.longrepr: - # longreprtext is only filled out on failure by pytest - # otherwise will be None. - # Use full_text if longreprtext is None-ish - # we added full_text elsewhere in this file. - text = report.longreprtext or report.full_text - for line in text.splitlines(): - separator = line.startswith("_ " * 10) - if separator: - log.append(line[:80]) - else: - exception = line.startswith("E ") - if exception: - log.append(html.span(raw(escape(line)), class_="error")) - else: - log.append(raw(escape(line))) - log.append(html.br()) - - for section in report.sections: - header, content = map(escape, section) - log.append(f" {header:-^80} ") - log.append(html.br()) - - if ansi_support(): - converter = ansi_support().Ansi2HTMLConverter( - inline=False, escaped=False - ) - content = converter.convert(content, full=False) - else: - content = _remove_ansi_escape_sequences(content) - - log.append(raw(content)) - log.append(html.br()) - - def append_log_html( - self, - report, - additional_html, - pytest_capture_value, - pytest_show_capture_value, - ): - log = html.div(class_="log") - - should_skip_captured_output = pytest_capture_value == "no" - if report.outcome == "failed" and not should_skip_captured_output: - should_skip_captured_output = pytest_show_capture_value == "no" - if not should_skip_captured_output: - self._populate_html_log_div(log, report) - - if len(log) == 0: - log = html.div(class_="empty log") - log.append("No log output captured.") - - additional_html.append(log) - - def _make_media_html_div( - self, extra, extra_index, test_index, base_extra_string, base_extra_class - ): - content = extra.get("content") - try: - is_uri_or_path = content.startswith(("file", "http")) or isfile(content) - except ValueError: - # On Windows, os.path.isfile throws this exception when - # passed a b64 encoded image. - is_uri_or_path = False - if is_uri_or_path: - if self.self_contained: - warnings.warn( - "Self-contained HTML report " - "includes link to external " - f"resource: {content}" - ) - - html_div = html.a( - raw(base_extra_string.format(extra.get("content"))), href=content - ) - elif self.self_contained: - src = f"data:{extra.get('mime_type')};base64,{content}" - html_div = raw(base_extra_string.format(src)) - else: - content = b64decode(content.encode("utf-8")) - href = src = self.create_asset( - content, extra_index, test_index, extra.get("extension"), "wb" - ) - html_div = html.a( - raw(base_extra_string.format(src)), - class_=base_extra_class, - target="_blank", - href=href, - ) - return html_div - - def _append_image(self, extra, extra_index, test_index): - image_base = '' - html_div = self._make_media_html_div( - extra, extra_index, test_index, image_base, "image" - ) - self.additional_html.append(html.div(html_div, class_="image")) - - def _append_video(self, extra, extra_index, test_index): - video_base = '' - html_div = self._make_media_html_div( - extra, extra_index, test_index, video_base, "video" - ) - self.additional_html.append(html.div(html_div, class_="video")) - - def _appendrow(self, outcome, report): - result = self.TestResult(outcome, report, self.logfile, self.config) - if result.row_table is not None: - index = bisect.bisect_right(self.results, result) - self.results.insert(index, result) - tbody = html.tbody( - result.row_table, - class_="{} results-table-row".format(result.outcome.lower()), - ) - if result.row_extra is not None: - tbody.append(result.row_extra) - self.test_logs.insert(index, tbody) - - def append_passed(self, report): - if report.when == "call": - if hasattr(report, "wasxfail"): - self.xpassed += 1 - self._appendrow("XPassed", report) - else: - self.passed += 1 - self._appendrow("Passed", report) - - def append_failed(self, report): - if getattr(report, "when", None) == "call": - if hasattr(report, "wasxfail"): - # pytest < 3.0 marked xpasses as failures - self.xpassed += 1 - self._appendrow("XPassed", report) - else: - self.failed += 1 - self._appendrow("Failed", report) - else: - self.errors += 1 - self._appendrow("Error", report) - - def append_rerun(self, report): - self.rerun += 1 - self._appendrow("Rerun", report) - - def append_skipped(self, report): - if hasattr(report, "wasxfail"): - self.xfailed += 1 - self._appendrow("XFailed", report) - else: - self.skipped += 1 - self._appendrow("Skipped", report) - - def _generate_report(self, session): - suite_stop_time = time.time() - suite_time_delta = suite_stop_time - self.suite_start_time - numtests = self.passed + self.failed + self.xpassed + self.xfailed - generated = datetime.datetime.now() - - with open( - os.path.join(os.path.dirname(__file__), "resources", "style.css") - ) as style_css_fp: - self.style_css = style_css_fp.read() - - if ansi_support(): - ansi_css = [ - "\n/******************************", - " * ANSI2HTML STYLES", - " ******************************/\n", - ] - ansi_css.extend([str(r) for r in ansi_support().style.get_styles()]) - self.style_css += "\n".join(ansi_css) - - # Add user-provided CSS - for path in self.config.getoption("css"): - self.style_css += "\n/******************************" - self.style_css += "\n * CUSTOM CSS" - self.style_css += f"\n * {path}" - self.style_css += "\n ******************************/\n\n" - with open(path) as f: - self.style_css += f.read() - - css_href = "assets/style.css" - html_css = html.link(href=css_href, rel="stylesheet", type="text/css") - if self.self_contained: - html_css = html.style(raw(self.style_css)) - - session.config.hook.pytest_html_report_title(report=self) - - head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css) - - class Outcome: - def __init__( - self, outcome, total=0, label=None, test_result=None, class_html=None - ): - self.outcome = outcome - self.label = label or outcome - self.class_html = class_html or outcome - self.total = total - self.test_result = test_result or outcome - - self.generate_checkbox() - self.generate_summary_item() - - def generate_checkbox(self): - checkbox_kwargs = {"data-test-result": self.test_result.lower()} - if self.total == 0: - checkbox_kwargs["disabled"] = "true" - - self.checkbox = html.input( - type="checkbox", - checked="true", - onChange="filterTable(this)", - name="filter_checkbox", - class_="filter", - hidden="true", - **checkbox_kwargs, - ) - - def generate_summary_item(self): - self.summary_item = html.span( - f"{self.total} {self.label}", class_=self.class_html - ) - - outcomes = [ - Outcome("passed", self.passed), - Outcome("skipped", self.skipped), - Outcome("failed", self.failed), - Outcome("error", self.errors, label="errors"), - Outcome("xfailed", self.xfailed, label="expected failures"), - Outcome("xpassed", self.xpassed, label="unexpected passes"), - ] - - if self.rerun is not None: - outcomes.append(Outcome("rerun", self.rerun)) - - summary = [ - html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "), - html.p( - "(Un)check the boxes to filter the results.", - class_="filter", - hidden="true", - ), - ] - - for i, outcome in enumerate(outcomes, start=1): - summary.append(outcome.checkbox) - summary.append(outcome.summary_item) - if i < len(outcomes): - summary.append(", ") - - cells = [ - html.th("Result", class_="sortable result initial-sort", col="result"), - html.th("Test", class_="sortable", col="name"), - html.th("Duration", class_="sortable", col="duration"), - html.th("Links", class_="sortable links", col="links"), - ] - session.config.hook.pytest_html_results_table_header(cells=cells) - - results = [ - html.h2("Results"), - html.table( - [ - html.thead( - html.tr(cells), - html.tr( - [ - html.th( - "No results found. Try to check the filters", - colspan=len(cells), - ) - ], - id="not-found-message", - hidden="true", - ), - id="results-table-head", - ), - self.test_logs, - ], - id="results-table", - ), - ] - - with open( - os.path.join(os.path.dirname(__file__), "resources", "main.js") - ) as main_js_fp: - main_js = main_js_fp.read() - - body = html.body( - html.script(raw(main_js)), - html.h1(self.title), - html.p( - "Report generated on {} at {} by ".format( - generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S") - ), - html.a("pytest-html", href=__pypi_url__), - f" v{__version__}", - ), - onLoad="init()", - ) - - body.extend(self._generate_environment(session.config)) - - summary_prefix, summary_postfix = [], [] - session.config.hook.pytest_html_results_summary( - prefix=summary_prefix, summary=summary, postfix=summary_postfix - ) - body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix) - - body.extend(results) - - doc = html.html(head, body) - - unicode_doc = "\n{}".format(doc.unicode(indent=2)) - - # Fix encoding issues, e.g. with surrogates - unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace") - return unicode_doc.decode("utf-8") - - def _generate_environment(self, config): - if not hasattr(config, "_metadata") or config._metadata is None: - return [] - - metadata = config._metadata - environment = [html.h2("Environment")] - rows = [] - - keys = [k for k in metadata.keys()] - if not isinstance(metadata, OrderedDict): - keys.sort() - - for key in keys: - value = metadata[key] - if isinstance(value, str) and value.startswith("http"): - value = html.a(value, href=value, target="_blank") - elif isinstance(value, (list, tuple, set)): - value = ", ".join(str(i) for i in sorted(map(str, value))) - elif isinstance(value, dict): - sorted_dict = {k: value[k] for k in sorted(value)} - value = json.dumps(sorted_dict) - raw_value_string = raw(str(value)) - rows.append(html.tr(html.td(key), html.td(raw_value_string))) - - environment.append(html.table(rows, id="environment")) - return environment - - def _save_report(self, report_content): - dir_name = os.path.dirname(self.logfile) - assets_dir = os.path.join(dir_name, "assets") - - os.makedirs(dir_name, exist_ok=True) - if not self.self_contained: - os.makedirs(assets_dir, exist_ok=True) - - with open(self.logfile, "w", encoding="utf-8") as f: - f.write(report_content) - if not self.self_contained: - style_path = os.path.join(assets_dir, "style.css") - with open(style_path, "w", encoding="utf-8") as f: - f.write(self.style_css) - - def _post_process_reports(self): - for test_name, test_reports in self.reports.items(): - outcome = "passed" - wasxfail = False - failure_when = None - full_text = "" - extras = [] - duration = 0.0 - - # in theory the last one should have all logs so we just go - # through them all to figure out the outcome, xfail, duration, - # extras, and when it swapped from pass - for test_report in test_reports: - if test_report.outcome == "rerun": - # reruns are separate test runs for all intensive purposes - self.append_rerun(test_report) - else: - full_text += test_report.longreprtext - extras.extend(getattr(test_report, "extra", [])) - duration += getattr(test_report, "duration", 0.0) - - if ( - test_report.outcome not in ("passed", "rerun") - and outcome == "passed" - ): - outcome = test_report.outcome - failure_when = test_report.when - - if hasattr(test_report, "wasxfail"): - wasxfail = True - - # the following test_report. = settings come at the end of us - # looping through all test_reports that make up a single - # case. - - # outcome on the right comes from the outcome of the various - # test_reports that make up this test case - # we are just carrying it over to the final report. - test_report.outcome = outcome - test_report.when = "call" - test_report.nodeid = test_name - test_report.longrepr = full_text - test_report.extra = extras - test_report.duration = duration - - if wasxfail: - test_report.wasxfail = True - - if test_report.outcome == "passed": - self.append_passed(test_report) - elif test_report.outcome == "skipped": - self.append_skipped(test_report) - elif test_report.outcome == "failed": - test_report.when = failure_when - self.append_failed(test_report) - - def pytest_runtest_logreport(self, report): - self.reports[report.nodeid].append(report) - - def pytest_collectreport(self, report): - if report.failed: - self.append_failed(report) - - def pytest_sessionstart(self, session): - self.suite_start_time = time.time() - - def pytest_sessionfinish(self, session): - self._post_process_reports() - report_content = self._generate_report(session) - self._save_report(report_content) - - def pytest_terminal_summary(self, terminalreporter): - terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}") diff --git a/src/pytest_html/result.py b/src/pytest_html/result.py new file mode 100644 index 00000000..f791e6d7 --- /dev/null +++ b/src/pytest_html/result.py @@ -0,0 +1,287 @@ +import json +import os +import re +import time +import warnings +from base64 import b64decode +from base64 import b64encode +from html import escape +from os.path import isfile + +from _pytest.logging import _remove_ansi_escape_sequences +from py.xml import html +from py.xml import raw + +from . import extras +from .util import ansi_support + + +class TestResult: + def __init__(self, outcome, report, logfile, config): + self.test_id = report.nodeid.encode("utf-8").decode("unicode_escape") + if getattr(report, "when", "call") != "call": + self.test_id = "::".join([report.nodeid, report.when]) + self.time = getattr(report, "duration", 0.0) + self.formatted_time = self._format_time(report) + self.outcome = outcome + self.additional_html = [] + self.links_html = [] + self.self_contained = config.getoption("self_contained_html") + self.max_asset_filename_length = int(config.getini("max_asset_filename_length")) + self.logfile = logfile + self.config = config + self.row_table = self.row_extra = None + + test_index = hasattr(report, "rerun") and report.rerun + 1 or 0 + + for extra_index, extra in enumerate(getattr(report, "extra", [])): + self.append_extra_html(extra, extra_index, test_index) + + self.append_log_html( + report, + self.additional_html, + config.option.capture, + config.option.showcapture, + ) + + cells = [ + html.td(self.outcome, class_="col-result"), + html.td(self.test_id, class_="col-name"), + html.td(self.formatted_time, class_="col-duration"), + html.td(self.links_html, class_="col-links"), + ] + + self.config.hook.pytest_html_results_table_row(report=report, cells=cells) + + self.config.hook.pytest_html_results_table_html( + report=report, data=self.additional_html + ) + + if len(cells) > 0: + tr_class = None + if self.config.getini("render_collapsed"): + tr_class = "collapsed" + self.row_table = html.tr(cells) + self.row_extra = html.tr( + html.td(self.additional_html, class_="extra", colspan=len(cells)), + class_=tr_class, + ) + + def __lt__(self, other): + order = ( + "Error", + "Failed", + "Rerun", + "XFailed", + "XPassed", + "Skipped", + "Passed", + ) + return order.index(self.outcome) < order.index(other.outcome) + + def create_asset(self, content, extra_index, test_index, file_extension, mode="w"): + asset_file_name = "{}_{}_{}.{}".format( + re.sub(r"[^\w\.]", "_", self.test_id), + str(extra_index), + str(test_index), + file_extension, + )[-self.max_asset_filename_length :] + asset_path = os.path.join( + os.path.dirname(self.logfile), "assets", asset_file_name + ) + + os.makedirs(os.path.dirname(asset_path), exist_ok=True) + + relative_path = f"assets/{asset_file_name}" + + kwargs = {"encoding": "utf-8"} if "b" not in mode else {} + with open(asset_path, mode, **kwargs) as f: + f.write(content) + return relative_path + + def append_extra_html(self, extra, extra_index, test_index): + href = None + if extra.get("format_type") == extras.FORMAT_IMAGE: + self._append_image(extra, extra_index, test_index) + + elif extra.get("format_type") == extras.FORMAT_HTML: + self.additional_html.append(html.div(raw(extra.get("content")))) + + elif extra.get("format_type") == extras.FORMAT_JSON: + content = json.dumps(extra.get("content")) + if self.self_contained: + href = self._data_uri(content, mime_type=extra.get("mime_type")) + else: + href = self.create_asset( + content, extra_index, test_index, extra.get("extension") + ) + + elif extra.get("format_type") == extras.FORMAT_TEXT: + content = extra.get("content") + if isinstance(content, bytes): + content = content.decode("utf-8") + if self.self_contained: + href = self._data_uri(content) + else: + href = self.create_asset( + content, extra_index, test_index, extra.get("extension") + ) + + elif extra.get("format_type") == extras.FORMAT_URL: + href = extra.get("content") + + elif extra.get("format_type") == extras.FORMAT_VIDEO: + self._append_video(extra, extra_index, test_index) + + if href is not None: + self.links_html.append( + html.a( + extra.get("name"), + class_=extra.get("format_type"), + href=href, + target="_blank", + ) + ) + self.links_html.append(" ") + + def _format_time(self, report): + # parse the report duration into its display version and return + # it to the caller + duration = getattr(report, "duration", None) + if duration is None: + return "" + + duration_formatter = getattr(report, "duration_formatter", None) + string_duration = str(duration) + if duration_formatter is None: + if "." in string_duration: + split_duration = string_duration.split(".") + split_duration[1] = split_duration[1][0:2] + + string_duration = ".".join(split_duration) + + return string_duration + else: + # support %f, since time.strftime doesn't support it out of the box + # keep a precision of 2 for legacy reasons + formatted_milliseconds = "00" + if "." in string_duration: + milliseconds = string_duration.split(".")[1] + formatted_milliseconds = milliseconds[0:2] + + duration_formatter = duration_formatter.replace( + "%f", formatted_milliseconds + ) + duration_as_gmtime = time.gmtime(report.duration) + return time.strftime(duration_formatter, duration_as_gmtime) + + def _populate_html_log_div(self, log, report): + if report.longrepr: + # longreprtext is only filled out on failure by pytest + # otherwise will be None. + # Use full_text if longreprtext is None-ish + # we added full_text elsewhere in this file. + text = report.longreprtext or report.full_text + for line in text.splitlines(): + separator = line.startswith("_ " * 10) + if separator: + log.append(line[:80]) + else: + exception = line.startswith("E ") + if exception: + log.append(html.span(raw(escape(line)), class_="error")) + else: + log.append(raw(escape(line))) + log.append(html.br()) + + for section in report.sections: + header, content = map(escape, section) + log.append(f" {header:-^80} ") + log.append(html.br()) + + if ansi_support(): + converter = ansi_support().Ansi2HTMLConverter( + inline=False, escaped=False + ) + content = converter.convert(content, full=False) + else: + content = _remove_ansi_escape_sequences(content) + + log.append(raw(content)) + log.append(html.br()) + + def append_log_html( + self, + report, + additional_html, + pytest_capture_value, + pytest_show_capture_value, + ): + log = html.div(class_="log") + + should_skip_captured_output = pytest_capture_value == "no" + if report.outcome == "failed" and not should_skip_captured_output: + should_skip_captured_output = pytest_show_capture_value == "no" + if not should_skip_captured_output: + self._populate_html_log_div(log, report) + + if len(log) == 0: + log = html.div(class_="empty log") + log.append("No log output captured.") + + additional_html.append(log) + + def _make_media_html_div( + self, extra, extra_index, test_index, base_extra_string, base_extra_class + ): + content = extra.get("content") + try: + is_uri_or_path = content.startswith(("file", "http")) or isfile(content) + except ValueError: + # On Windows, os.path.isfile throws this exception when + # passed a b64 encoded image. + is_uri_or_path = False + if is_uri_or_path: + if self.self_contained: + warnings.warn( + "Self-contained HTML report " + "includes link to external " + f"resource: {content}" + ) + + html_div = html.a( + raw(base_extra_string.format(extra.get("content"))), href=content + ) + elif self.self_contained: + src = f"data:{extra.get('mime_type')};base64,{content}" + html_div = raw(base_extra_string.format(src)) + else: + content = b64decode(content.encode("utf-8")) + href = src = self.create_asset( + content, extra_index, test_index, extra.get("extension"), "wb" + ) + html_div = html.a( + raw(base_extra_string.format(src)), + class_=base_extra_class, + target="_blank", + href=href, + ) + return html_div + + def _append_image(self, extra, extra_index, test_index): + image_base = '' + html_div = self._make_media_html_div( + extra, extra_index, test_index, image_base, "image" + ) + self.additional_html.append(html.div(html_div, class_="image")) + + def _append_video(self, extra, extra_index, test_index): + video_base = '' + html_div = self._make_media_html_div( + extra, extra_index, test_index, video_base, "video" + ) + self.additional_html.append(html.div(html_div, class_="video")) + + def _data_uri(self, content, mime_type="text/plain", charset="utf-8"): + data = b64encode(content.encode(charset)).decode("ascii") + return f"data:{mime_type};charset={charset};base64,{data}" diff --git a/src/pytest_html/util.py b/src/pytest_html/util.py new file mode 100644 index 00000000..37259ec7 --- /dev/null +++ b/src/pytest_html/util.py @@ -0,0 +1,12 @@ +import importlib +from functools import lru_cache + + +@lru_cache() +def ansi_support(): + try: + # from ansi2html import Ansi2HTMLConverter, style # NOQA + return importlib.import_module("ansi2html") + except ImportError: + # ansi2html is not installed + pass diff --git a/testing/test_pytest_html.py b/testing/test_pytest_html.py index 3e0c59bf..b2d23af9 100644 --- a/testing/test_pytest_html.py +++ b/testing/test_pytest_html.py @@ -529,7 +529,7 @@ def pytest_runtest_makereport(item, call): assert f'' in html def test_extra_image_windows(self, mocker, testdir): - mock_isfile = mocker.patch("pytest_html.plugin.isfile") + mock_isfile = mocker.patch("pytest_html.result.isfile") mock_isfile.side_effect = ValueError("stat: path too long for Windows") self.test_extra_image(testdir, "image/png", "png") assert mock_isfile.call_count == 1 @@ -558,7 +558,7 @@ def pytest_runtest_makereport(item, call): ) def test_extra_video_windows(self, mocker, testdir): - mock_isfile = mocker.patch("pytest_html.plugin.isfile") + mock_isfile = mocker.patch("pytest_html.result.isfile") mock_isfile.side_effect = ValueError("stat: path too long for Windows") self.test_extra_video(testdir, "video/mp4", "mp4") assert mock_isfile.call_count == 1 @@ -923,7 +923,8 @@ def test_foo(val): ) def test_ansi_color(self, testdir, mocker, with_ansi): if not with_ansi: - mock_ansi_support = mocker.patch("pytest_html.plugin.ansi_support") + mock_ansi_support = mocker.patch("pytest_html.html_report.ansi_support") + mock_ansi_support = mocker.patch("pytest_html.result.ansi_support") mock_ansi_support.return_value = None pass_content = [ From cc809864592638abe90c5c3a8bd1c03ab3f9970b Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 20 Dec 2020 19:51:36 -0500 Subject: [PATCH 05/19] Implement the visible URL query parameter to control visibility of test results on page load. (#433) * enable control of test result visability via query params * fix typos and query parsing * Add changelog entry * fix type in changelog --- docs/changelog.rst | 4 ++++ docs/user_guide.rst | 26 ++++++++++++++++++++++++++ src/pytest_html/resources/main.js | 12 +++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ceeca76d..b9a929bf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,10 @@ Version History 3.2.0 (unreleased) ~~~~~~~~~~~~~~~~~~ +* Implement the ``visible`` URL query parameter to control visibility of test results on page load. (`#399 `_) + + * Thanks to `@TheCorp `_ for reporting and `@gnikonorov `_ for the fix + * Make the report tab title reflect the report name. (`#412 `_) * Thanks to `@gnikonorov `_ for the PR diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 072c714c..5aedab57 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -234,6 +234,9 @@ additional HTML and log output with a notice that the log is empty: Display options --------------- +Auto Collapsing Table Rows +~~~~~~~~~~~~~~~~~~~~~~~~~~ + By default, all rows in the **Results** table will be expanded except those that have :code:`Passed`. This behavior can be customized either with a query parameter: :code:`?collapsed=Passed,XFailed,Skipped` @@ -246,6 +249,29 @@ or by setting the :code:`render_collapsed` in a configuration file (pytest.ini, **NOTE:** Setting :code:`render_collapsed` will, unlike the query parameter, affect all statuses. +Controlling Test Result Visibility Via Query Params +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, all tests are visible, regardless of their results. It is possible to control which tests are visible on +page load by passing the :code:`visible` query parameter. To use this parameter, please pass a comma separated list +of test results you wish to be visible. For example, passing :code:`?visible=passed,skipped` will show only those +tests in the report that have outcome :code:`passed` or :code:`skipped`. + +Note that this match is case insensitive, so passing :code:`PASSED` and :code:`passed` has the same effect. + +The following query parameters may be passed: + +* :code:`passed` +* :code:`skipped` +* :code:`failed` +* :code:`error` +* :code:`xfailed` +* :code:`xpassed` +* :code:`rerun` + +Formatting the Duration Column +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + The formatting of the timestamp used in the :code:`Durations` column can be modified by setting :code:`duration_formatter` on the :code:`report` attribute. All `time.strftime`_ formatting directives are supported. In addition, it is possible to supply :code:`%f` to get duration milliseconds. If this value is not set, the values in the :code:`Durations` column are diff --git a/src/pytest_html/resources/main.js b/src/pytest_html/resources/main.js index 3b05664a..a2e49d37 100644 --- a/src/pytest_html/resources/main.js +++ b/src/pytest_html/resources/main.js @@ -63,9 +63,19 @@ function hideExtras(colresultElem) { } function showFilters() { + let visibleString = getQueryParameter('visible') || 'all'; + visibleString = visibleString.toLowerCase(); + const checkedItems = visibleString.split(','); + const filterItems = document.getElementsByClassName('filter'); - for (let i = 0; i < filterItems.length; i++) + for (let i = 0; i < filterItems.length; i++) { filterItems[i].hidden = false; + + if (visibleString != 'all') { + filterItems[i].checked = checkedItems.includes(filterItems[i].getAttribute('data-test-result')); + filterTable(filterItems[i]); + } + } } function addCollapse() { From 5e4950b6f47479eb3ce90999474b93c473e5aa9c Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Fri, 22 Jan 2021 22:26:17 -0500 Subject: [PATCH 06/19] Allow for redacting of environment table values (#431) * Add untested change * Add tests * Add documentation * Add the changelog entry * remove debug code from test * Change wording of documentation --- docs/changelog.rst | 4 +++ docs/user_guide.rst | 16 ++++++++-- src/pytest_html/html_report.py | 13 ++++++++ src/pytest_html/plugin.py | 6 ++++ testing/test_pytest_html.py | 55 ++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b9a929bf..e1f52a22 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,10 @@ Version History * Thanks to `@gnikonorov `_ for the PR +* Implement :code:`environment_table_redact_list` to allow for redaction of environment table values. (`#233 `_) + + * Thanks to `@fenchu `_ for reporting and `@gnikonorov `_ for the PR + 3.1.1 (2020-12-13) ~~~~~~~~~~~~~~~~~~ diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 5aedab57..57649250 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -80,8 +80,20 @@ Note that in the above example `@pytest.hookimpl(tryfirst=True)`_ is important, If this line is omitted, then the *Environment* table will **not** be updated since the :code:`pytest_sessionfinish` of the plugins will execute first, and thus not pick up your change. -The generated table will be sorted alphabetically unless the metadata is a -:code:`collections.OrderedDict`. +The generated table will be sorted alphabetically unless the metadata is a :code:`collections.OrderedDict`. + +It is possible to redact variables from the environment table. Redacted variables will have their names displayed, but their values grayed out. +This can be achieved by setting :code:`environment_table_redact_list` in your INI configuration file (e.g.: :code:`pytest.ini`). +: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. + +For example, the following will redact all environment table variables that match the regexes :code:`^foo$`, :code:`.*redact.*`, or :code:`bar`: + +.. code-block:: ini + + [pytest] + environment_table_redact_list = ^foo$ + .*redact.* + bar Additional summary information ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/pytest_html/html_report.py b/src/pytest_html/html_report.py index e1aa3973..66e10f07 100644 --- a/src/pytest_html/html_report.py +++ b/src/pytest_html/html_report.py @@ -2,6 +2,7 @@ import datetime import json import os +import re import time from collections import defaultdict from collections import OrderedDict @@ -226,6 +227,10 @@ def _generate_environment(self, config): for key in keys: value = metadata[key] + if self._is_redactable_environment_variable(key, config): + black_box_ascii_value = 0x2593 + value = "".join(chr(black_box_ascii_value) for char in str(value)) + if isinstance(value, str) and value.startswith("http"): value = html.a(value, href=value, target="_blank") elif isinstance(value, (list, tuple, set)): @@ -239,6 +244,14 @@ def _generate_environment(self, config): environment.append(html.table(rows, id="environment")) return environment + def _is_redactable_environment_variable(self, environment_variable, config): + redactable_regexes = config.getini("environment_table_redact_list") + for redactable_regex in redactable_regexes: + if re.match(redactable_regex, environment_variable): + return True + + return False + def _save_report(self, report_content): dir_name = os.path.dirname(self.logfile) assets_dir = os.path.join(dir_name, "assets") diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index 6a0ef7a4..0034da19 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -53,6 +53,12 @@ def pytest_addoption(parser): help="set the maximum filename length for assets " "attached to the html report.", ) + parser.addini( + "environment_table_redact_list", + type="linelist", + help="A list of regexes corresponding to environment " + "table variables whose values should be redacted from the report", + ) def pytest_configure(config): diff --git a/testing/test_pytest_html.py b/testing/test_pytest_html.py index b2d23af9..69341caf 100644 --- a/testing/test_pytest_html.py +++ b/testing/test_pytest_html.py @@ -1209,3 +1209,58 @@ def test_show_capture_no(): assert extra_log_div_regex.search(html) is not None else: assert extra_log_div_regex.search(html) is None + + def test_environment_table_redact_list(self, testdir): + testdir.makeini( + """ + [pytest] + environment_table_redact_list = ^foo$ + .*redact.* + bar + """ + ) + + testdir.makeconftest( + """ + def pytest_configure(config): + config._metadata["foo"] = "will not appear a" + config._metadata["afoo"] = "will appear" + config._metadata["foos"] = "will appear" + config._metadata["redact"] = "will not appear ab" + config._metadata["will_redact"] = "will not appear abc" + config._metadata["redacted_item"] = "will not appear abcd" + config._metadata["unrelated_item"] = "will appear" + config._metadata["bar"] = "will not appear abcde" + config._metadata["bars"] = "will not appear abcdef" + """ + ) + + testdir.makepyfile( + """ + def test_pass(): + assert True + """ + ) + + result, html = run(testdir) + assert result.ret == 0 + assert_results(html) + + black_box_ascii_value = 0x2593 + expected_environment_values = { + "foo": "".join(chr(black_box_ascii_value) for value in range(17)), + "afoo": "will appear", + "foos": "will appear", + "redact": "".join(chr(black_box_ascii_value) for value in range(18)), + "will_redact": "".join(chr(black_box_ascii_value) for value in range(19)), + "redacted_item": "".join(chr(black_box_ascii_value) for value in range(20)), + "unrelated_item": "will appear", + "bar": "".join(chr(black_box_ascii_value) for value in range(21)), + "bars": "".join(chr(black_box_ascii_value) for value in range(22)), + } + for variable in expected_environment_values: + variable_value = expected_environment_values[variable] + variable_value_regex = re.compile( + f"\n.*{variable}\n.*{variable_value}" + ) + assert variable_value_regex.search(html) is not None From 3d139d3da868f35b2f5159f26660a65b8c7dfcf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Mon, 17 Jan 2022 14:19:49 +0100 Subject: [PATCH 07/19] Disable Codecov (#480) * Disable Codecov * Disable pypy3 on mac --- .github/workflows/actions.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 74b4257e..694076a8 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -13,7 +13,7 @@ on: ** pull_request: schedule: - - cron: 1 0 * * * # Run daily at 0:01 UTC + - cron: '1 0 * * *' # Run daily at 0:01 UTC jobs: build_docs: @@ -93,9 +93,10 @@ jobs: name: pypy3-windows python-version: pypy3 - - os: macOS-latest - name: pypy3-mac - python-version: pypy3 + # https://github.com/pytest-dev/pytest-html/issues/482 +# - os: macOS-latest +# name: pypy3-mac +# python-version: pypy3 - os: ubuntu-18.04 name: devel-ubuntu @@ -122,14 +123,16 @@ jobs: - name: Test with tox run: | python -m tox -e ${{ steps.split-matrix-name.outputs._0}}-cov - - name: Upload coverage to codecov - uses: codecov/codecov-action@v1 - with: - fail_ci_if_error: true - file: ./coverage.xml - flags: tests - name: ${{ matrix.py }} - ${{ matrix.os }} - verbose: true + # TODO: https://github.com/pytest-dev/pytest-html/issues/481 +# - name: Upload coverage to codecov +# if: github.event.schedule == '' +# uses: codecov/codecov-action@v2 +# with: +# fail_ci_if_error: true +# file: ./coverage.xml +# flags: tests +# name: ${{ matrix.py }} - ${{ matrix.os }} +# verbose: true build_javascript: name: grunt runs-on: ubuntu-18.04 From c7c50edee459ca1b90a2791390153172bcc917de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Mon, 17 Jan 2022 17:26:24 +0100 Subject: [PATCH 08/19] Add Tests.yml reusable workflow (#484) --- .github/workflows/tests.yml | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..2b9c78ef --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,132 @@ +name: Tests + +on: + workflow_call: + +jobs: + test_python: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: py36-ubuntu + python-version: 3.6 + + - os: windows-latest + name: py36-windows + python-version: 3.6 + + - os: macOS-latest + name: py36-mac + python-version: 3.6 + + - os: ubuntu-latest + name: py37-ubuntu + python-version: 3.7 + + - os: windows-latest + name: py37-windows + python-version: 3.7 + + - os: macOS-latest + name: py37-mac + python-version: 3.7 + + - os: ubuntu-latest + name: py38-ubuntu + python-version: 3.8 + + - os: windows-latest + name: py38-windows + python-version: 3.8 + + - os: macOS-latest + name: py38-mac + python-version: 3.8 + + - os: ubuntu-latest + name: py39-ubuntu + python-version: 3.9 + + - os: windows-latest + name: py39-windows + python-version: 3.9 + + - os: macOS-latest + name: py39-mac + python-version: 3.9 + + - os: ubuntu-latest + name: pypy3-ubuntu + python-version: pypy3 + + - os: windows-latest + name: pypy3-windows + python-version: pypy3 + # https://github.com/pytest-dev/pytest-html/issues/482 +# - os: macOS-latest +# name: pypy3-mac +# python-version: pypy3 + + - os: ubuntu-latest + name: devel-ubuntu + python-version: 3.9 + + steps: + - name: Set Newline Behavior + run : git config --global core.autocrlf false + - uses: actions/checkout@master + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix['python-version'] }} + - name: Install tox + run: python -m pip install --upgrade tox + - name: Get Tox Environment Name From Matrix Name + uses: rishabhgupta/split-by@v1 + id: split-matrix-name + with: + string: '${{ matrix.name }}' + split-by: '-' + - name: Test with tox + run: python -m tox -e ${{ steps.split-matrix-name.outputs._0}}-cov + # TODO: https://github.com/pytest-dev/pytest-html/issues/481 +# - name: Upload coverage to codecov +# if: github.event.schedule == '' +# uses: codecov/codecov-action@v2 +# with: +# fail_ci_if_error: true +# file: ./coverage.xml +# flags: tests +# name: ${{ matrix.py }} - ${{ matrix.os }} +# verbose: true + + test_javascript: + name: grunt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: '12.x' + - name: Install Dependencies + run: npm install + - name: QUnit Tests + run: npm test + + linting: + name: linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install tox + run: python -m pip install --upgrade tox + - name: Lint with tox + run: python -m tox -e linting From 9a978ecb834fd7080001e43b1345c8f5cd1e7e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Mon, 17 Jan 2022 20:04:20 +0100 Subject: [PATCH 09/19] Use the tests reusable workflow (#486) --- .github/workflows/actions.yml | 144 ++-------------------------------- .github/workflows/tests.yml | 1 + 2 files changed, 6 insertions(+), 139 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 694076a8..b8696008 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -26,152 +26,18 @@ jobs: with: python-version: 3.6 - name: Install tox - run: | - python -m pip install --upgrade tox + run: python -m pip install --upgrade tox - name: Build docs with tox - run: | - python -m tox -e docs - build_python: - name: ${{ matrix.name }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-18.04 - name: py36-ubuntu - python-version: 3.6 + run: python -m tox -e docs - - os: windows-latest - name: py36-windows - python-version: 3.6 + tests: + uses: pytest-dev/pytest-html/.github/workflows/tests.yml@master - - os: macOS-latest - name: py36-mac - python-version: 3.6 - - - os: ubuntu-18.04 - name: py37-ubuntu - python-version: 3.7 - - - os: windows-latest - name: py37-windows - python-version: 3.7 - - - os: macOS-latest - name: py37-mac - python-version: 3.7 - - - os: ubuntu-18.04 - name: py38-ubuntu - python-version: 3.8 - - - os: windows-latest - name: py38-windows - python-version: 3.8 - - - os: macOS-latest - name: py38-mac - python-version: 3.8 - - - os: ubuntu-18.04 - name: py39-ubuntu - python-version: 3.9 - - - os: windows-latest - name: py39-windows - python-version: 3.9 - - - os: macOS-latest - name: py39-mac - python-version: 3.9 - - - os: ubuntu-18.04 - name: pypy3-ubuntu - python-version: pypy3 - - - os: windows-latest - name: pypy3-windows - python-version: pypy3 - - # https://github.com/pytest-dev/pytest-html/issues/482 -# - os: macOS-latest -# name: pypy3-mac -# python-version: pypy3 - - - os: ubuntu-18.04 - name: devel-ubuntu - python-version: 3.8 - - steps: - - name: Set Newline Behavior - run : | - git config --global core.autocrlf false - - uses: actions/checkout@master - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix['python-version'] }} - - name: Install tox - run: | - python -m pip install --upgrade tox - - name: Get Tox Environment Name From Matrix Name - uses: rishabhgupta/split-by@v1 - id: split-matrix-name - with: - string: '${{ matrix.name }}' - split-by: '-' - - name: Test with tox - run: | - python -m tox -e ${{ steps.split-matrix-name.outputs._0}}-cov - # TODO: https://github.com/pytest-dev/pytest-html/issues/481 -# - name: Upload coverage to codecov -# if: github.event.schedule == '' -# uses: codecov/codecov-action@v2 -# with: -# fail_ci_if_error: true -# file: ./coverage.xml -# flags: tests -# name: ${{ matrix.py }} - ${{ matrix.os }} -# verbose: true - build_javascript: - name: grunt - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: '12.x' - - name: Install Dependencies - run: | - npm install - - name: QUnit Tests - run: | - npm test - env: - CI: true - linting: - name: linting - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@master - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install tox - run: | - python -m pip install --upgrade tox - - name: Lint with tox - run: | - python -m tox -e linting publish: name: Publish to PyPI registry needs: - - build_python - - build_javascript + - tests runs-on: ubuntu-latest - env: PY_COLORS: 1 TOXENV: packaging diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2b9c78ef..e550cf58 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,7 @@ jobs: - os: windows-latest name: pypy3-windows python-version: pypy3 + # https://github.com/pytest-dev/pytest-html/issues/482 # - os: macOS-latest # name: pypy3-mac From fdcd5c6331861c338e12537313b395300007f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Mon, 17 Jan 2022 21:14:15 +0100 Subject: [PATCH 10/19] Migrate to precommit.ci (#487) --- .github/workflows/tests.yml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e550cf58..42afa78d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -117,17 +117,3 @@ jobs: run: npm install - name: QUnit Tests run: npm test - - linting: - name: linting - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: Install tox - run: python -m pip install --upgrade tox - - name: Lint with tox - run: python -m tox -e linting From 737aa9d21e01c545d3bc1e57d973cc24f494dd41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 21:50:19 +0100 Subject: [PATCH 11/19] Separate Nightly workflow (#488) --- .github/workflows/actions.yml | 6 ++---- .github/workflows/nightly.yml | 9 +++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index b8696008..6b4a4e36 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -1,4 +1,4 @@ -name: gh +name: Main on: create: # is used for publishing to PyPI and TestPyPI @@ -12,13 +12,11 @@ on: - >- ** pull_request: - schedule: - - cron: '1 0 * * *' # Run daily at 0:01 UTC jobs: build_docs: name: Build Docs - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..d97d3156 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,9 @@ +name: Nightly tests + +on: + schedule: + - cron: '1 0 * * *' # Run daily at 0:01 UTC + +jobs: + tests: + uses: pytest-dev/pytest-html/.github/workflows/tests.yml@master From caca5a3e393aebe9206f6a2cf42c66af4c3e7df3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Jan 2022 21:04:52 +0000 Subject: [PATCH 12/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 1 - src/pytest_html/plugin.py | 3 +-- src/pytest_html/resources/index.html | 14 +++++++------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 512272eb..2683f072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,4 +11,3 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] local_scheme = "no-local-version" write_to = "src/pytest_html/__version.py" - diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index fdd0fe6d..3961bcde 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -4,13 +4,12 @@ import os import pytest +from _pytest.pathlib import Path from . import extras # noqa: F401 from .html_report import HTMLReport from .nextgen import NextGenReport -from _pytest.pathlib import Path - def pytest_addhooks(pluginmanager): from . import hooks diff --git a/src/pytest_html/resources/index.html b/src/pytest_html/resources/index.html index 417809e0..f75cf531 100644 --- a/src/pytest_html/resources/index.html +++ b/src/pytest_html/resources/index.html @@ -64,17 +64,17 @@

Environment

Summary

(Un)check the boxes to filter the results.

- - + + - - + + - +
@@ -86,7 +86,7 @@

Summary

Results

- +
@@ -97,4 +97,4 @@

Results

- \ No newline at end of file + From 680fdc120f01016d7b684af2a209efbb67c30dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:34:48 +0100 Subject: [PATCH 13/19] fix main.js conflicts --- src/pytest_html/html_report.py | 2 +- src/pytest_html/resources/main.js | 26 +-- src/pytest_html/resources/old_main.js | 246 ++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 src/pytest_html/resources/old_main.js diff --git a/src/pytest_html/html_report.py b/src/pytest_html/html_report.py index 66e10f07..28afe823 100644 --- a/src/pytest_html/html_report.py +++ b/src/pytest_html/html_report.py @@ -178,7 +178,7 @@ def _generate_report(self, session): ] with open( - os.path.join(os.path.dirname(__file__), "resources", "main.js") + os.path.join(os.path.dirname(__file__), "resources", "old_main.js") ) as main_js_fp: main_js = main_js_fp.read() diff --git a/src/pytest_html/resources/main.js b/src/pytest_html/resources/main.js index b8736f1e..63c26498 100644 --- a/src/pytest_html/resources/main.js +++ b/src/pytest_html/resources/main.js @@ -56,29 +56,13 @@ function hideExtras(colresultElem) { expandcollapse.classList.add('expander'); } -function showFilters() { - let visibleString = getQueryParameter('visible') || 'all'; - visibleString = visibleString.toLowerCase(); - const checkedItems = visibleString.split(','); - - const filterItems = document.getElementsByClassName('filter'); - for (let i = 0; i < filterItems.length; i++) { - filterItems[i].hidden = false; - - if (visibleString != 'all') { - filterItems[i].checked = checkedItems.includes(filterItems[i].getAttribute('data-test-result')); - filterTable(filterItems[i]); - } - } -} - function addCollapse() { // Add links for show/hide all const resulttable = find('table#results-table'); const showhideall = document.createElement('p'); showhideall.innerHTML = - 'Show all details / ' + - 'Hide all details'; + 'Show all details / ' + + 'Hide all details'; resulttable.parentElement.insertBefore(showhideall, resulttable); // Add show/hide link to each result @@ -98,9 +82,9 @@ function addCollapse() { elem.addEventListener('click', function (event) { if ( - event.currentTarget.parentNode.nextElementSibling.classList.contains( - 'collapsed' - ) + event.currentTarget.parentNode.nextElementSibling.classList.contains( + 'collapsed' + ) ) { showExtras(event.currentTarget); } else { diff --git a/src/pytest_html/resources/old_main.js b/src/pytest_html/resources/old_main.js new file mode 100644 index 00000000..f26513c9 --- /dev/null +++ b/src/pytest_html/resources/old_main.js @@ -0,0 +1,246 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +function toArray(iter) { + if (iter === null) { + return null; + } + return Array.prototype.slice.call(iter); +} + +function find(selector, elem) { // eslint-disable-line no-redeclare + if (!elem) { + elem = document; + } + return elem.querySelector(selector); +} + +function findAll(selector, elem) { + if (!elem) { + elem = document; + } + return toArray(elem.querySelectorAll(selector)); +} + +function sortColumn(elem) { + toggleSortStates(elem); + const colIndex = toArray(elem.parentNode.childNodes).indexOf(elem); + let key; + if (elem.classList.contains('result')) { + key = keyResult; + } else if (elem.classList.contains('links')) { + key = keyLink; + } else { + key = keyAlpha; + } + sortTable(elem, key(colIndex)); +} + +function showAllExtras() { // eslint-disable-line no-unused-vars + findAll('.col-result').forEach(showExtras); +} + +function hideAllExtras() { // eslint-disable-line no-unused-vars + findAll('.col-result').forEach(hideExtras); +} + +function showExtras(colresultElem) { + const extras = colresultElem.parentNode.nextElementSibling; + const expandcollapse = colresultElem.firstElementChild; + extras.classList.remove('collapsed'); + expandcollapse.classList.remove('expander'); + expandcollapse.classList.add('collapser'); +} + +function hideExtras(colresultElem) { + const extras = colresultElem.parentNode.nextElementSibling; + const expandcollapse = colresultElem.firstElementChild; + extras.classList.add('collapsed'); + expandcollapse.classList.remove('collapser'); + expandcollapse.classList.add('expander'); +} + +function showFilters() { + let visibleString = getQueryParameter('visible') || 'all'; + visibleString = visibleString.toLowerCase(); + const checkedItems = visibleString.split(','); + + const filterItems = document.getElementsByClassName('filter'); + for (let i = 0; i < filterItems.length; i++) { + filterItems[i].hidden = false; + + if (visibleString != 'all') { + filterItems[i].checked = checkedItems.includes(filterItems[i].getAttribute('data-test-result')); + filterTable(filterItems[i]); + } + } +} + +function addCollapse() { + // Add links for show/hide all + const resulttable = find('table#results-table'); + const showhideall = document.createElement('p'); + showhideall.innerHTML = 'Show all details / ' + + 'Hide all details'; + resulttable.parentElement.insertBefore(showhideall, resulttable); + + // Add show/hide link to each result + findAll('.col-result').forEach(function(elem) { + const collapsed = getQueryParameter('collapsed') || 'Passed'; + const extras = elem.parentNode.nextElementSibling; + const expandcollapse = document.createElement('span'); + if (extras.classList.contains('collapsed')) { + expandcollapse.classList.add('expander'); + } else if (collapsed.includes(elem.innerHTML)) { + extras.classList.add('collapsed'); + expandcollapse.classList.add('expander'); + } else { + expandcollapse.classList.add('collapser'); + } + elem.appendChild(expandcollapse); + + elem.addEventListener('click', function(event) { + if (event.currentTarget.parentNode.nextElementSibling.classList.contains('collapsed')) { + showExtras(event.currentTarget); + } else { + hideExtras(event.currentTarget); + } + }); + }); +} + +function getQueryParameter(name) { + const match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search); + return match && decodeURIComponent(match[1].replace(/\+/g, ' ')); +} + +function init () { // eslint-disable-line no-unused-vars + resetSortHeaders(); + + addCollapse(); + + showFilters(); + + sortColumn(find('.initial-sort')); + + findAll('.sortable').forEach(function(elem) { + elem.addEventListener('click', + function() { + sortColumn(elem); + }, false); + }); +} + +function sortTable(clicked, keyFunc) { + const rows = findAll('.results-table-row'); + const reversed = !clicked.classList.contains('asc'); + const sortedRows = sort(rows, keyFunc, reversed); + /* Whole table is removed here because browsers acts much slower + * when appending existing elements. + */ + const thead = document.getElementById('results-table-head'); + document.getElementById('results-table').remove(); + const parent = document.createElement('table'); + parent.id = 'results-table'; + parent.appendChild(thead); + sortedRows.forEach(function(elem) { + parent.appendChild(elem); + }); + document.getElementsByTagName('BODY')[0].appendChild(parent); +} + +function sort(items, keyFunc, reversed) { + const sortArray = items.map(function(item, i) { + return [keyFunc(item), i]; + }); + + sortArray.sort(function(a, b) { + const keyA = a[0]; + const keyB = b[0]; + + if (keyA == keyB) return 0; + + if (reversed) { + return keyA < keyB ? 1 : -1; + } else { + return keyA > keyB ? 1 : -1; + } + }); + + return sortArray.map(function(item) { + const index = item[1]; + return items[index]; + }); +} + +function keyAlpha(colIndex) { + return function(elem) { + return elem.childNodes[1].childNodes[colIndex].firstChild.data.toLowerCase(); + }; +} + +function keyLink(colIndex) { + return function(elem) { + const dataCell = elem.childNodes[1].childNodes[colIndex].firstChild; + return dataCell == null ? '' : dataCell.innerText.toLowerCase(); + }; +} + +function keyResult(colIndex) { + return function(elem) { + const strings = ['Error', 'Failed', 'Rerun', 'XFailed', 'XPassed', + 'Skipped', 'Passed']; + return strings.indexOf(elem.childNodes[1].childNodes[colIndex].firstChild.data); + }; +} + +function resetSortHeaders() { + findAll('.sort-icon').forEach(function(elem) { + elem.parentNode.removeChild(elem); + }); + findAll('.sortable').forEach(function(elem) { + const icon = document.createElement('div'); + icon.className = 'sort-icon'; + icon.textContent = 'vvv'; + elem.insertBefore(icon, elem.firstChild); + elem.classList.remove('desc', 'active'); + elem.classList.add('asc', 'inactive'); + }); +} + +function toggleSortStates(elem) { + //if active, toggle between asc and desc + if (elem.classList.contains('active')) { + elem.classList.toggle('asc'); + elem.classList.toggle('desc'); + } + + //if inactive, reset all other functions and add ascending active + if (elem.classList.contains('inactive')) { + resetSortHeaders(); + elem.classList.remove('inactive'); + elem.classList.add('active'); + } +} + +function isAllRowsHidden(value) { + return value.hidden == false; +} + +function filterTable(elem) { // eslint-disable-line no-unused-vars + const outcomeAtt = 'data-test-result'; + const outcome = elem.getAttribute(outcomeAtt); + const classOutcome = outcome + ' results-table-row'; + const outcomeRows = document.getElementsByClassName(classOutcome); + + for(let i = 0; i < outcomeRows.length; i++){ + outcomeRows[i].hidden = !elem.checked; + } + + const rows = findAll('.results-table-row').filter(isAllRowsHidden); + const allRowsHidden = rows.length == 0 ? true : false; + const notFoundMessage = document.getElementById('not-found-message'); + notFoundMessage.hidden = !allRowsHidden; +} From b780107c92ea29dbfca3f5fcfbefae4d79ca7104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:37:45 +0100 Subject: [PATCH 14/19] fix js test --- testing/js_test_report.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/js_test_report.html b/testing/js_test_report.html index 5b4fae82..205fab29 100644 --- a/testing/js_test_report.html +++ b/testing/js_test_report.html @@ -12,7 +12,7 @@ - +
From 4a324b714654de8646ea4f3c8cc5e68acbc27452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:41:54 +0100 Subject: [PATCH 15/19] fix resource (main.js) test --- testing/test_pytest_html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_pytest_html.py b/testing/test_pytest_html.py index 69341caf..aaa7bd2e 100644 --- a/testing/test_pytest_html.py +++ b/testing/test_pytest_html.py @@ -369,7 +369,7 @@ def test_resources(self, testdir): assert result.ret == 0 content = pkg_resources.resource_string( - "pytest_html", os.path.join("resources", "main.js") + "pytest_html", os.path.join("resources", "old_main.js") ) content = content.decode("utf-8") assert content From f84c7d4b8158b415a79dcb46709c0cb81dc92eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:45:48 +0100 Subject: [PATCH 16/19] ignore linting old main.js --- .eslintignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .eslintignore diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..1d5235ca --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +old_main.js From ca59818df38fc7b83d2cfe339390c36bd77b172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:47:57 +0100 Subject: [PATCH 17/19] ignore more js-files for now --- .eslintignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.eslintignore b/.eslintignore index 1d5235ca..cf0dabd2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ old_main.js +events.js +dom.js From e70971025db614f1fa122ac3971d2f9b918a1170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:49:43 +0100 Subject: [PATCH 18/19] sigh --- .eslintignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.eslintignore b/.eslintignore index cf0dabd2..a6c7c285 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1 @@ -old_main.js -events.js -dom.js +*.js From 03f7bb8a5c85609dd96043b8a77aaabe84d177af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Tue, 25 Jan 2022 22:52:18 +0100 Subject: [PATCH 19/19] lets skip eslint for now --- .eslintignore | 1 - .pre-commit-config.yaml | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) delete mode 100644 .eslintignore diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index a6c7c285..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -*.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5757483..dcbc8a52 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,13 +37,13 @@ repos: hooks: - id: pyupgrade args: [--py3-plus] - - repo: https://github.com/pre-commit/mirrors-eslint - rev: v7.13.0 - hooks: - - id: eslint - additional_dependencies: - - eslint@7.13.0 - args: [src] +# - repo: https://github.com/pre-commit/mirrors-eslint +# rev: v7.13.0 +# hooks: +# - id: eslint +# additional_dependencies: +# - eslint@7.13.0 +# args: [src] - repo: local hooks: - id: rst