+
Summary
+ {% for p in prefix %}
+ {{ p|safe }}
+ {% endfor %}
+
+
(Un)check the boxes to filter the results.
+
+
+
+ {% for s in summary %}
+ {{ s|safe }}
+ {% endfor %}
+ {% for p in postfix %}
+ {{ p|safe }}
+ {% endfor %}
+
+
+
+
+
diff --git a/src/pytest_html/resources/main.js b/src/pytest_html/resources/main.js
deleted file mode 100644
index a2e49d37..00000000
--- a/src/pytest_html/resources/main.js
+++ /dev/null
@@ -1,246 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-
-function toArray(iter) {
- if (iter === null) {
- return null;
- }
- return Array.prototype.slice.call(iter);
-}
-
-function find(selector, elem) { // eslint-disable-line no-redeclare
- if (!elem) {
- elem = document;
- }
- return elem.querySelector(selector);
-}
-
-function findAll(selector, elem) {
- if (!elem) {
- elem = document;
- }
- return toArray(elem.querySelectorAll(selector));
-}
-
-function sortColumn(elem) {
- toggleSortStates(elem);
- const colIndex = toArray(elem.parentNode.childNodes).indexOf(elem);
- let key;
- if (elem.classList.contains('result')) {
- key = keyResult;
- } else if (elem.classList.contains('links')) {
- key = keyLink;
- } else {
- key = keyAlpha;
- }
- sortTable(elem, key(colIndex));
-}
-
-function showAllExtras() { // eslint-disable-line no-unused-vars
- findAll('.col-result').forEach(showExtras);
-}
-
-function hideAllExtras() { // eslint-disable-line no-unused-vars
- findAll('.col-result').forEach(hideExtras);
-}
-
-function showExtras(colresultElem) {
- const extras = colresultElem.parentNode.nextElementSibling;
- const expandcollapse = colresultElem.firstElementChild;
- extras.classList.remove('collapsed');
- expandcollapse.classList.remove('expander');
- expandcollapse.classList.add('collapser');
-}
-
-function hideExtras(colresultElem) {
- const extras = colresultElem.parentNode.nextElementSibling;
- const expandcollapse = colresultElem.firstElementChild;
- extras.classList.add('collapsed');
- expandcollapse.classList.remove('collapser');
- expandcollapse.classList.add('expander');
-}
-
-function showFilters() {
- let visibleString = getQueryParameter('visible') || 'all';
- visibleString = visibleString.toLowerCase();
- const checkedItems = visibleString.split(',');
-
- const filterItems = document.getElementsByClassName('filter');
- for (let i = 0; i < filterItems.length; i++) {
- filterItems[i].hidden = false;
-
- if (visibleString != 'all') {
- filterItems[i].checked = checkedItems.includes(filterItems[i].getAttribute('data-test-result'));
- filterTable(filterItems[i]);
- }
- }
-}
-
-function addCollapse() {
- // Add links for show/hide all
- const resulttable = find('table#results-table');
- const showhideall = document.createElement('p');
- showhideall.innerHTML = '
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;
-}
diff --git a/src/pytest_html/resources/style.css b/src/pytest_html/resources/style.css
index 3edac88e..20fe0a18 100644
--- a/src/pytest_html/resources/style.css
+++ b/src/pytest_html/resources/style.css
@@ -33,11 +33,16 @@ table {
******************************/
#environment td {
padding: 5px;
- border: 1px solid #E6E6E6;
+ border: 1px solid #e6e6e6;
+ vertical-align: top;
}
#environment tr:nth-child(odd) {
background-color: #f6f6f6;
}
+#environment ul {
+ margin: 0;
+ padding: 0 20px;
+}
/******************************
* TEST RESULT COLORS
@@ -65,6 +70,10 @@ span.xpassed,
color: red;
}
+.col-links__extra {
+ margin-right: 3px;
+}
+
/******************************
* RESULTS TABLE
*
@@ -85,7 +94,7 @@ span.xpassed,
#results-table th,
#results-table td {
padding: 5px;
- border: 1px solid #E6E6E6;
+ border: 1px solid #e6e6e6;
text-align: left;
}
#results-table th {
@@ -110,48 +119,70 @@ span.xpassed,
height: inherit;
}
-div.image {
+div.media {
border: 1px solid #e6e6e6;
float: right;
height: 240px;
- margin-left: 5px;
+ margin: 0 5px;
overflow: hidden;
width: 320px;
}
-div.image img {
- width: 320px;
-}
-div.video {
- border: 1px solid #e6e6e6;
- float: right;
- height: 240px;
- margin-left: 5px;
+.media-container {
+ display: grid;
+ grid-template-columns: 25px auto 25px;
+ align-items: center;
+ flex: 1 1;
overflow: hidden;
- width: 320px;
+ height: 200px;
}
-div.video video {
- overflow: hidden;
- width: 320px;
- height: 240px;
+
+.media-container__nav--right,
+.media-container__nav--left {
+ text-align: center;
+ cursor: pointer;
+}
+
+.media-container__viewport {
+ cursor: pointer;
+ text-align: center;
+ height: inherit;
+}
+.media-container__viewport img,
+.media-container__viewport video {
+ object-fit: cover;
+ width: 100%;
+ max-height: 100%;
+}
+
+.media__name,
+.media__counter {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ flex: 0 0 25px;
+ align-items: center;
}
.collapsed {
display: none;
}
-.expander::after {
- content: " (show details)";
- color: #BBB;
+.col-result {
+ cursor: pointer;
+}
+.col-result:hover::after {
+ color: #bbb;
font-style: italic;
cursor: pointer;
}
-.collapser::after {
+.col-result.collapser:hover::after {
content: " (hide details)";
- color: #BBB;
- font-style: italic;
- cursor: pointer;
+}
+
+.col-result.expander:hover::after {
+ content: " (show details)";
}
/*------------------
@@ -160,27 +191,81 @@ div.video video {
.sortable {
cursor: pointer;
}
+.sortable.asc:after {
+ content: " ";
+ position: relative;
+ left: 5px;
+ bottom: -12.5px;
+ border: 10px solid #4caf50;
+ border-bottom: 0;
+ border-left-color: transparent;
+ border-right-color: transparent;
+}
+.sortable.desc:after {
+ content: " ";
+ position: relative;
+ left: 5px;
+ bottom: 12.5px;
+ border: 10px solid #4caf50;
+ border-top: 0;
+ border-left-color: transparent;
+ border-right-color: transparent;
+}
+
+.hidden, .summary__reload__button.hidden {
+ display: none;
+}
+
+.summary__data {
+ flex: 0 0 550px;
+}
+.summary__reload {
+ flex: 1 1;
+ display: flex;
+ justify-content: center;
+}
+.summary__reload__button {
+ flex: 0 0 300px;
+ display: flex;
+ color: white;
+ font-weight: bold;
+ background-color: #4caf50;
+ text-align: center;
+ justify-content: center;
+ align-items: center;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.summary__reload__button:hover {
+ background-color: #46a049;
+}
+.summary__spacer {
+ flex: 0 0 550px;
+}
+
+.controls {
+ display: flex;
+ justify-content: space-between;
+}
+
+.filters,
+.collapse {
+ display: flex;
+ align-items: center;
+}
+.filters button,
+.collapse button {
+ color: #999;
+ border: none;
+ background: none;
+ cursor: pointer;
+ text-decoration: underline;
+}
+.filters button:hover,
+.collapse button:hover {
+ color: #ccc;
+}
-.sort-icon {
- font-size: 0px;
- float: left;
- margin-right: 5px;
- margin-top: 5px;
- /*triangle*/
- width: 0;
- height: 0;
- border-left: 8px solid transparent;
- border-right: 8px solid transparent;
-}
-.inactive .sort-icon {
- /*finish triangle*/
- border-top: 8px solid #E6E6E6;
-}
-.asc.active .sort-icon {
- /*finish triangle*/
- border-bottom: 8px solid #999;
-}
-.desc.active .sort-icon {
- /*finish triangle*/
- border-top: 8px solid #999;
+.filter__label {
+ margin-right: 10px;
}
diff --git a/src/pytest_html/result.py b/src/pytest_html/result.py
deleted file mode 100644
index a9313c17..00000000
--- a/src/pytest_html/result.py
+++ /dev/null
@@ -1,286 +0,0 @@
-import json
-import re
-import time
-import warnings
-from base64 import b64decode
-from base64 import b64encode
-from html import escape
-from os.path import isfile
-from pathlib import Path
-
-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 = Path(self.logfile).parent / "assets" / asset_file_name
-
- asset_path.parent.mkdir(exist_ok=True, parents=True)
-
- relative_path = f"assets/{asset_file_name}"
-
- kwargs = {"encoding": "utf-8"} if "b" not in mode else {}
- func = asset_path.write_bytes if "b" in mode else asset_path.write_text
- func(content, **kwargs)
-
- 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/scripts/datamanager.js b/src/pytest_html/scripts/datamanager.js
new file mode 100644
index 00000000..25c2c3f8
--- /dev/null
+++ b/src/pytest_html/scripts/datamanager.js
@@ -0,0 +1,57 @@
+const { getCollapsedCategory } = require('./storage.js')
+
+class DataManager {
+ setManager(data) {
+ const collapsedCategories = [...getCollapsedCategory(data.collapsed)]
+ const dataBlob = { ...data, tests: Object.values(data.tests).flat().map((test, index) => ({
+ ...test,
+ id: `test_${index}`,
+ collapsed: collapsedCategories.includes(test.result.toLowerCase()),
+ })) }
+ this.data = { ...dataBlob }
+ this.renderData = { ...dataBlob }
+ }
+
+ get allData() {
+ return { ...this.data }
+ }
+ resetRender() {
+ this.renderData = { ...this.data }
+ }
+ setRender(data) {
+ this.renderData.tests = [...data]
+ }
+ toggleCollapsedItem(id) {
+ this.renderData.tests = this.renderData.tests.map((test) =>
+ test.id === id ? { ...test, collapsed: !test.collapsed } : test,
+ )
+ }
+ set allCollapsed(collapsed) {
+ this.renderData = { ...this.renderData, tests: [...this.renderData.tests.map((test) => (
+ { ...test, collapsed }
+ ))] }
+ }
+
+ get testSubset() {
+ return [...this.renderData.tests]
+ }
+ get allTests() {
+ return [...this.data.tests]
+ }
+ get title() {
+ return this.renderData.title
+ }
+ get environment() {
+ return this.renderData.environment
+ }
+ get collectedItems() {
+ return this.renderData.collectedItems
+ }
+ get isFinished() {
+ return this.data.runningState === 'Finished'
+ }
+}
+
+module.exports = {
+ manager: new DataManager(),
+}
diff --git a/src/pytest_html/scripts/dom.js b/src/pytest_html/scripts/dom.js
new file mode 100644
index 00000000..28ad8c29
--- /dev/null
+++ b/src/pytest_html/scripts/dom.js
@@ -0,0 +1,143 @@
+const storageModule = require('./storage.js')
+const { formatDuration } = require('./utils.js')
+const mediaViewer = require('./mediaviewer.js')
+const templateEnvRow = document.querySelector('#template_environment_row')
+const templateCollGroup = document.querySelector('#template_table-colgroup')
+const templateResult = document.querySelector('#template_results-table__tbody')
+const aTag = document.querySelector('#template_a')
+const listHeader = document.querySelector('#template_results-table__head')
+const listHeaderEmpty = document.querySelector('#template_results-table__head--empty')
+
+function htmlToElements(html) {
+ const temp = document.createElement('template')
+ temp.innerHTML = html
+ return temp.content.childNodes
+}
+
+const find = (selector, elem) => {
+ if (!elem) {
+ elem = document
+ }
+ return elem.querySelector(selector)
+}
+
+const findAll = (selector, elem) => {
+ if (!elem) {
+ elem = document
+ }
+ return [...elem.querySelectorAll(selector)]
+}
+
+const insertAdditionalHTML = (html, element, selector) => {
+ Object.keys(html).map((key) => {
+ element.querySelectorAll(selector).item(key).insertAdjacentHTML('beforebegin', html[key])
+ })
+}
+
+const dom = {
+ getStaticRow: (key, value) => {
+ const envRow = templateEnvRow.content.cloneNode(true)
+ const isObj = typeof value === 'object' && value !== null
+ const values = isObj ? Object.keys(value).map((k) => `${k}: ${value[k]}`) : null
+
+ const valuesElement = htmlToElements(
+ values ? `
${values.map((val) => `${val} `).join('')}` : `${value}
`)[0]
+ const td = findAll('td', envRow)
+ td[0].textContent = key
+ td[1].appendChild(valuesElement)
+
+ return envRow
+ },
+ getListHeader: ({ resultsTableHeader }) => {
+ const header = listHeader.content.cloneNode(true)
+ const sortAttr = storageModule.getSort()
+ const sortAsc = JSON.parse(storageModule.getSortDirection())
+
+ const regex = /data-column-type="(\w+)/
+ const cols = Object.values(resultsTableHeader).reduce((result, value) => {
+ if (value.includes("sortable")) {
+ const matches = regex.exec(value)
+ if (matches) {
+ result.push(matches[1])
+ }
+ }
+ return result
+ }, [])
+ const sortables = ['result', 'testId', 'duration', ...cols]
+
+ // Add custom html from the pytest_html_results_table_header hook
+ insertAdditionalHTML(resultsTableHeader, header, 'th')
+
+ sortables.forEach((sortCol) => {
+ if (sortCol === sortAttr) {
+ header.querySelector(`[data-column-type="${sortCol}"]`).classList.add(sortAsc ? 'desc' : 'asc')
+ }
+ })
+
+ return header
+ },
+ getListHeaderEmpty: () => listHeaderEmpty.content.cloneNode(true),
+ getColGroup: () => templateCollGroup.content.cloneNode(true),
+ getResultTBody: ({ testId, id, log, duration, extras, resultsTableRow, tableHtml, result, collapsed }) => {
+ const resultLower = result.toLowerCase()
+ let formattedDuration = formatDuration(duration)
+ formattedDuration = formatDuration < 1 ? formattedDuration.ms : formattedDuration.formatted
+ const resultBody = templateResult.content.cloneNode(true)
+ resultBody.querySelector('tbody').classList.add(resultLower)
+ resultBody.querySelector('tbody').id = testId
+ resultBody.querySelector('.col-result').innerText = result
+ resultBody.querySelector('.col-result').classList.add(`${collapsed ? 'expander' : 'collapser'}`)
+ resultBody.querySelector('.col-result').dataset.id = id
+ resultBody.querySelector('.col-name').innerText = testId
+
+ resultBody.querySelector('.col-duration').innerText = duration < 1 ? formatDuration(duration).ms : formatDuration(duration).formatted
+
+
+ if (log) {
+ resultBody.querySelector('.log').innerHTML = log
+ } else {
+ resultBody.querySelector('.log').remove()
+ }
+
+ if (collapsed) {
+ resultBody.querySelector('.extras-row').classList.add('hidden')
+ }
+
+ const media = []
+ extras?.forEach(({ name, format_type, content }) => {
+ if (['json', 'text', 'url'].includes(format_type)) {
+ const extraLink = aTag.content.cloneNode(true)
+ const extraLinkItem = extraLink.querySelector('a')
+
+ extraLinkItem.href = content
+ extraLinkItem.className = `col-links__extra ${format_type}`
+ extraLinkItem.innerText = name
+ resultBody.querySelector('.col-links').appendChild(extraLinkItem)
+ }
+
+ if (['image', 'video'].includes(format_type)) {
+ media.push({ path: content, name, format_type })
+ }
+
+ if (format_type === 'html') {
+ resultBody.querySelector('.extraHTML').insertAdjacentHTML('beforeend', `${content}
`)
+ }
+ })
+ mediaViewer.setUp(resultBody, media)
+
+ // Add custom html from the pytest_html_results_table_row hook
+ resultsTableRow && insertAdditionalHTML(resultsTableRow, resultBody, 'td')
+
+ // Add custom html from the pytest_html_results_table_html hook
+ tableHtml?.forEach((item) => {
+ resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item)
+ })
+
+ return resultBody
+ },
+}
+
+exports.dom = dom
+exports.htmlToElements = htmlToElements
+exports.find = find
+exports.findAll = findAll
diff --git a/src/pytest_html/scripts/filter.js b/src/pytest_html/scripts/filter.js
new file mode 100644
index 00000000..99a06bfb
--- /dev/null
+++ b/src/pytest_html/scripts/filter.js
@@ -0,0 +1,33 @@
+const { manager } = require('./datamanager.js')
+const storageModule = require('./storage.js')
+
+const getFilteredSubSet = (filter) =>
+ manager.allData.tests.filter(({ result }) => filter.includes(result.toLowerCase()))
+
+const doInitFilter = () => {
+ const currentFilter = storageModule.getVisible()
+ const filteredSubset = getFilteredSubSet(currentFilter)
+ manager.setRender(filteredSubset)
+}
+
+const doFilter = (type, show) => {
+ if (show) {
+ storageModule.showCategory(type)
+ } else {
+ storageModule.hideCategory(type)
+ }
+
+ const currentFilter = storageModule.getVisible()
+
+ if (currentFilter.length) {
+ const filteredSubset = getFilteredSubSet(currentFilter)
+ manager.setRender(filteredSubset)
+ } else {
+ manager.resetRender()
+ }
+}
+
+module.exports = {
+ doFilter,
+ doInitFilter,
+}
diff --git a/src/pytest_html/scripts/index.js b/src/pytest_html/scripts/index.js
new file mode 100644
index 00000000..7fcab5fe
--- /dev/null
+++ b/src/pytest_html/scripts/index.js
@@ -0,0 +1,15 @@
+const { redraw, bindEvents } = require('./main.js')
+const { doInitFilter } = require('./filter.js')
+const { doInitSort } = require('./sort.js')
+const { manager } = require('./datamanager.js')
+const data = JSON.parse(document.querySelector('#data-container').dataset.jsonblob)
+
+function init() {
+ manager.setManager(data)
+ doInitFilter()
+ doInitSort()
+ redraw()
+ bindEvents()
+}
+
+init()
diff --git a/src/pytest_html/scripts/main.js b/src/pytest_html/scripts/main.js
new file mode 100644
index 00000000..ffff5332
--- /dev/null
+++ b/src/pytest_html/scripts/main.js
@@ -0,0 +1,121 @@
+const { formatDuration } = require('./utils.js')
+const { dom, findAll } = require('./dom.js')
+const { manager } = require('./datamanager.js')
+const { doSort } = require('./sort.js')
+const { doFilter } = require('./filter.js')
+const { getVisible, possibleResults } = require('./storage.js')
+
+const removeChildren = (node) => {
+ while (node.firstChild) {
+ node.removeChild(node.firstChild)
+ }
+}
+
+const renderStatic = () => {
+ const renderTitle = () => {
+ const title = manager.title
+ document.querySelector('#title').innerText = title
+ document.querySelector('#head-title').innerText = title
+ }
+ const renderTable = () => {
+ const environment = manager.environment
+ const rows = Object.keys(environment).map((key) => dom.getStaticRow(key, environment[key]))
+ const table = document.querySelector('#environment')
+ removeChildren(table)
+ rows.forEach((row) => table.appendChild(row))
+ }
+ renderTitle()
+ renderTable()
+}
+
+const renderContent = (tests) => {
+ const rows = tests.map(dom.getResultTBody)
+ const table = document.querySelector('#results-table')
+ removeChildren(table)
+ const tableHeader = dom.getListHeader(manager.renderData)
+ if (!rows.length) {
+ tableHeader.appendChild(dom.getListHeaderEmpty())
+ }
+ table.appendChild(dom.getColGroup())
+ table.appendChild(tableHeader)
+
+ rows.forEach((row) => !!row && table.appendChild(row))
+
+ table.querySelectorAll('.extra').forEach((item) => {
+ item.colSpan = document.querySelectorAll('th').length
+ })
+ findAll('.sortable').forEach((elem) => {
+ elem.addEventListener('click', (evt) => {
+ const { target: element } = evt
+ const { columnType } = element.dataset
+ doSort(columnType)
+ redraw()
+ })
+ })
+ findAll('.col-result').forEach((elem) => {
+ elem.addEventListener('click', ({ target }) => {
+ manager.toggleCollapsedItem(target.dataset.id)
+ redraw()
+ })
+ })
+}
+
+const renderDerived = (tests, collectedItems, isFinished) => {
+ const currentFilter = getVisible()
+ possibleResults.forEach(({ result, label }) => {
+ const count = tests.filter((test) => test.result.toLowerCase() === result).length
+ const input = document.querySelector(`input[data-test-result="${result}"]`)
+ document.querySelector(`.${result}`).innerText = `${count} ${label}`
+
+ input.disabled = !count
+ input.checked = currentFilter.includes(result)
+ })
+
+ const numberOfTests = tests.filter(({ result }) =>
+ ['Passed', 'Failed', 'XPassed', 'XFailed'].includes(result)).length
+
+ if (isFinished) {
+ const accTime = tests.reduce((prev, { duration }) => prev + duration, 0)
+ const formattedAccTime = formatDuration(accTime)
+ const testWord = numberOfTests > 1 ? 'tests' : 'test'
+ const durationText = formattedAccTime.hasOwnProperty('ms') ? formattedAccTime.ms : formattedAccTime.formatted
+
+ document.querySelector('.run-count').innerText = `${numberOfTests} ${testWord} took ${durationText}.`
+ document.querySelector('.summary__reload__button').classList.add('hidden')
+ } else {
+ document.querySelector('.run-count').innerText = `${numberOfTests} / ${collectedItems} tests done`
+ }
+}
+
+const bindEvents = () => {
+ const filterColumn = (evt) => {
+ const { target: element } = evt
+ const { testResult } = element.dataset
+
+ doFilter(testResult, element.checked)
+ redraw()
+ }
+ findAll('input[name="filter_checkbox"]').forEach((elem) => {
+ elem.removeEventListener('click', filterColumn)
+ elem.addEventListener('click', filterColumn)
+ })
+ document.querySelector('#show_all_details').addEventListener('click', () => {
+ manager.allCollapsed = false
+ redraw()
+ })
+ document.querySelector('#hide_all_details').addEventListener('click', () => {
+ manager.allCollapsed = true
+ redraw()
+ })
+}
+
+const redraw = () => {
+ const { testSubset, allTests, collectedItems, isFinished } = manager
+
+ renderStatic()
+ renderContent(testSubset)
+ renderDerived(allTests, collectedItems, isFinished)
+}
+
+exports.redraw = redraw
+exports.bindEvents = bindEvents
diff --git a/src/pytest_html/scripts/mediaviewer.js b/src/pytest_html/scripts/mediaviewer.js
new file mode 100644
index 00000000..8bf27a1f
--- /dev/null
+++ b/src/pytest_html/scripts/mediaviewer.js
@@ -0,0 +1,74 @@
+class MediaViewer {
+ constructor(assets) {
+ this.assets = assets
+ this.index = 0
+ }
+ nextActive() {
+ this.index = this.index === this.assets.length - 1 ? 0 : this.index + 1
+ return [this.activeFile, this.index]
+ }
+ prevActive() {
+ this.index = this.index === 0 ? this.assets.length - 1 : this.index -1
+ return [this.activeFile, this.index]
+ }
+
+ get currentIndex() {
+ return this.index
+ }
+ get activeFile() {
+ return this.assets[this.index]
+ }
+}
+
+
+const setUp = (resultBody, assets) => {
+ if (!assets.length) {
+ resultBody.querySelector('.media').classList.add('hidden')
+ return
+ }
+
+ const mediaViewer = new MediaViewer(assets)
+ const leftArrow = resultBody.querySelector('.media-container__nav--left')
+ const rightArrow = resultBody.querySelector('.media-container__nav--right')
+ const mediaName = resultBody.querySelector('.media__name')
+ const counter = resultBody.querySelector('.media__counter')
+ const imageEl = resultBody.querySelector('img')
+ const sourceEl = resultBody.querySelector('source')
+ const videoEl = resultBody.querySelector('video')
+
+ const setImg = (media, index) => {
+ if (media?.format_type === 'image') {
+ imageEl.src = media.path
+
+ imageEl.classList.remove('hidden')
+ videoEl.classList.add('hidden')
+ } else if (media?.format_type === 'video') {
+ sourceEl.src = media.path
+
+ videoEl.classList.remove('hidden')
+ imageEl.classList.add('hidden')
+ }
+
+ mediaName.innerText = media?.name
+ counter.innerText = `${index + 1} / ${assets.length}`
+ }
+ setImg(mediaViewer.activeFile, mediaViewer.currentIndex)
+
+ const moveLeft = () => {
+ const [media, index] = mediaViewer.prevActive()
+ setImg(media, index)
+ }
+ const doRight = () => {
+ const [media, index] = mediaViewer.nextActive()
+ setImg(media, index)
+ }
+ const openImg = () => {
+ window.open(mediaViewer.activeFile.path, '_blank')
+ }
+
+ leftArrow.addEventListener('click', moveLeft)
+ rightArrow.addEventListener('click', doRight)
+ imageEl.addEventListener('click', openImg)
+}
+
+exports.setUp = setUp
diff --git a/src/pytest_html/scripts/sort.js b/src/pytest_html/scripts/sort.js
new file mode 100644
index 00000000..aee74719
--- /dev/null
+++ b/src/pytest_html/scripts/sort.js
@@ -0,0 +1,34 @@
+const { manager } = require('./datamanager.js')
+const storageModule = require('./storage.js')
+
+const genericSort = (list, key, ascending) => {
+ const sorted = list.sort((a, b) => a[key] === b[key] ? 0 : a[key] > b[key] ? 1 : -1)
+
+ if (ascending) {
+ sorted.reverse()
+ }
+ return sorted
+}
+
+const doInitSort = () => {
+ const type = storageModule.getSort()
+ const ascending = storageModule.getSortDirection()
+ const list = manager.testSubset
+ const sortedList = genericSort(list, type, ascending)
+ manager.setRender(sortedList)
+}
+
+const doSort = (type) => {
+ const newSortType = storageModule.getSort() !== type
+ const currentAsc = storageModule.getSortDirection()
+ const ascending = newSortType ? true : !currentAsc
+ storageModule.setSort(type)
+ storageModule.setSortDirection(ascending)
+ const list = manager.testSubset
+
+ const sortedList = genericSort(list, type, ascending)
+ manager.setRender(sortedList)
+}
+
+exports.doSort = doSort
+exports.doInitSort = doInitSort
diff --git a/src/pytest_html/scripts/storage.js b/src/pytest_html/scripts/storage.js
new file mode 100644
index 00000000..5af8171a
--- /dev/null
+++ b/src/pytest_html/scripts/storage.js
@@ -0,0 +1,102 @@
+const possibleResults = [
+ { result: 'passed', label: 'Passed' },
+ { result: 'skipped', label: 'Skipped' },
+ { result: 'failed', label: 'Failed' },
+ { result: 'error', label: 'Errors' },
+ { result: 'xfailed', label: 'Unexpected failures' },
+ { result: 'xpassed', label: 'Unexpected passes' },
+ { result: 'rerun', label: 'Reruns' },
+]
+const possibleFilters = possibleResults.map((item) => item.result)
+
+const getVisible = () => {
+ const url = new URL(window.location.href)
+ const settings = new URLSearchParams(url.search).get('visible') || ''
+ return settings ?
+ [...new Set(settings.split(',').filter((filter) => possibleFilters.includes(filter)))] : possibleFilters
+}
+const hideCategory = (categoryToHide) => {
+ const url = new URL(window.location.href)
+ const visibleParams = new URLSearchParams(url.search).get('visible')
+ const currentVisible = visibleParams ? visibleParams.split(',') : [...possibleFilters]
+ const settings = [...new Set(currentVisible)].filter((f) => f !== categoryToHide).join(',')
+
+ url.searchParams.set('visible', settings)
+ history.pushState({}, null, unescape(url.href))
+}
+
+const showCategory = (categoryToShow) => {
+ if (typeof window === 'undefined') {
+ return
+ }
+ const url = new URL(window.location.href)
+ const currentVisible = new URLSearchParams(url.search).get('visible')?.split(',') || [...possibleFilters]
+ const settings = [...new Set([categoryToShow, ...currentVisible])]
+ const noFilter = possibleFilters.length === settings.length || !settings.length
+
+ noFilter ? url.searchParams.delete('visible') : url.searchParams.set('visible', settings.join(','))
+ history.pushState({}, null, unescape(url.href))
+}
+const setFilter = (currentFilter) => {
+ if (!possibleFilters.includes(currentFilter)) {
+ return
+ }
+ const url = new URL(window.location.href)
+ const settings = [currentFilter, ...new Set(new URLSearchParams(url.search).get('filter').split(','))]
+
+ url.searchParams.set('filter', settings)
+ history.pushState({}, null, unescape(url.href))
+}
+
+const getSort = () => {
+ const url = new URL(window.location.href)
+ return new URLSearchParams(url.search).get('sort') || 'result'
+}
+const setSort = (type) => {
+ const url = new URL(window.location.href)
+ url.searchParams.set('sort', type)
+ history.pushState({}, null, unescape(url.href))
+}
+
+const getCollapsedCategory = (config) => {
+ let categories
+ if (typeof window !== 'undefined') {
+ const url = new URL(window.location.href)
+ const collapsedItems = new URLSearchParams(url.search).get('collapsed')
+ switch (true) {
+ case !config && collapsedItems === null:
+ categories = ['passed'];
+ break;
+ case collapsedItems?.length === 0 || /^["']{2}$/.test(collapsedItems):
+ categories = [];
+ break;
+ case /^all$/.test(collapsedItems) || (collapsedItems === null && /^all$/.test(config)):
+ categories = [...possibleFilters];
+ break;
+ default:
+ categories = collapsedItems?.split(',').map(item => item.toLowerCase()) || config;
+ break;
+ }
+ } else {
+ categories = []
+ }
+ return categories
+}
+
+const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc'))
+
+const setSortDirection = (ascending) => sessionStorage.setItem('sortAsc', ascending)
+
+module.exports = {
+ getVisible,
+ setFilter,
+ hideCategory,
+ showCategory,
+ getSort,
+ getSortDirection,
+ setSort,
+ setSortDirection,
+ getCollapsedCategory,
+ possibleFilters,
+ possibleResults,
+}
diff --git a/src/pytest_html/scripts/utils.js b/src/pytest_html/scripts/utils.js
new file mode 100644
index 00000000..278ab158
--- /dev/null
+++ b/src/pytest_html/scripts/utils.js
@@ -0,0 +1,24 @@
+const formattedNumber = (number) =>
+ number.toLocaleString('en-US', {
+ minimumIntegerDigits: 2,
+ useGrouping: false,
+ })
+
+const formatDuration = ( totalSeconds ) => {
+ if (totalSeconds < 1) {
+ return {ms: `${Math.round(totalSeconds * 1000)} ms`}
+ }
+
+ const hours = Math.floor(totalSeconds / 3600)
+ let remainingSeconds = totalSeconds % 3600
+ const minutes = Math.floor(remainingSeconds / 60)
+ remainingSeconds = remainingSeconds % 60
+ const seconds = Math.round(remainingSeconds)
+
+ return {
+ seconds: `${Math.round(totalSeconds)} seconds`,
+ formatted: `${formattedNumber(hours)}:${formattedNumber(minutes)}:${formattedNumber(seconds)}`,
+ }
+}
+
+module.exports = { formatDuration }
diff --git a/src/pytest_html/selfcontained_report.py b/src/pytest_html/selfcontained_report.py
new file mode 100644
index 00000000..55fb8556
--- /dev/null
+++ b/src/pytest_html/selfcontained_report.py
@@ -0,0 +1,36 @@
+import base64
+import binascii
+import warnings
+
+from pytest_html.basereport import BaseReport
+
+
+class SelfContainedReport(BaseReport):
+ def __init__(self, report_path, config):
+ super().__init__(report_path, config)
+
+ @property
+ def css(self):
+ return self._css
+
+ def _data_content(self, content, mime_type, *args, **kwargs):
+ charset = "utf-8"
+ data = base64.b64encode(content.encode(charset)).decode(charset)
+ return f"data:{mime_type};charset={charset};base64,{data}"
+
+ def _media_content(self, content, mime_type, *args, **kwargs):
+ try:
+ # test if content is base64
+ base64.b64decode(content.encode("utf-8"), validate=True)
+ return f"data:{mime_type};base64,{content}"
+ except binascii.Error:
+ # if not base64, issue warning and just return as it's a file or link
+ warnings.warn(
+ "Self-contained HTML report "
+ "includes link to external "
+ f"resource: {content}"
+ )
+ return content
+
+ def _generate_report(self, *args, **kwargs):
+ super()._generate_report(self_contained=True)
diff --git a/src/pytest_html/table.py b/src/pytest_html/table.py
new file mode 100644
index 00000000..95f7fa50
--- /dev/null
+++ b/src/pytest_html/table.py
@@ -0,0 +1,59 @@
+import warnings
+
+
+class Table:
+ def __init__(self):
+ self._html = {}
+
+ @property
+ def html(self):
+ return self._html
+
+
+class Html(Table):
+ def __init__(self):
+ super().__init__()
+ self.html.setdefault("html", [])
+ self._replace_log = False
+
+ def __delitem__(self, key):
+ # This means the log should be removed
+ self._replace_log = True
+
+ @property
+ def replace_log(self):
+ return self._replace_log
+
+ def append(self, html):
+ self.html["html"].append(html)
+
+
+class Cell(Table):
+ def insert(self, index, html):
+ # backwards-compat
+ if not isinstance(html, str):
+ if html.__module__.startswith("py."):
+ warnings.warn(
+ "The 'py' module is deprecated and support "
+ "will be removed in a future release.",
+ DeprecationWarning,
+ )
+ html = str(html)
+ html = html.replace("col", "data-column-type")
+ self._html[index] = html
+
+ def pop(self, *args):
+ warnings.warn(
+ "'pop' is deprecated and no longer supported.",
+ DeprecationWarning,
+ )
+
+
+class Header(Cell):
+ pass
+
+
+class Row(Cell):
+ def __delitem__(self, key):
+ # This means the item should be removed
+ self._html = None
diff --git a/src/pytest_html/util.py b/src/pytest_html/util.py
index 37259ec7..3cfce7a2 100644
--- a/src/pytest_html/util.py
+++ b/src/pytest_html/util.py
@@ -1,5 +1,8 @@
import importlib
+import json
from functools import lru_cache
+from typing import Any
+from typing import Dict
@lru_cache()
@@ -10,3 +13,15 @@ def ansi_support():
except ImportError:
# ansi2html is not installed
pass
+
+
+def cleanup_unserializable(d: Dict[str, Any]) -> Dict[str, Any]:
+ """Return new dict with entries that are not json serializable by their str()."""
+ result = {}
+ for k, v in d.items():
+ try:
+ json.dumps({k: v})
+ except TypeError:
+ v = str(v)
+ result[k] = v
+ return result
diff --git a/start b/start
new file mode 100755
index 00000000..3440913d
--- /dev/null
+++ b/start
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+if [[ $(uname) == "Darwin" ]]; then
+ volume="/private/var/folders:/reports/private/var/folders"
+else
+ volume="${TMPDIR:-/tmp}:/reports${TMPDIR:-/tmp}"
+fi
+
+if [[ "${1}" == "down" ]]; then
+ docker-compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) down
+else
+ docker-compose -f <(sed -e "s;%%VOLUME%%;${volume};g" docker-compose.tmpl.yml) up -d
+fi
diff --git a/testing/js_test_report.html b/testing/js_test_report.html
deleted file mode 100644
index 5b4fae82..00000000
--- a/testing/js_test_report.html
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
- QUnit Pytest-HTML
-
-
-
-
-
-
-
-
-
-
-
-
-
- Result
- Test
- Duration
- Links
-
- No results found. Try to check the filters
-
-
-
-
- Rerun
- rerun.py::test_rexample_1
- 1.00
- URL
-
-
-
-
- Passed
- rerun.py::test_example_2
- 0.00
-
-
-
-
-
-
-
- Passed
- rerun.py::test_example_3
- 0.00
-
-
-
-
-
-
-
-
-
diff --git a/testing/test_pytest_html.py b/testing/legacy_test_pytest_html.py
similarity index 99%
rename from testing/test_pytest_html.py
rename to testing/legacy_test_pytest_html.py
index ac9a1500..a3c90805 100644
--- a/testing/test_pytest_html.py
+++ b/testing/legacy_test_pytest_html.py
@@ -347,7 +347,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
diff --git a/testing/test.js b/testing/test.js
deleted file mode 100644
index 65dc2928..00000000
--- a/testing/test.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-
- if (!String.prototype.includes) {
- String.prototype.includes = function() {'use strict';
- return String.prototype.indexOf.apply(this, arguments) !== -1;
- };
- }
-
- QUnit.module( 'module', {
- beforeEach: function( assert ) {
- init();
- }
- });
-
- QUnit.test('sortColumn', function(assert){
- function sortColumnTest(col_re, first_element_then, first_element_now) {
- assert.equal(findAll('.results-table-row')[0].className, first_element_then);
- var row_sort = find(col_re);
- sortColumn(row_sort);
- assert.equal(findAll('.results-table-row')[0].className, first_element_now);
- }
-
- //check col-name, tests should be in this order test-1 => (test-2 => test-3) on col-name
- assert.equal(findAll('.col-name')[1].className, 'test-2 col-name');
-
- //result
- sortColumnTest('[col=result]',
- 'rerun results-table-row', 'passed results-table-row');
-
- //make sure sorting the result column does not change the tests order in the col-name
- //tests should be in this order (test-2 => test-3) => test1 on col-name
- assert.equal(findAll('.col-name')[0].className, 'test-2 col-name');
-
- sortColumnTest('[col=result]',
- 'passed results-table-row', 'rerun results-table-row');
-
-
- //name
- sortColumnTest('[col=name]',
- 'rerun results-table-row', 'passed results-table-row');
- sortColumnTest('[col=name]',
- 'passed results-table-row', 'rerun results-table-row');
-
- //duration
- sortColumnTest('[col=duration]',
- 'rerun results-table-row', 'passed results-table-row');
- sortColumnTest('[col=duration]',
- 'passed results-table-row', 'rerun results-table-row');
-
- //links
- sortColumnTest('[col=links]',
- 'rerun results-table-row', 'passed results-table-row');
- sortColumnTest('[col=links]',
- 'passed results-table-row', 'rerun results-table-row');
- });
-
-QUnit.test('filterTable', function(assert){
- function filterTableTest(outcome, checked) {
- var filter_input = document.createElement('input');
- filter_input.setAttribute('data-test-result', outcome);
- filter_input.checked = checked;
- filterTable(filter_input);
-
- var outcomes = findAll('.' + outcome);
- for(var i = 0; i < outcomes.length; i++) {
- assert.equal(outcomes[i].hidden, !checked);
- }
- }
- assert.equal(find('#not-found-message').hidden, true);
-
- filterTableTest('rerun', false);
- filterTableTest('passed', false);
- assert.equal(find('#not-found-message').hidden, false);
-
- filterTableTest('rerun', true);
- assert.equal(find('#not-found-message').hidden, true);
-
- filterTableTest('passed', true);
-
-});
-
-QUnit.test('showHideExtras', function(assert) {
- function showExtrasTest(element){
- assert.equal(element.parentNode.nextElementSibling.className, 'collapsed');
- showExtras(element);
- assert.notEqual(element.parentNode.nextElementSibling.className, 'collapsed');
- }
-
- function hideExtrasTest(element){
- assert.notEqual(element.parentNode.nextElementSibling.className, 'collapsed');
- hideExtras(element);
- assert.equal(element.parentNode.nextElementSibling.className, 'collapsed');
- }
- //Passed results have log collapsed by default
- showExtrasTest(find('.passed').firstElementChild.firstElementChild);
- hideExtrasTest(find('.passed').firstElementChild.firstElementChild);
-
- hideExtrasTest(find('.rerun').firstElementChild.firstElementChild);
- showExtrasTest(find('.rerun').firstElementChild.firstElementChild);
-});
-
-QUnit.test('showHideAllExtras', function(assert) {
- function showAllExtrasTest(){
- showAllExtras();
- var extras = findAll('.extra');
- for (var i = 0; i < extras.length; i++) {
- assert.notEqual(extras[i].parentNode.className, 'collapsed');
- }
- }
-
- function hideAllExtrasTest(){
- hideAllExtras();
- var extras = findAll('.extra');
- for (var i = 0; i < extras.length; i++) {
- assert.equal(extras[i].parentNode.className, 'collapsed');
- }
- }
-
- showAllExtrasTest();
- hideAllExtrasTest();
-});
-
-QUnit.test('find', function (assert) {
- assert.notEqual(find('#results-table-head'), null);
- assert.notEqual(find('table#results-table'), null);
- assert.equal(find('.not-in-table'), null);
-});
-
-QUnit.test('findAll', function(assert) {
- assert.equal(findAll('.sortable').length, 4);
- assert.equal(findAll('.not-in-table').length, 0);
-});
diff --git a/testing/test_integration.py b/testing/test_integration.py
new file mode 100644
index 00000000..72862bdb
--- /dev/null
+++ b/testing/test_integration.py
@@ -0,0 +1,788 @@
+import base64
+import importlib.resources
+import json
+import os
+import random
+import re
+import urllib.parse
+from base64 import b64encode
+from pathlib import Path
+
+import pkg_resources
+import pytest
+from assertpy import assert_that
+from bs4 import BeautifulSoup
+from selenium import webdriver
+
+pytest_plugins = ("pytester",)
+
+OUTCOMES = {
+ "passed": "Passed",
+ "skipped": "Skipped",
+ "failed": "Failed",
+ "error": "Errors",
+ "xfailed": "Unexpected failures",
+ "xpassed": "Unexpected passes",
+ "rerun": "Reruns",
+}
+
+
+def run(pytester, path="report.html", cmd_flags=None, query_params=None):
+ if cmd_flags is None:
+ cmd_flags = []
+
+ if query_params is None:
+ query_params = {}
+ query_params = urllib.parse.urlencode(query_params)
+
+ path = pytester.path.joinpath(path)
+ pytester.runpytest("--html", path, *cmd_flags)
+
+ chrome_options = webdriver.ChromeOptions()
+ if os.environ.get("CI", False):
+ chrome_options.add_argument("--headless")
+ chrome_options.add_argument("--window-size=1920x1080")
+ driver = webdriver.Remote(
+ command_executor="http://127.0.0.1:4444", options=chrome_options
+ )
+ try:
+ # Begin workaround
+ # See: https://github.com/pytest-dev/pytest/issues/10738
+ path.chmod(0o755)
+ for parent in path.parents:
+ try:
+ os.chmod(parent, 0o755)
+ except PermissionError:
+ continue
+ # End workaround
+
+ driver.get(f"file:///reports{path}?{query_params}")
+ return BeautifulSoup(driver.page_source, "html.parser")
+ finally:
+ driver.quit()
+
+
+def assert_results(
+ page,
+ passed=0,
+ skipped=0,
+ failed=0,
+ error=0,
+ xfailed=0,
+ xpassed=0,
+ rerun=0,
+ total_tests=None,
+):
+ args = locals()
+ number_of_tests = 0
+ for outcome, number in args.items():
+ if outcome == "total_tests":
+ continue
+ if isinstance(number, int):
+ number_of_tests += number
+ result = get_text(page, f"span[class={outcome}]")
+ assert_that(result).is_equal_to(f"{number} {OUTCOMES[outcome]}")
+
+
+def get_element(page, selector):
+ return page.select_one(selector)
+
+
+def get_text(page, selector):
+ return get_element(page, selector).string
+
+
+def is_collapsed(page, test_name):
+ return get_element(page, f".summary tbody[id$='{test_name}'] .expander")
+
+
+def get_log(page, test_id=None):
+ # TODO(jim) move to get_text (use .contents)
+ if test_id:
+ log = get_element(page, f".summary tbody[id$='{test_id}'] div[class='log']")
+ else:
+ log = get_element(page, ".summary div[class='log']")
+ all_text = ""
+ for text in log.strings:
+ all_text += text
+
+ return all_text
+
+
+def file_content():
+ try:
+ return (
+ importlib.resources.files("pytest_html")
+ .joinpath("resources", "style.css")
+ .read_bytes()
+ .decode("utf-8")
+ .strip()
+ )
+ except AttributeError:
+ # Needed for python < 3.9
+ return pkg_resources.resource_string(
+ "pytest_html", os.path.join("resources", "style.css")
+ ).decode("utf-8")
+
+
+class TestHTML:
+ @pytest.mark.parametrize(
+ "pause, expectation",
+ [
+ (0.4, 400),
+ (1, r"^((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$)"),
+ ],
+ )
+ def test_durations(self, pytester, pause, expectation):
+ pytester.makepyfile(
+ f"""
+ import time
+ def test_sleep():
+ time.sleep({pause})
+ """
+ )
+ page = run(pytester)
+ duration = get_text(page, "#results-table td[class='col-duration']")
+ total_duration = get_text(page, "p[class='run-count']")
+ if pause < 1:
+ assert_that(int(duration.replace("ms", ""))).is_between(
+ expectation, expectation * 2
+ )
+ assert_that(total_duration).matches(r"\d+\s+ms")
+ else:
+ assert_that(duration).matches(expectation)
+ assert_that(total_duration).matches(r"\d{2}:\d{2}:\d{2}")
+
+ def test_total_number_of_tests_zero(self, pytester):
+ page = run(pytester)
+ assert_results(page)
+
+ total = get_text(page, "p[class='run-count']")
+ assert_that(total).matches(r"0 test(?!s)")
+
+ def test_total_number_of_tests_singular(self, pytester):
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+ assert_results(page, passed=1)
+
+ total = get_text(page, "p[class='run-count']")
+ assert_that(total).matches(r"1 test(?!s)")
+
+ def test_total_number_of_tests_plural(self, pytester):
+ pytester.makepyfile(
+ """
+ def test_pass_one(): pass
+ def test_pass_two(): pass
+ """
+ )
+ page = run(pytester)
+ assert_results(page, passed=2)
+
+ total = get_text(page, "p[class='run-count']")
+ assert_that(total).matches(r"2 tests(?!\S)")
+
+ def test_pass(self, pytester):
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+ assert_results(page, passed=1)
+
+ def test_skip(self, pytester):
+ reason = str(random.random())
+ pytester.makepyfile(
+ f"""
+ import pytest
+ def test_skip():
+ pytest.skip('{reason}')
+ """
+ )
+ page = run(pytester)
+ assert_results(page, skipped=1, total_tests=0)
+
+ log = get_text(page, ".summary div[class='log']")
+ assert_that(log).contains(reason)
+
+ def test_fail(self, pytester):
+ pytester.makepyfile("def test_fail(): assert False")
+ page = run(pytester)
+ assert_results(page, failed=1)
+ assert_that(get_log(page)).contains("AssertionError")
+
+ def test_xfail(self, pytester):
+ reason = str(random.random())
+ pytester.makepyfile(
+ f"""
+ import pytest
+ def test_xfail():
+ pytest.xfail('{reason}')
+ """
+ )
+ page = run(pytester)
+ assert_results(page, xfailed=1)
+ assert_that(get_log(page)).contains(reason)
+
+ def test_xpass(self, pytester):
+ pytester.makepyfile(
+ """
+ import pytest
+ @pytest.mark.xfail()
+ def test_xpass():
+ pass
+ """
+ )
+ page = run(pytester)
+ assert_results(page, xpassed=1)
+
+ def test_rerun(self, pytester):
+ pytester.makepyfile(
+ """
+ import pytest
+ import time
+
+ @pytest.mark.flaky(reruns=2)
+ def test_example():
+ time.sleep(0.2)
+ assert False
+ """
+ )
+
+ page = run(pytester)
+ assert_results(page, failed=1, rerun=2, total_tests=1)
+
+ def test_conditional_xfails(self, pytester):
+ pytester.makepyfile(
+ """
+ import pytest
+ @pytest.mark.xfail(False, reason='reason')
+ def test_fail(): assert False
+ @pytest.mark.xfail(False, reason='reason')
+ def test_pass(): pass
+ @pytest.mark.xfail(True, reason='reason')
+ def test_xfail(): assert False
+ @pytest.mark.xfail(True, reason='reason')
+ def test_xpass(): pass
+ """
+ )
+ page = run(pytester)
+ assert_results(page, passed=1, failed=1, xfailed=1, xpassed=1)
+
+ def test_setup_error(self, pytester):
+ pytester.makepyfile(
+ """
+ import pytest
+ @pytest.fixture
+ def arg(request):
+ raise ValueError()
+ def test_function(arg):
+ pass
+ """
+ )
+ page = run(pytester)
+ assert_results(page, error=1, total_tests=0)
+
+ col_name = get_text(page, ".summary td[class='col-name']")
+ assert_that(col_name).contains("::setup")
+ assert_that(get_log(page)).contains("ValueError")
+
+ @pytest.mark.parametrize("title", ["", "Special Report"])
+ def test_report_title(self, pytester, title):
+ pytester.makepyfile("def test_pass(): pass")
+
+ if title:
+ pytester.makeconftest(
+ f"""
+ import pytest
+ def pytest_html_report_title(report):
+ report.title = "{title}"
+ """
+ )
+
+ expected_title = title if title else "report.html"
+ page = run(pytester)
+ assert_that(get_text(page, "#head-title")).is_equal_to(expected_title)
+ assert_that(get_text(page, "h1[id='title']")).is_equal_to(expected_title)
+
+ def test_resources_inline_css(self, pytester):
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester, cmd_flags=["--self-contained-html"])
+
+ content = file_content()
+
+ assert_that(get_text(page, "head style").strip()).contains(content)
+
+ def test_resources_css(self, pytester):
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+
+ assert_that(page.select_one("head link")["href"]).is_equal_to(
+ str(Path("assets", "style.css"))
+ )
+
+ def test_custom_content_in_summary(self, pytester):
+ content = {
+ "prefix": str(random.random()),
+ "summary": str(random.random()),
+ "postfix": str(random.random()),
+ }
+
+ pytester.makeconftest(
+ f"""
+ import pytest
+
+ def pytest_html_results_summary(prefix, summary, postfix):
+ prefix.append(r"prefix is {content['prefix']}
")
+ summary.extend([r"summary is {content['summary']}
"])
+ postfix.extend([r"postfix is {content['postfix']}
"])
+ """
+ )
+
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+
+ elements = page.select(".summary__data p:not(.run-count):not(.filter)")
+ assert_that(elements).is_length(3)
+ for element in elements:
+ key = re.search(r"(\w+).*", element.string).group(1)
+ value = content.pop(key)
+ assert_that(element.string).contains(value)
+
+ def test_extra_html(self, pytester):
+ content = str(random.random())
+ pytester.makeconftest(
+ f"""
+ import pytest
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.html('{content}
')]
+ """
+ )
+
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+
+ assert_that(page.select_one(".summary .extraHTML").string).is_equal_to(content)
+
+ @pytest.mark.parametrize(
+ "content, encoded",
+ [("u'\u0081'", "woE="), ("'foo'", "Zm9v"), ("b'\\xe2\\x80\\x93'", "4oCT")],
+ )
+ def test_extra_text(self, pytester, content, encoded):
+ pytester.makeconftest(
+ f"""
+ import pytest
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.text({content})]
+ """
+ )
+
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester, cmd_flags=["--self-contained-html"])
+
+ element = page.select_one(".summary a[class='col-links__extra text']")
+ assert_that(element.string).is_equal_to("Text")
+ assert_that(element["href"]).is_equal_to(
+ f"data:text/plain;charset=utf-8;base64,{encoded}"
+ )
+
+ def test_extra_json(self, pytester):
+ content = {str(random.random()): str(random.random())}
+ pytester.makeconftest(
+ f"""
+ import pytest
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.json({content})]
+ """
+ )
+
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester, cmd_flags=["--self-contained-html"])
+
+ content_str = json.dumps(content)
+ data = b64encode(content_str.encode("utf-8")).decode("ascii")
+
+ element = page.select_one(".summary a[class='col-links__extra json']")
+ assert_that(element.string).is_equal_to("JSON")
+ assert_that(element["href"]).is_equal_to(
+ f"data:application/json;charset=utf-8;base64,{data}"
+ )
+
+ def test_extra_url(self, pytester):
+ content = str(random.random())
+ pytester.makeconftest(
+ f"""
+ import pytest
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.url('{content}')]
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+
+ element = page.select_one(".summary a[class='col-links__extra url']")
+ assert_that(element.string).is_equal_to("URL")
+ assert_that(element["href"]).is_equal_to(content)
+
+ @pytest.mark.parametrize(
+ "mime_type, extension",
+ [
+ ("image/png", "png"),
+ ("image/png", "image"),
+ ("image/jpeg", "jpg"),
+ ("image/svg+xml", "svg"),
+ ],
+ )
+ def test_extra_image(self, pytester, mime_type, extension):
+ content = str(random.random())
+ charset = "utf-8"
+ data = base64.b64encode(content.encode(charset)).decode(charset)
+
+ pytester.makeconftest(
+ f"""
+ import pytest
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.{extension}('{data}')]
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester, cmd_flags=["--self-contained-html"])
+
+ # element = page.select_one(".summary a[class='col-links__extra image']")
+ src = f"data:{mime_type};base64,{data}"
+ # assert_that(element.string).is_equal_to("Image")
+ # assert_that(element["href"]).is_equal_to(src)
+
+ element = page.select_one(".summary .media img")
+ assert_that(str(element)).is_equal_to(f' ')
+
+ @pytest.mark.parametrize("mime_type, extension", [("video/mp4", "mp4")])
+ def test_extra_video(self, pytester, mime_type, extension):
+ content = str(random.random())
+ charset = "utf-8"
+ data = base64.b64encode(content.encode(charset)).decode(charset)
+ pytester.makeconftest(
+ f"""
+ import pytest
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ if report.when == 'call':
+ from pytest_html import extras
+ report.extras = [extras.{extension}('{data}')]
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester, cmd_flags=["--self-contained-html"])
+
+ # element = page.select_one(".summary a[class='col-links__extra video']")
+ src = f"data:{mime_type};base64,{data}"
+ # assert_that(element.string).is_equal_to("Video")
+ # assert_that(element["href"]).is_equal_to(src)
+
+ element = page.select_one(".summary .media video")
+ assert_that(str(element)).is_equal_to(
+ f'\n \n '
+ )
+
+ def test_xdist(self, pytester):
+ pytester.makepyfile("def test_xdist(): pass")
+ page = run(pytester, cmd_flags=["-n1"])
+ assert_results(page, passed=1)
+
+ def test_results_table_hook_insert(self, pytester):
+ header_selector = (
+ ".summary #results-table-head tr:nth-child(1) th:nth-child({})"
+ )
+ row_selector = ".summary #results-table tr:nth-child(1) td:nth-child({})"
+
+ pytester.makeconftest(
+ """
+ def pytest_html_results_table_header(cells):
+ cells.insert(2, "Description ")
+ cells.insert(
+ 1,
+ 'Time '
+ )
+
+ def pytest_html_results_table_row(report, cells):
+ cells.insert(2, "A description ")
+ cells.insert(1, 'A time ')
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ page = run(pytester)
+
+ assert_that(get_text(page, header_selector.format(2))).is_equal_to("Time")
+ assert_that(get_text(page, header_selector.format(3))).is_equal_to(
+ "Description"
+ )
+
+ assert_that(get_text(page, row_selector.format(2))).is_equal_to("A time")
+ assert_that(get_text(page, row_selector.format(3))).is_equal_to("A description")
+
+ def test_results_table_hook_delete(self, pytester):
+ pytester.makeconftest(
+ """
+ def pytest_html_results_table_row(report, cells):
+ if report.skipped:
+ del cells[:]
+ """
+ )
+ pytester.makepyfile(
+ """
+ import pytest
+ def test_skip():
+ pytest.skip('reason')
+
+ def test_pass(): pass
+
+ """
+ )
+ page = run(pytester)
+ assert_results(page, passed=1)
+
+ @pytest.mark.parametrize("no_capture", ["", "-s"])
+ def test_standard_streams(self, pytester, no_capture):
+ pytester.makepyfile(
+ """
+ import pytest
+ import sys
+ @pytest.fixture
+ def setup():
+ print("this is setup stdout")
+ print("this is setup stderr", file=sys.stderr)
+ yield
+ print("this is teardown stdout")
+ print("this is teardown stderr", file=sys.stderr)
+
+ def test_streams(setup):
+ print("this is call stdout")
+ print("this is call stderr", file=sys.stderr)
+ assert True
+ """
+ )
+ page = run(pytester, "report.html", cmd_flags=[no_capture])
+ assert_results(page, passed=1)
+
+ log = get_log(page)
+ for when in ["setup", "call", "teardown"]:
+ for stream in ["stdout", "stderr"]:
+ if no_capture:
+ assert_that(log).does_not_match(f"- Captured {stream} {when} -")
+ assert_that(log).does_not_match(f"this is {when} {stream}")
+ else:
+ assert_that(log).matches(f"- Captured {stream} {when} -")
+ assert_that(log).matches(f"this is {when} {stream}")
+
+
+class TestLogCapturing:
+ LOG_LINE_REGEX = r"\s+this is {}"
+
+ @pytest.fixture
+ def log_cli(self, pytester):
+ pytester.makeini(
+ """
+ [pytest]
+ log_cli = 1
+ log_cli_level = INFO
+ log_cli_date_format = %Y-%m-%d %H:%M:%S
+ log_cli_format = %(asctime)s %(levelname)s: %(message)s
+ """
+ )
+
+ @pytest.fixture
+ def test_file(self):
+ return """
+ import pytest
+ import logging
+ @pytest.fixture
+ def setup():
+ logging.info("this is setup")
+ {setup}
+ yield
+ logging.info("this is teardown")
+ {teardown}
+
+ def test_logging(setup):
+ logging.info("this is test")
+ assert {assertion}
+ """
+
+ @pytest.mark.usefixtures("log_cli")
+ def test_all_pass(self, test_file, pytester):
+ pytester.makepyfile(test_file.format(setup="", teardown="", assertion=True))
+ page = run(pytester)
+ assert_results(page, passed=1)
+
+ log = get_log(page)
+ for when in ["setup", "test", "teardown"]:
+ assert_that(log).matches(self.LOG_LINE_REGEX.format(when))
+
+ @pytest.mark.usefixtures("log_cli")
+ def test_setup_error(self, test_file, pytester):
+ pytester.makepyfile(
+ test_file.format(setup="error", teardown="", assertion=True)
+ )
+ page = run(pytester)
+ assert_results(page, error=1)
+
+ log = get_log(page)
+ assert_that(log).matches(self.LOG_LINE_REGEX.format("setup"))
+ assert_that(log).does_not_match(self.LOG_LINE_REGEX.format("test"))
+ assert_that(log).does_not_match(self.LOG_LINE_REGEX.format("teardown"))
+
+ @pytest.mark.usefixtures("log_cli")
+ def test_test_fails(self, test_file, pytester):
+ pytester.makepyfile(test_file.format(setup="", teardown="", assertion=False))
+ page = run(pytester)
+ assert_results(page, failed=1)
+
+ log = get_log(page)
+ for when in ["setup", "test", "teardown"]:
+ assert_that(log).matches(self.LOG_LINE_REGEX.format(when))
+
+ @pytest.mark.usefixtures("log_cli")
+ @pytest.mark.parametrize(
+ "assertion, result", [(True, {"passed": 1}), (False, {"failed": 1})]
+ )
+ def test_teardown_error(self, test_file, pytester, assertion, result):
+ pytester.makepyfile(
+ test_file.format(setup="", teardown="error", assertion=assertion)
+ )
+ page = run(pytester)
+ assert_results(page, error=1, **result)
+
+ for test_name in ["test_logging", "test_logging::teardown"]:
+ log = get_log(page, test_name)
+ for when in ["setup", "test", "teardown"]:
+ assert_that(log).matches(self.LOG_LINE_REGEX.format(when))
+
+ def test_no_log(self, test_file, pytester):
+ pytester.makepyfile(test_file.format(setup="", teardown="", assertion=True))
+ page = run(pytester)
+ assert_results(page, passed=1)
+
+ log = get_log(page, "test_logging")
+ assert_that(log).contains("No log output captured.")
+ for when in ["setup", "test", "teardown"]:
+ assert_that(log).does_not_match(self.LOG_LINE_REGEX.format(when))
+
+
+class TestCollapsedQueryParam:
+ @pytest.fixture
+ def test_file(self):
+ return """
+ import pytest
+ @pytest.fixture
+ def setup():
+ error
+
+ def test_error(setup):
+ assert True
+
+ def test_pass():
+ assert True
+
+ def test_fail():
+ assert False
+ """
+
+ def test_default(self, pytester, test_file):
+ pytester.makepyfile(test_file)
+ page = run(pytester)
+ assert_results(page, passed=1, failed=1, error=1)
+
+ assert_that(is_collapsed(page, "test_pass")).is_true()
+ assert_that(is_collapsed(page, "test_fail")).is_false()
+ assert_that(is_collapsed(page, "test_error::setup")).is_false()
+
+ @pytest.mark.parametrize("param", ["failed,error", "FAILED,eRRoR"])
+ def test_specified(self, pytester, test_file, param):
+ pytester.makepyfile(test_file)
+ page = run(pytester, query_params={"collapsed": param})
+ assert_results(page, passed=1, failed=1, error=1)
+
+ assert_that(is_collapsed(page, "test_pass")).is_false()
+ assert_that(is_collapsed(page, "test_fail")).is_true()
+ assert_that(is_collapsed(page, "test_error::setup")).is_true()
+
+ def test_all(self, pytester, test_file):
+ pytester.makepyfile(test_file)
+ page = run(pytester, query_params={"collapsed": "all"})
+ assert_results(page, passed=1, failed=1, error=1)
+
+ for test_name in ["test_pass", "test_fail", "test_error::setup"]:
+ assert_that(is_collapsed(page, test_name)).is_true()
+
+ @pytest.mark.parametrize("param", ["", 'collapsed=""', "collapsed=''"])
+ def test_falsy(self, pytester, test_file, param):
+ pytester.makepyfile(test_file)
+ page = run(pytester, query_params={"collapsed": param})
+ assert_results(page, passed=1, failed=1, error=1)
+
+ assert_that(is_collapsed(page, "test_pass")).is_false()
+ assert_that(is_collapsed(page, "test_fail")).is_false()
+ assert_that(is_collapsed(page, "test_error::setup")).is_false()
+
+ @pytest.mark.parametrize("param", ["failed,error", "FAILED,eRRoR"])
+ def test_render_collapsed(self, pytester, test_file, param):
+ pytester.makeini(
+ f"""
+ [pytest]
+ render_collapsed = {param}
+ """
+ )
+ pytester.makepyfile(test_file)
+ page = run(pytester)
+ assert_results(page, passed=1, failed=1, error=1)
+
+ assert_that(is_collapsed(page, "test_pass")).is_false()
+ assert_that(is_collapsed(page, "test_fail")).is_true()
+ assert_that(is_collapsed(page, "test_error::setup")).is_true()
+
+ def test_render_collapsed_precedence(self, pytester, test_file):
+ pytester.makeini(
+ """
+ [pytest]
+ render_collapsed = failed,error
+ """
+ )
+ test_file += """
+ def test_skip():
+ pytest.skip('meh')
+ """
+ pytester.makepyfile(test_file)
+ page = run(pytester, query_params={"collapsed": "skipped"})
+ assert_results(page, passed=1, failed=1, error=1, skipped=1)
+
+ assert_that(is_collapsed(page, "test_pass")).is_false()
+ assert_that(is_collapsed(page, "test_fail")).is_false()
+ assert_that(is_collapsed(page, "test_error::setup")).is_false()
+ assert_that(is_collapsed(page, "test_skip")).is_true()
diff --git a/testing/test_unit.py b/testing/test_unit.py
new file mode 100644
index 00000000..fa5d9be2
--- /dev/null
+++ b/testing/test_unit.py
@@ -0,0 +1,42 @@
+pytest_plugins = ("pytester",)
+
+
+def run(pytester, path="report.html", *args):
+ path = pytester.path.joinpath(path)
+ return pytester.runpytest("--html", path, *args)
+
+
+def test_duration_format_deprecation_warning(pytester):
+ pytester.makeconftest(
+ """
+ import pytest
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ setattr(report, "duration_formatter", "%H:%M:%S.%f")
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ result = run(pytester)
+ result.stdout.fnmatch_lines(
+ [
+ "*DeprecationWarning: 'duration_formatter'*",
+ ],
+ )
+
+
+def test_cells_pop_deprecation_warning(pytester):
+ pytester.makeconftest(
+ """
+ def pytest_html_results_table_row(cells):
+ cells.pop()
+ """
+ )
+ pytester.makepyfile("def test_pass(): pass")
+ result = run(pytester)
+ result.stdout.fnmatch_lines(
+ [
+ "*DeprecationWarning: 'pop' is deprecated*",
+ ],
+ )
diff --git a/testing/unittest.js b/testing/unittest.js
new file mode 100644
index 00000000..17d6543d
--- /dev/null
+++ b/testing/unittest.js
@@ -0,0 +1,237 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+const { doInitFilter, doFilter } = require('../src/pytest_html/scripts/filter.js')
+const { doInitSort, doSort } = require('../src/pytest_html/scripts/sort.js')
+const { formatDuration } = require('../src/pytest_html/scripts/utils.js')
+const dataModule = require('../src/pytest_html/scripts/datamanager.js')
+const storageModule = require('../src/pytest_html/scripts/storage.js')
+
+
+const setTestData = () => {
+ const jsonDatan = {
+ 'tests':
+ [
+ {
+ 'id': 'passed_1',
+ 'result': 'passed',
+ },
+ {
+ 'id': 'failed_2',
+ 'result': 'failed',
+ },
+ {
+ 'id': 'passed_3',
+ 'result': 'passed',
+ },
+ {
+ 'id': 'passed_4',
+ 'result': 'passed',
+ },
+ {
+ 'id': 'passed_5',
+ 'result': 'passed',
+ },
+ {
+ 'id': 'passed_6',
+ 'result': 'passed',
+ },
+ ],
+ }
+ dataModule.manager.setManager(jsonDatan)
+}
+
+describe('Filter tests', () => {
+ let getFilterMock
+ let managerSpy
+
+ beforeEach(setTestData)
+ afterEach(() => [getFilterMock, managerSpy].forEach((fn) => fn.restore()))
+ after(() => dataModule.manager.setManager({ tests: [] }))
+
+ describe('doInitFilter', () => {
+ it('has no stored filters', () => {
+ getFilterMock = sinon.stub(storageModule, 'getVisible').returns([])
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doInitFilter()
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([])
+ })
+ it('exclude passed', () => {
+ getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['failed'])
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doInitFilter()
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql(['failed'])
+ })
+ })
+ describe('doFilter', () => {
+ let setFilterMock
+ afterEach(() => setFilterMock.restore())
+ it('removes all but passed', () => {
+ getFilterMock = sinon.stub(storageModule, 'getVisible').returns(['passed'])
+ setFilterMock = sinon.stub(storageModule, 'setFilter')
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doFilter('passed', true)
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([
+ 'passed', 'passed', 'passed', 'passed', 'passed',
+ ])
+ })
+ })
+})
+
+
+describe('Sort tests', () => {
+ beforeEach(setTestData)
+ after(() => dataModule.manager.setManager({ tests: [] }))
+ describe('doInitSort', () => {
+ let managerSpy
+ let sortMock
+ let sortDirectionMock
+ beforeEach(() => dataModule.manager.resetRender())
+
+ afterEach(() => [sortMock, sortDirectionMock, managerSpy].forEach((fn) => fn.restore()))
+ it('has no stored sort', () => {
+ sortMock = sinon.stub(storageModule, 'getSort').returns(null)
+ sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null)
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doInitSort()
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([
+ 'passed', 'failed', 'passed', 'passed', 'passed', 'passed',
+ ])
+ })
+ it('has stored sort preference', () => {
+ sortMock = sinon.stub(storageModule, 'getSort').returns('result')
+ sortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(false)
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doInitSort()
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([
+ 'failed', 'passed', 'passed', 'passed', 'passed', 'passed',
+ ])
+ })
+ })
+ describe('doSort', () => {
+ let getSortMock
+ let setSortMock
+ let getSortDirectionMock
+ let setSortDirection
+ let managerSpy
+
+ afterEach(() => [
+ getSortMock, setSortMock, getSortDirectionMock, setSortDirection, managerSpy,
+ ].forEach((fn) => fn.restore()))
+ it('sort on result', () => {
+ getSortMock = sinon.stub(storageModule, 'getSort').returns(null)
+ setSortMock = sinon.stub(storageModule, 'setSort')
+ getSortDirectionMock = sinon.stub(storageModule, 'getSortDirection').returns(null)
+ setSortDirection = sinon.stub(storageModule, 'setSortDirection')
+ managerSpy = sinon.spy(dataModule.manager, 'setRender')
+
+ doSort('result')
+ expect(managerSpy.callCount).to.eql(1)
+ expect(dataModule.manager.testSubset.map(({ result }) => result)).to.eql([
+ 'passed', 'passed', 'passed', 'passed', 'passed', 'failed',
+ ])
+ })
+ })
+})
+
+describe('utils tests', () => {
+ describe('formatDuration', () => {
+ it('handles small durations', () => {
+ expect(formatDuration(0.123).ms).to.eql('123 ms')
+ expect(formatDuration(0).ms).to.eql('0 ms')
+ expect(formatDuration(0.999).ms).to.eql('999 ms')
+ })
+ it('handles larger durations', () => {
+ expect(formatDuration(1.234).formatted).to.eql('00:00:01')
+ expect(formatDuration(12345.678).formatted).to.eql('03:25:46')
+ })
+ })
+})
+
+describe('Storage tests', () => {
+ describe('getCollapsedCategory', () => {
+ let originalWindow
+ const mockWindow = (queryParam) => {
+ const mock = {
+ location: {
+ href: `https://example.com/page?${queryParam}`
+ }
+ }
+ originalWindow = global.window
+ global.window = mock
+ }
+ after(() => global.window = originalWindow)
+
+ it('collapses passed by default', () => {
+ mockWindow()
+ const collapsedItems = storageModule.getCollapsedCategory()
+ expect(collapsedItems).to.eql(['passed'])
+ })
+
+ it('collapses specified outcomes', () => {
+ mockWindow('collapsed=failed,error')
+ const collapsedItems = storageModule.getCollapsedCategory()
+ expect(collapsedItems).to.eql(['failed', 'error'])
+ })
+
+ it('collapses all', () => {
+ mockWindow('collapsed=all')
+ const collapsedItems = storageModule.getCollapsedCategory()
+ expect(collapsedItems).to.eql(storageModule.possibleFilters)
+ })
+
+ it('handles case insensitive params', () => {
+ mockWindow('collapsed=fAiLeD,ERROR,passed')
+ const collapsedItems = storageModule.getCollapsedCategory()
+ expect(collapsedItems).to.eql(['failed', 'error', 'passed'])
+ })
+
+ const config = [
+ { value: ['failed', 'error'], expected: ['failed', 'error'] },
+ { value: ['all'], expected: storageModule.possibleFilters }
+ ]
+ config.forEach(({value, expected}) => {
+ it(`handles python config: ${value}`, () => {
+ mockWindow()
+ const collapsedItems = storageModule.getCollapsedCategory(value)
+ expect(collapsedItems).to.eql(expected)
+ })
+ })
+
+ const precedence = [
+ {query: 'collapsed=xpassed,xfailed', config: ['failed', 'error'], expected: ['xpassed', 'xfailed']},
+ {query: 'collapsed=all', config: ['failed', 'error'], expected: storageModule.possibleFilters},
+ {query: 'collapsed=xpassed,xfailed', config: ['all'], expected: ['xpassed', 'xfailed']},
+ ]
+ precedence.forEach(({query, config, expected}, index) => {
+ it(`handles python config precedence ${index + 1}`, () => {
+ mockWindow(query)
+ const collapsedItems = storageModule.getCollapsedCategory(config)
+ expect(collapsedItems).to.eql(expected)
+ })
+ })
+
+ const falsy = [
+ { param: 'collapsed' },
+ { param: 'collapsed=' },
+ { param: 'collapsed=""' },
+ { param: 'collapsed=\'\'' }
+ ]
+ falsy.forEach(({param}) => {
+ it(`collapses none with ${param}`, () => {
+ mockWindow(param)
+ const collapsedItems = storageModule.getCollapsedCategory()
+ expect(collapsedItems).to.be.empty
+ })
+ })
+ })
+})
diff --git a/tox.ini b/tox.ini
index 9b279358..300ee4ef 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,20 +4,24 @@
# and then run "tox" from this directory.
[tox]
-envlist = py{37,38,39,310,py3}, docs, linting
+envlist = py{3.7, 3.8, 3.9, 3.10, py3.9}, docs, linting
isolated_build = True
[testenv]
-setenv = PYTHONDONTWRITEBYTECODE=1
+setenv =
+ PYTHONDONTWRITEBYTECODE=1
deps =
+ assertpy
+ beautifulsoup4
pytest-xdist
pytest-rerunfailures
pytest-mock
+ selenium
ansi2html # soft-dependency
cov: pytest-cov
commands =
- !cov: python -X utf8 -m pytest -v -r a --color=yes --html={envlogdir}/report.html --self-contained-html {posargs}
- cov: python -X utf8 -m pytest -v -r a --color=yes --html={envlogdir}/report.html --self-contained-html --cov={envsitepackagesdir}/pytest_html --cov-report=term --cov-report=xml {posargs}
+ !cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html {posargs}
+ cov: pytest -s -ra --color=yes --html={envlogdir}/report.html --self-contained-html --cov={envsitepackagesdir}/pytest_html --cov-report=term --cov-report=xml {posargs}
[testenv:linting]
skip_install = True
@@ -35,49 +39,14 @@ deps =
pytest-rerunfailures @ git+https://github.com/pytest-dev/pytest-rerunfailures.git
pytest @ git+https://github.com/pytest-dev/pytest.git
-[testenv:devel-cov]
-description = Tests with unreleased deps and coverage
-basepython = {[testenv:devel]basepython}
-pip_pre = {[testenv:devel]pip_pre}
-deps = {[testenv:devel]deps}
-
[testenv:docs]
# NOTE: The command for doc building was taken from readthedocs documentation
-# See https://docs.readthedocs.io/en/stable/builds.html#understanding-what-s-going-on
+# See https://docs.readthedocs.io/en/stable/builds.html#understanding-what-s-going-on
basepython = python
changedir = docs
deps = sphinx
commands = sphinx-build -b html . _build/html
-[testenv:packaging]
-description =
- Do packaging/distribution. If tag is not present or PEP440 compliant upload to
- PYPI could fail
-# `usedevelop = true` overrides `skip_install` instruction, it's unwanted
-usedevelop = false
-# don't install package in this env
-skip_install = true
-deps =
- collective.checkdocs >= 0.2
- pep517 >= 0.8.2
- pip >= 20.2.2
- toml >= 0.10.1
- twine >= 3.2.0
-setenv =
-commands =
- rm -rfv {toxinidir}/dist/
- python -m pep517.build \
- --source \
- --binary \
- --out-dir {toxinidir}/dist/ \
- {toxinidir}
- # metadata validation
- python setup.py check
- sh -c "python -m twine check {toxinidir}/dist/*"
-whitelist_externals =
- rm
- sh
-
[flake8]
max-line-length = 88
exclude = .eggs,.tox