Chore: Simplify results table hooks (#688)

This commit is contained in:
Jim Brännlund 2023-07-22 15:06:29 +02:00 committed by GitHub
parent 316246e72d
commit f6f623d0d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 86 additions and 268 deletions

View File

@ -42,7 +42,7 @@ classifiers = [
]
dependencies = [
"pytest>=7.0.0",
"pytest-metadata>=2.0.2",
"pytest-metadata>=3.0.0",
"Jinja2>=3.0.0",
]
dynamic = [

View File

@ -3,17 +3,17 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import datetime
import json
import math
import os
import re
import warnings
from pathlib import Path
import pytest
from pytest_metadata.plugin import metadata_key
from pytest_html import __version__
from pytest_html import extras
from pytest_html.table import Header
from pytest_html.table import Row
from pytest_html.util import cleanup_unserializable
@ -60,8 +60,8 @@ class BaseReport:
self._write_report(rendered_report)
def _generate_environment(self):
metadata = self._config._metadata
def _generate_environment(self, metadata_key):
metadata = self._config.stash[metadata_key]
for key in metadata.keys():
value = metadata[key]
if self._is_redactable_environment_variable(key):
@ -145,16 +145,12 @@ class BaseReport:
@pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session):
config = session.config
if hasattr(config, "_metadata") and config._metadata:
self._report.set_data("environment", self._generate_environment())
self._report.set_data("environment", self._generate_environment(metadata_key))
session.config.hook.pytest_html_report_title(report=self._report)
header_cells = Header()
session.config.hook.pytest_html_results_table_header(cells=header_cells)
self._report.set_data("resultsTableHeader", header_cells.html)
self._report.set_data("headerPops", header_cells.get_pops())
headers = self._report.data["resultsTableHeader"]
session.config.hook.pytest_html_results_table_header(cells=headers)
self._report.set_data("runningState", "Started")
self._generate_report()
@ -173,7 +169,8 @@ class BaseReport:
@pytest.hookimpl(trylast=True)
def pytest_terminal_summary(self, terminalreporter):
terminalreporter.write_sep(
"-", f"Generated html report: file://{self._report_path.resolve()}"
"-",
f"Generated html report: file://{self._report_path.resolve().as_posix()}",
)
@pytest.hookimpl(trylast=True)
@ -189,34 +186,60 @@ class BaseReport:
)
data = {
"duration": report.duration,
"result": _process_outcome(report),
"duration": _format_duration(report.duration),
}
total_duration = self._report.data["totalDuration"]
total_duration["total"] += report.duration
total_duration["formatted"] = _format_duration(total_duration["total"])
test_id = report.nodeid
if report.when != "call":
test_id += f"::{report.when}"
data["testId"] = test_id
row_cells = Row()
self._config.hook.pytest_html_results_table_row(report=report, cells=row_cells)
if row_cells.html is None:
data["extras"] = self._process_extras(report, test_id)
links = [
extra
for extra in data["extras"]
if extra["format_type"] in ["json", "text", "url"]
]
cells = [
f'<td class="col-result">{data["result"]}</td>',
f'<td class="col-name">{data["testId"]}</td>',
f'<td class="col-duration">{data["duration"]}</td>',
f'<td class="col-links">{_process_links(links)}</td>',
]
self._config.hook.pytest_html_results_table_row(report=report, cells=cells)
if not cells:
return
data["resultsTableRow"] = row_cells.html
for sortable, value in row_cells.sortables.items():
data[sortable] = value
data["resultsTableRow"] = cells
processed_logs = _process_logs(report)
self._config.hook.pytest_html_results_table_html(
report=report, data=processed_logs
)
data["result"] = _process_outcome(report)
data["extras"] = self._process_extras(report, test_id)
if self._report.add_test(data, report, processed_logs):
self._generate_report()
def _format_duration(duration):
if duration < 1:
return "{} ms".format(round(duration * 1000))
hours = math.floor(duration / 3600)
remaining_seconds = duration % 3600
minutes = math.floor(remaining_seconds / 60)
remaining_seconds = remaining_seconds % 60
seconds = round(remaining_seconds)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def _is_error(report):
return report.when in ["setup", "teardown"] and report.outcome == "failed"
@ -249,3 +272,8 @@ def _process_outcome(report):
return "XFailed"
return report.outcome.capitalize()
def _process_links(links):
a_tag = '<a target="_blank" href="{content}" class="col-links__extra {format_type}">{name}</a>'
return "".join([a_tag.format_map(link) for link in links])

View File

@ -10,14 +10,26 @@ from pytest_html.util import _handle_ansi
class ReportData:
def __init__(self, config):
self._config = config
default_headers = [
'<th class="sortable" data-column-type="result">Result</th>',
'<th class="sortable" data-column-type="testId">Test</th>',
'<th class="sortable" data-column-type="duration">Duration</th>',
"<th>Links</th>",
]
self._data = {
"title": "",
"collectedItems": 0,
"totalDuration": {
"total": 0,
"formatted": "",
},
"runningState": "not_started",
"environment": {},
"tests": defaultdict(list),
"resultsTableHeader": {},
"additionalSummary": defaultdict(list),
"resultsTableHeader": default_headers,
}
collapsed = config.getini("render_collapsed")

