|
| 1 | +import bisect |
| 2 | +import datetime |
| 3 | +import json |
| 4 | +import os |
| 5 | +import time |
| 6 | +from collections import defaultdict |
| 7 | +from collections import OrderedDict |
| 8 | + |
| 9 | +from py.xml import html |
| 10 | +from py.xml import raw |
| 11 | + |
| 12 | +from . import __pypi_url__ |
| 13 | +from . import __version__ |
| 14 | +from .outcome import Outcome |
| 15 | +from .result import TestResult |
| 16 | +from .util import ansi_support |
| 17 | + |
| 18 | + |
| 19 | +class HTMLReport: |
| 20 | + def __init__(self, logfile, config): |
| 21 | + logfile = os.path.expanduser(os.path.expandvars(logfile)) |
| 22 | + self.logfile = os.path.abspath(logfile) |
| 23 | + self.test_logs = [] |
| 24 | + self.title = os.path.basename(self.logfile) |
| 25 | + self.results = [] |
| 26 | + self.errors = self.failed = 0 |
| 27 | + self.passed = self.skipped = 0 |
| 28 | + self.xfailed = self.xpassed = 0 |
| 29 | + has_rerun = config.pluginmanager.hasplugin("rerunfailures") |
| 30 | + self.rerun = 0 if has_rerun else None |
| 31 | + self.self_contained = config.getoption("self_contained_html") |
| 32 | + self.config = config |
| 33 | + self.reports = defaultdict(list) |
| 34 | + |
| 35 | + def _appendrow(self, outcome, report): |
| 36 | + result = TestResult(outcome, report, self.logfile, self.config) |
| 37 | + if result.row_table is not None: |
| 38 | + index = bisect.bisect_right(self.results, result) |
| 39 | + self.results.insert(index, result) |
| 40 | + tbody = html.tbody( |
| 41 | + result.row_table, |
| 42 | + class_="{} results-table-row".format(result.outcome.lower()), |
| 43 | + ) |
| 44 | + if result.row_extra is not None: |
| 45 | + tbody.append(result.row_extra) |
| 46 | + self.test_logs.insert(index, tbody) |
| 47 | + |
| 48 | + def append_passed(self, report): |
| 49 | + if report.when == "call": |
| 50 | + if hasattr(report, "wasxfail"): |
| 51 | + self.xpassed += 1 |
| 52 | + self._appendrow("XPassed", report) |
| 53 | + else: |
| 54 | + self.passed += 1 |
| 55 | + self._appendrow("Passed", report) |
| 56 | + |
| 57 | + def append_failed(self, report): |
| 58 | + if getattr(report, "when", None) == "call": |
| 59 | + if hasattr(report, "wasxfail"): |
| 60 | + # pytest < 3.0 marked xpasses as failures |
| 61 | + self.xpassed += 1 |
| 62 | + self._appendrow("XPassed", report) |
| 63 | + else: |
| 64 | + self.failed += 1 |
| 65 | + self._appendrow("Failed", report) |
| 66 | + else: |
| 67 | + self.errors += 1 |
| 68 | + self._appendrow("Error", report) |
| 69 | + |
| 70 | + def append_rerun(self, report): |
| 71 | + self.rerun += 1 |
| 72 | + self._appendrow("Rerun", report) |
| 73 | + |
| 74 | + def append_skipped(self, report): |
| 75 | + if hasattr(report, "wasxfail"): |
| 76 | + self.xfailed += 1 |
| 77 | + self._appendrow("XFailed", report) |
| 78 | + else: |
| 79 | + self.skipped += 1 |
| 80 | + self._appendrow("Skipped", report) |
| 81 | + |
| 82 | + def _generate_report(self, session): |
| 83 | + suite_stop_time = time.time() |
| 84 | + suite_time_delta = suite_stop_time - self.suite_start_time |
| 85 | + numtests = self.passed + self.failed + self.xpassed + self.xfailed |
| 86 | + generated = datetime.datetime.now() |
| 87 | + |
| 88 | + with open( |
| 89 | + os.path.join(os.path.dirname(__file__), "resources", "style.css") |
| 90 | + ) as style_css_fp: |
| 91 | + self.style_css = style_css_fp.read() |
| 92 | + |
| 93 | + if ansi_support(): |
| 94 | + ansi_css = [ |
| 95 | + "\n/******************************", |
| 96 | + " * ANSI2HTML STYLES", |
| 97 | + " ******************************/\n", |
| 98 | + ] |
| 99 | + ansi_css.extend([str(r) for r in ansi_support().style.get_styles()]) |
| 100 | + self.style_css += "\n".join(ansi_css) |
| 101 | + |
| 102 | + # <DF> Add user-provided CSS |
| 103 | + for path in self.config.getoption("css"): |
| 104 | + self.style_css += "\n/******************************" |
| 105 | + self.style_css += "\n * CUSTOM CSS" |
| 106 | + self.style_css += f"\n * {path}" |
| 107 | + self.style_css += "\n ******************************/\n\n" |
| 108 | + with open(path) as f: |
| 109 | + self.style_css += f.read() |
| 110 | + |
| 111 | + css_href = "assets/style.css" |
| 112 | + html_css = html.link(href=css_href, rel="stylesheet", type="text/css") |
| 113 | + if self.self_contained: |
| 114 | + html_css = html.style(raw(self.style_css)) |
| 115 | + |
| 116 | + session.config.hook.pytest_html_report_title(report=self) |
| 117 | + |
| 118 | + head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css) |
| 119 | + |
| 120 | + outcomes = [ |
| 121 | + Outcome("passed", self.passed), |
| 122 | + Outcome("skipped", self.skipped), |
| 123 | + Outcome("failed", self.failed), |
| 124 | + Outcome("error", self.errors, label="errors"), |
| 125 | + Outcome("xfailed", self.xfailed, label="expected failures"), |
| 126 | + Outcome("xpassed", self.xpassed, label="unexpected passes"), |
| 127 | + ] |
| 128 | + |
| 129 | + if self.rerun is not None: |
| 130 | + outcomes.append(Outcome("rerun", self.rerun)) |
| 131 | + |
| 132 | + summary = [ |
| 133 | + html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "), |
| 134 | + html.p( |
| 135 | + "(Un)check the boxes to filter the results.", |
| 136 | + class_="filter", |
| 137 | + hidden="true", |
| 138 | + ), |
| 139 | + ] |
| 140 | + |
| 141 | + for i, outcome in enumerate(outcomes, start=1): |
| 142 | + summary.append(outcome.checkbox) |
| 143 | + summary.append(outcome.summary_item) |
| 144 | + if i < len(outcomes): |
| 145 | + summary.append(", ") |
| 146 | + |
| 147 | + cells = [ |
| 148 | + html.th("Result", class_="sortable result initial-sort", col="result"), |
| 149 | + html.th("Test", class_="sortable", col="name"), |
| 150 | + html.th("Duration", class_="sortable", col="duration"), |
| 151 | + html.th("Links", class_="sortable links", col="links"), |
| 152 | + ] |
| 153 | + session.config.hook.pytest_html_results_table_header(cells=cells) |
| 154 | + |
| 155 | + results = [ |
| 156 | + html.h2("Results"), |
| 157 | + html.table( |
| 158 | + [ |
| 159 | + html.thead( |
| 160 | + html.tr(cells), |
| 161 | + html.tr( |
| 162 | + [ |
| 163 | + html.th( |
| 164 | + "No results found. Try to check the filters", |
| 165 | + colspan=len(cells), |
| 166 | + ) |
| 167 | + ], |
| 168 | + id="not-found-message", |
| 169 | + hidden="true", |
| 170 | + ), |
| 171 | + id="results-table-head", |
| 172 | + ), |
| 173 | + self.test_logs, |
| 174 | + ], |
| 175 | + id="results-table", |
| 176 | + ), |
| 177 | + ] |
| 178 | + |
| 179 | + with open( |
| 180 | + os.path.join(os.path.dirname(__file__), "resources", "main.js") |
| 181 | + ) as main_js_fp: |
| 182 | + main_js = main_js_fp.read() |
| 183 | + |
| 184 | + body = html.body( |
| 185 | + html.script(raw(main_js)), |
| 186 | + html.h1(self.title), |
| 187 | + html.p( |
| 188 | + "Report generated on {} at {} by ".format( |
| 189 | + generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S") |
| 190 | + ), |
| 191 | + html.a("pytest-html", href=__pypi_url__), |
| 192 | + f" v{__version__}", |
| 193 | + ), |
| 194 | + onLoad="init()", |
| 195 | + ) |
| 196 | + |
| 197 | + body.extend(self._generate_environment(session.config)) |
| 198 | + |
| 199 | + summary_prefix, summary_postfix = [], [] |
| 200 | + session.config.hook.pytest_html_results_summary( |
| 201 | + prefix=summary_prefix, summary=summary, postfix=summary_postfix |
| 202 | + ) |
| 203 | + body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix) |
| 204 | + |
| 205 | + body.extend(results) |
| 206 | + |
| 207 | + doc = html.html(head, body) |
| 208 | + |
| 209 | + unicode_doc = "<!DOCTYPE html>\n{}".format(doc.unicode(indent=2)) |
| 210 | + |
| 211 | + # Fix encoding issues, e.g. with surrogates |
| 212 | + unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace") |
| 213 | + return unicode_doc.decode("utf-8") |
| 214 | + |
| 215 | + def _generate_environment(self, config): |
| 216 | + if not hasattr(config, "_metadata") or config._metadata is None: |
| 217 | + return [] |
| 218 | + |
| 219 | + metadata = config._metadata |
| 220 | + environment = [html.h2("Environment")] |
| 221 | + rows = [] |
| 222 | + |
| 223 | + keys = [k for k in metadata.keys()] |
| 224 | + if not isinstance(metadata, OrderedDict): |
| 225 | + keys.sort() |
| 226 | + |
| 227 | + for key in keys: |
| 228 | + value = metadata[key] |
| 229 | + if isinstance(value, str) and value.startswith("http"): |
| 230 | + value = html.a(value, href=value, target="_blank") |
| 231 | + elif isinstance(value, (list, tuple, set)): |
| 232 | + value = ", ".join(str(i) for i in sorted(map(str, value))) |
| 233 | + elif isinstance(value, dict): |
| 234 | + sorted_dict = {k: value[k] for k in sorted(value)} |
| 235 | + value = json.dumps(sorted_dict) |
| 236 | + raw_value_string = raw(str(value)) |
| 237 | + rows.append(html.tr(html.td(key), html.td(raw_value_string))) |
| 238 | + |
| 239 | + environment.append(html.table(rows, id="environment")) |
| 240 | + return environment |
| 241 | + |
| 242 | + def _save_report(self, report_content): |
| 243 | + dir_name = os.path.dirname(self.logfile) |
| 244 | + assets_dir = os.path.join(dir_name, "assets") |
| 245 | + |
| 246 | + os.makedirs(dir_name, exist_ok=True) |
| 247 | + if not self.self_contained: |
| 248 | + os.makedirs(assets_dir, exist_ok=True) |
| 249 | + |
| 250 | + with open(self.logfile, "w", encoding="utf-8") as f: |
| 251 | + f.write(report_content) |
| 252 | + if not self.self_contained: |
| 253 | + style_path = os.path.join(assets_dir, "style.css") |
| 254 | + with open(style_path, "w", encoding="utf-8") as f: |
| 255 | + f.write(self.style_css) |
| 256 | + |
| 257 | + def _post_process_reports(self): |
| 258 | + for test_name, test_reports in self.reports.items(): |
| 259 | + report_outcome = "passed" |
| 260 | + wasxfail = False |
| 261 | + failure_when = None |
| 262 | + full_text = "" |
| 263 | + extras = [] |
| 264 | + duration = 0.0 |
| 265 | + |
| 266 | + # in theory the last one should have all logs so we just go |
| 267 | + # through them all to figure out the outcome, xfail, duration, |
| 268 | + # extras, and when it swapped from pass |
| 269 | + for test_report in test_reports: |
| 270 | + if test_report.outcome == "rerun": |
| 271 | + # reruns are separate test runs for all intensive purposes |
| 272 | + self.append_rerun(test_report) |
| 273 | + else: |
| 274 | + full_text += test_report.longreprtext |
| 275 | + extras.extend(getattr(test_report, "extra", [])) |
| 276 | + duration += getattr(test_report, "duration", 0.0) |
| 277 | + |
| 278 | + if ( |
| 279 | + test_report.outcome not in ("passed", "rerun") |
| 280 | + and report_outcome == "passed" |
| 281 | + ): |
| 282 | + report_outcome = test_report.outcome |
| 283 | + failure_when = test_report.when |
| 284 | + |
| 285 | + if hasattr(test_report, "wasxfail"): |
| 286 | + wasxfail = True |
| 287 | + |
| 288 | + # the following test_report.<X> = settings come at the end of us |
| 289 | + # looping through all test_reports that make up a single |
| 290 | + # case. |
| 291 | + |
| 292 | + # outcome on the right comes from the outcome of the various |
| 293 | + # test_reports that make up this test case |
| 294 | + # we are just carrying it over to the final report. |
| 295 | + test_report.outcome = report_outcome |
| 296 | + test_report.when = "call" |
| 297 | + test_report.nodeid = test_name |
| 298 | + test_report.longrepr = full_text |
| 299 | + test_report.extra = extras |
| 300 | + test_report.duration = duration |
| 301 | + |
| 302 | + if wasxfail: |
| 303 | + test_report.wasxfail = True |
| 304 | + |
| 305 | + if test_report.outcome == "passed": |
| 306 | + self.append_passed(test_report) |
| 307 | + elif test_report.outcome == "skipped": |
| 308 | + self.append_skipped(test_report) |
| 309 | + elif test_report.outcome == "failed": |
| 310 | + test_report.when = failure_when |
| 311 | + self.append_failed(test_report) |
| 312 | + |
| 313 | + def pytest_runtest_logreport(self, report): |
| 314 | + self.reports[report.nodeid].append(report) |
| 315 | + |
| 316 | + def pytest_collectreport(self, report): |
| 317 | + if report.failed: |
| 318 | + self.append_failed(report) |
| 319 | + |
| 320 | + def pytest_sessionstart(self, session): |
| 321 | + self.suite_start_time = time.time() |
| 322 | + |
| 323 | + def pytest_sessionfinish(self, session): |
| 324 | + self._post_process_reports() |
| 325 | + report_content = self._generate_report(session) |
| 326 | + self._save_report(report_content) |
| 327 | + |
| 328 | + def pytest_terminal_summary(self, terminalreporter): |
| 329 | + terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}") |
0 commit comments