View File

@ -26,16 +26,9 @@
<td></td>
</tr>
</template>
<template id="template_a">
<a target="_blank"></a>
</template>
<template id="template_results-table__tbody">
<tbody class="results-table-row">
<tr class="collapsible">
<td class="col-result"></td>
<td class="col-name"></td>
<td class="col-duration"></td>
<td class="col-links"></td>
</tr>
<tr class="extras-row">
<td class="extra" colspan="4">
@ -62,10 +55,6 @@
<template id="template_results-table__head">
<thead id="results-table-head">
<tr>
<th class="sortable" data-column-type="result">Result</th>
<th class="sortable" data-column-type="testId">Test</th>
<th class="sortable" data-column-type="duration">Duration</th>
<th>Links</th>
</tr>
</thead>
</template>

View File

@ -207,7 +207,7 @@ div.media {
.sortable {
cursor: pointer;
}
.sortable.asc:after {
.sortable.desc:after {
content: " ";
position: relative;
left: 5px;
@ -217,7 +217,7 @@ div.media {
border-left-color: transparent;
border-right-color: transparent;
}
.sortable.desc:after {
.sortable.asc:after {
content: " ";
position: relative;
left: 5px;

View File

@ -50,6 +50,9 @@ class DataManager {
get isFinished() {
return this.data.runningState === 'Finished'
}
get formattedDuration() {
return this.data.totalDuration.formatted
}
}
module.exports = {

View File

@ -1,10 +1,8 @@
const storageModule = require('./storage.js')
const { formatDuration, transformTableObj } = 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')
@ -28,12 +26,6 @@ const findAll = (selector, elem) => {
return [...elem.querySelectorAll(selector)]
}
const insertAdditionalHTML = (html, element, selector, position = 'beforebegin') => {
Object.keys(html).map((key) => {
element.querySelectorAll(selector).item(key).insertAdjacentHTML(position, html[key])
})
}
const dom = {
getStaticRow: (key, value) => {
const envRow = templateEnvRow.content.cloneNode(true)
@ -53,29 +45,14 @@ const dom = {
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
const headers = transformTableObj(resultsTableHeader)
insertAdditionalHTML(headers.inserts, header, 'th')
insertAdditionalHTML(headers.appends, header, 'tr', 'beforeend')
sortables.forEach((sortCol) => {
if (sortCol === sortAttr) {
header.querySelector(`[data-column-type="${sortCol}"]`).classList.add(sortAsc ? 'desc' : 'asc')
}
resultsTableHeader.forEach((html) => {
const t = document.createElement('template')
t.innerHTML = html
header.querySelector('#results-table-head > tr').appendChild(t.content)
})
header.querySelector(`.sortable[data-column-type="${sortAttr}"]`).classList.add(sortAsc ? 'desc' : 'asc')
return header
},
getListHeaderEmpty: () => listHeaderEmpty.content.cloneNode(true),
@ -86,12 +63,13 @@ const dom = {
resultBody.querySelector('tbody').classList.add(resultLower)
resultBody.querySelector('tbody').id = testId
resultBody.querySelector('.collapsible').dataset.id = id
resultBody.querySelector('.col-result').innerText = result
resultBody.querySelector('.col-result').classList.add(`${collapsed ? 'expander' : 'collapser'}`)
resultBody.querySelector('.col-name').innerText = testId
const formattedDuration = duration < 1 ? formatDuration(duration).ms : formatDuration(duration).formatted
resultBody.querySelector('.col-duration').innerText = formattedDuration
resultsTableRow.forEach((html) => {
const t = document.createElement('template')
t.innerHTML = html
resultBody.querySelector('.collapsible').appendChild(t.content)
})
resultBody.querySelector('.collapsible > td')?.classList.add(`${collapsed ? 'expander' : 'collapser'}`)
if (log) {
// Wrap lines starting with "E" with span.error to color those lines red
@ -107,16 +85,6 @@ const dom = {
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 })
}
@ -127,11 +95,6 @@ const dom = {
})
mediaViewer.setUp(resultBody, media)
// Add custom html from the pytest_html_results_table_row hook
const rows = transformTableObj(resultsTableRow)
resultsTableRow && insertAdditionalHTML(rows.inserts, resultBody, 'td')
resultsTableRow && insertAdditionalHTML(rows.appends, resultBody, 'tr', 'beforeend')
// Add custom html from the pytest_html_results_table_html hook
tableHtml?.forEach((item) => {
resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item)

View File

@ -1,4 +1,3 @@
const { formatDuration } = require('./utils.js')
const { dom, findAll } = require('./dom.js')
const { manager } = require('./datamanager.js')
const { doSort } = require('./sort.js')
@ -52,18 +51,6 @@ const renderContent = (tests) => {
item.colSpan = document.querySelectorAll('th').length
})
const { headerPops } = manager.renderData
if (headerPops > 0) {
// remove 'headerPops' number of header columns
findAll('#results-table-head th').splice(-headerPops).forEach((column) => column.remove())
// remove 'headerPops' number of row columns
const resultRows = findAll('.results-table-row')
resultRows.forEach((elem) => {
findAll('td:not(.extra)', elem).splice(-headerPops).forEach((column) => column.remove())
})
}
findAll('.sortable').forEach((elem) => {
elem.addEventListener('click', (evt) => {
const { target: element } = evt
@ -80,7 +67,7 @@ const renderContent = (tests) => {
})
}
const renderDerived = (tests, collectedItems, isFinished) => {
const renderDerived = (tests, collectedItems, isFinished, formattedDuration) => {
const currentFilter = getVisible()
possibleResults.forEach(({ result, label }) => {
const count = tests.filter((test) => test.result.toLowerCase() === result).length
@ -100,12 +87,8 @@ const renderDerived = (tests, collectedItems, isFinished) => {
['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('.run-count').innerText = `${numberOfTests} ${testWord} took ${formattedDuration}.`
document.querySelector('.summary__reload__button').classList.add('hidden')
} else {
document.querySelector('.run-count').innerText = `${numberOfTests} / ${collectedItems} tests done`
@ -135,11 +118,11 @@ const bindEvents = () => {
}
const redraw = () => {
const { testSubset, allTests, collectedItems, isFinished } = manager
const { testSubset, allTests, collectedItems, isFinished, formattedDuration } = manager
renderStatic()
renderContent(testSubset)
renderDerived(allTests, collectedItems, isFinished)
renderDerived(allTests, collectedItems, isFinished, formattedDuration )
}
exports.redraw = redraw

View File

@ -1,41 +0,0 @@
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)}`,
}
}
const transformTableObj = (obj) => {
const appends = {}
const inserts = {}
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
key.startsWith('Z') ? appends[key] = obj[key] : inserts[key] = obj[key]
}
}
return {
appends,
inserts,
}
}
module.exports = {
formatDuration,
transformTableObj,
}

View File

@ -1,86 +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/.
import re
import warnings
class Table:
def __init__(self):
self._html = {}
@property
def html(self):
return self._html
@html.setter
def html(self, value):
self._html = value
class Cell(Table):
def __init__(self):
super().__init__()
self._append_counter = 0
self._pop_counter = 0
self._sortables = dict()
def __setitem__(self, key, value):
warnings.warn(
"list-type assignment is deprecated and support "
"will be removed in a future release. "
"Please use 'insert()' instead.",
DeprecationWarning,
)
self.insert(key, value)
@property
def sortables(self):
return self._sortables
def append(self, item):
# We need a way of separating inserts from appends in JS,
# hence the "Z" prefix
self.insert(f"Z{self._append_counter}", item)
self._append_counter += 1
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._extract_sortable(html)
self._html[index] = html
def pop(self, *args):
self._pop_counter += 1
def get_pops(self):
return self._pop_counter
def _extract_sortable(self, html):
match = re.search(r'<td class="col-(\w+)">(.*?)</', html)
if match:
sortable = match.group(1)
value = match.group(2)
self._sortables[sortable] = value
class Header(Cell):
pass
class Row(Cell):
def __delitem__(self, key):
# This means the item should be removed
self._html = None
def pop(self, *args):
# Calling pop on header is sufficient
pass

View File

@ -107,7 +107,6 @@ class TestHTML:
def test_can_format_duration_column(
self, testdir, duration_formatter, expected_report_content
):
testdir.makeconftest(
f"""
import pytest

View File

@ -650,7 +650,7 @@ class TestHTML:
pytester.makepyfile("def test_pass(): pass")
page = run(pytester)
description_index = 3
description_index = 4
time_index = 2
assert_that(get_text(page, header_selector.format(time_index))).is_equal_to(
"Time"

View File

@ -2,7 +2,6 @@ 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, transformTableObj } = 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')
@ -154,37 +153,6 @@ describe('Sort tests', () => {
})
})
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('transformTableObj', () => {
it('handles empty object', () => {
expect(transformTableObj({})).to.eql({ appends: {}, inserts: {} })
})
it('handles no appends', () => {
const expected = { 1: 'hello', 2: 'goodbye' }
expect(transformTableObj(expected)).to.eql({ appends: {}, inserts: expected })
})
it('handles no inserts', () => {
const expected = { 'Z1': 'hello', 'Z2': 'goodbye' }
expect(transformTableObj(expected)).to.eql({ appends: expected, inserts: {} })
})
it('handles both', () => {
const expected = { appends: { 'Z1': 'hello', 'Z2': 'goodbye' }, inserts: { 1: 'mee', 2: 'moo' } }
expect(transformTableObj({ ...expected.appends, ...expected.inserts })).to.eql(expected)
})
})
})
describe('Storage tests', () => {
describe('getCollapsedCategory', () => {
let originalWindow