28529d5bb8
Should be working as is (but may require some hackery). More automagic to come once setup.py is written.
652 lines
19 KiB
Python
652 lines
19 KiB
Python
"""
|
|
A plugin for nosetests that will write out test results to results.html. The
|
|
code is adapted from the example html output plugin at
|
|
https://github.com/nose-devs/nose/blob/master/examples/html_plugin/htmlplug.py
|
|
and the pyunit Html test runner at
|
|
http://tungwaiyip.info/software/HTMLTestRunner.html
|
|
|
|
Original HTMLTestRunner License:
|
|
------------------------------------------------------------------------
|
|
Copyright (c) 2004-2007, Wai Yip Tung
|
|
All rights reserved.
|
|
|
|
Redistribution and use in source and binary forms, with or without
|
|
modification, are permitted provided that the following conditions are
|
|
met:
|
|
|
|
* Redistributions of source code must retain the above copyright notice,
|
|
this list of conditions and the following disclaimer.
|
|
* Redistributions in binary form must reproduce the above copyright
|
|
notice, this list of conditions and the following disclaimer in the
|
|
documentation and/or other materials provided with the distribution.
|
|
* Neither the name Wai Yip Tung nor the names of its contributors may be
|
|
used to endorse or promote products derived from this software without
|
|
specific prior written permission.
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
|
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
|
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
import traceback
|
|
from nose.plugins import Plugin
|
|
from xml.sax import saxutils
|
|
|
|
__version__ = "0.0.1"
|
|
|
|
class TemplateData(object):
|
|
"""
|
|
Define a HTML template for report customerization and generation.
|
|
|
|
Overall structure of an HTML report
|
|
|
|
HTML
|
|
+------------------------+
|
|
|<html> |
|
|
| <head> |
|
|
| |
|
|
| STYLESHEET |
|
|
| +----------------+ |
|
|
| | | |
|
|
| +----------------+ |
|
|
| |
|
|
| </head> |
|
|
| |
|
|
| <body> |
|
|
| |
|
|
| HEADING |
|
|
| +----------------+ |
|
|
| | | |
|
|
| +----------------+ |
|
|
| |
|
|
| REPORT |
|
|
| +----------------+ |
|
|
| | | |
|
|
| +----------------+ |
|
|
| |
|
|
| ENDING |
|
|
| +----------------+ |
|
|
| | | |
|
|
| +----------------+ |
|
|
| |
|
|
| </body> |
|
|
|</html> |
|
|
+------------------------+
|
|
"""
|
|
|
|
STATUS = {
|
|
0: 'pass',
|
|
1: 'fail',
|
|
2: 'error',
|
|
}
|
|
|
|
DEFAULT_TITLE = 'Unit Test Report'
|
|
DEFAULT_DESCRIPTION = ''
|
|
|
|
# ------------------------------------------------------------------------
|
|
# HTML Template
|
|
|
|
HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<title>%(title)s</title>
|
|
<meta name="generator" content="%(generator)s"/>
|
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
|
%(stylesheet)s
|
|
</head>
|
|
<body>
|
|
<script language="javascript" type="text/javascript"><!--
|
|
output_list = Array();
|
|
|
|
/* level - 0:Summary; 1:Failed; 2:All */
|
|
function showCase(level) {
|
|
trs = document.getElementsByTagName("tr");
|
|
for (var i = 0; i < trs.length; i++) {
|
|
tr = trs[i];
|
|
id = tr.id;
|
|
if (id.substr(0,2) == 'ft') {
|
|
if (level < 1) {
|
|
tr.className = 'hiddenRow';
|
|
}
|
|
else {
|
|
tr.className = '';
|
|
}
|
|
}
|
|
if (id.substr(0,2) == 'pt') {
|
|
if (level > 1) {
|
|
tr.className = '';
|
|
}
|
|
else {
|
|
tr.className = 'hiddenRow';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function showClassDetail(cid, count) {
|
|
var id_list = Array(count);
|
|
var toHide = 1;
|
|
for (var i = 0; i < count; i++) {
|
|
tid0 = 't' + cid.substr(1) + '.' + (i+1);
|
|
tid = 'f' + tid0;
|
|
tr = document.getElementById(tid);
|
|
if (!tr) {
|
|
tid = 'p' + tid0;
|
|
tr = document.getElementById(tid);
|
|
}
|
|
id_list[i] = tid;
|
|
if (tr.className) {
|
|
toHide = 0;
|
|
}
|
|
}
|
|
for (var i = 0; i < count; i++) {
|
|
tid = id_list[i];
|
|
if (toHide) {
|
|
document.getElementById('div_'+tid).style.display = 'none'
|
|
document.getElementById(tid).className = 'hiddenRow';
|
|
}
|
|
else {
|
|
document.getElementById(tid).className = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function showTestDetail(div_id){
|
|
var details_div = document.getElementById(div_id)
|
|
var displayState = details_div.style.display
|
|
// alert(displayState)
|
|
if (displayState != 'block' ) {
|
|
displayState = 'block'
|
|
details_div.style.display = 'block'
|
|
}
|
|
else {
|
|
details_div.style.display = 'none'
|
|
}
|
|
}
|
|
|
|
|
|
function html_escape(s) {
|
|
s = s.replace(/&/g,'&');
|
|
s = s.replace(/</g,'<');
|
|
s = s.replace(/>/g,'>');
|
|
return s;
|
|
}
|
|
|
|
/* obsoleted by detail in <div>
|
|
function showOutput(id, name) {
|
|
var w = window.open("", //url
|
|
name,
|
|
"resizable,scrollbars,status,width=800,height=450");
|
|
d = w.document;
|
|
d.write("<pre>");
|
|
d.write(html_escape(output_list[id]));
|
|
d.write("\n");
|
|
d.write("<a href='javascript:window.close()'>close</a>\n");
|
|
d.write("</pre>\n");
|
|
d.close();
|
|
}
|
|
*/
|
|
--></script>
|
|
|
|
%(heading)s
|
|
%(report)s
|
|
%(ending)s
|
|
|
|
</body>
|
|
</html>
|
|
"""
|
|
# variables: (title, generator, stylesheet, heading, report, ending)
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
# Stylesheet
|
|
#
|
|
# alternatively use a <link> for external style sheet, e.g.
|
|
# <link rel="stylesheet" href="$url" type="text/css">
|
|
|
|
STYLESHEET_TMPL = """
|
|
<style type="text/css" media="screen">
|
|
body { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
|
|
table { font-size: 100%; }
|
|
pre { }
|
|
|
|
/* -- heading ---------------------------------------------------------------------- */
|
|
h1 {
|
|
font-size: 16pt;
|
|
color: gray;
|
|
}
|
|
.heading {
|
|
margin-top: 0ex;
|
|
margin-bottom: 1ex;
|
|
}
|
|
|
|
.heading .attribute {
|
|
margin-top: 1ex;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.heading .description {
|
|
margin-top: 4ex;
|
|
margin-bottom: 6ex;
|
|
}
|
|
|
|
/* -- css div popup ------------------------------------------------------------------------ */
|
|
a.popup_link {
|
|
}
|
|
|
|
a.popup_link:hover {
|
|
color: red;
|
|
}
|
|
|
|
.popup_window {
|
|
display: none;
|
|
position: relative;
|
|
left: 0px;
|
|
top: 0px;
|
|
/*border: solid #627173 1px; */
|
|
padding: 10px;
|
|
background-color: #E6E6D6;
|
|
font-family: "Lucida Console", "Courier New", Courier, monospace;
|
|
text-align: left;
|
|
font-size: 8pt;
|
|
width: 500px;
|
|
}
|
|
|
|
}
|
|
/* -- report ------------------------------------------------------------------------ */
|
|
#show_detail_line {
|
|
margin-top: 3ex;
|
|
margin-bottom: 1ex;
|
|
}
|
|
#result_table {
|
|
width: 80%;
|
|
border-collapse: collapse;
|
|
border: 1px solid #777;
|
|
}
|
|
#header_row {
|
|
font-weight: bold;
|
|
color: white;
|
|
background-color: #777;
|
|
}
|
|
#result_table td {
|
|
border: 1px solid #777;
|
|
padding: 2px;
|
|
}
|
|
#total_row { font-weight: bold; }
|
|
.passClass { background-color: #6c6; }
|
|
.failClass { background-color: #c60; }
|
|
.errorClass { background-color: #c00; }
|
|
.passCase { color: #6c6; }
|
|
.failCase { color: #c60; font-weight: bold; }
|
|
.errorCase { color: #c00; font-weight: bold; }
|
|
.hiddenRow { display: none; }
|
|
.testcase { margin-left: 2em; }
|
|
|
|
|
|
/* -- ending ---------------------------------------------------------------------- */
|
|
#ending {
|
|
}
|
|
|
|
</style>
|
|
"""
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
# Heading
|
|
#
|
|
|
|
HEADING_TMPL = """<div class='heading'>
|
|
<h1>%(title)s</h1>
|
|
%(parameters)s
|
|
<p class='description'>%(description)s</p>
|
|
</div>
|
|
|
|
""" # variables: (title, parameters, description)
|
|
|
|
HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
|
|
""" # variables: (name, value)
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
# Report
|
|
#
|
|
|
|
REPORT_TMPL = """
|
|
<p id='show_detail_line'>Show
|
|
<a href='javascript:showCase(0)'>Summary</a>
|
|
<a href='javascript:showCase(1)'>Failed</a>
|
|
<a href='javascript:showCase(2)'>All</a>
|
|
</p>
|
|
<table id='result_table'>
|
|
<colgroup>
|
|
<col align='left' />
|
|
<col align='right' />
|
|
<col align='right' />
|
|
<col align='right' />
|
|
<col align='right' />
|
|
<col align='right' />
|
|
</colgroup>
|
|
<tr id='header_row'>
|
|
<td>Test Group/Test case</td>
|
|
<td>Count</td>
|
|
<td>Pass</td>
|
|
<td>Fail</td>
|
|
<td>Error</td>
|
|
<td>View</td>
|
|
</tr>
|
|
%(test_list)s
|
|
<tr id='total_row'>
|
|
<td>Total</td>
|
|
<td>%(count)s</td>
|
|
<td>%(Pass)s</td>
|
|
<td>%(fail)s</td>
|
|
<td>%(error)s</td>
|
|
<td> </td>
|
|
</tr>
|
|
</table>
|
|
""" # variables: (test_list, count, Pass, fail, error)
|
|
|
|
REPORT_CLASS_TMPL = r"""
|
|
<tr class='%(style)s'>
|
|
<td>%(desc)s</td>
|
|
<td>%(count)s</td>
|
|
<td>%(Pass)s</td>
|
|
<td>%(fail)s</td>
|
|
<td>%(error)s</td>
|
|
<td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
|
|
</tr>
|
|
""" # variables: (style, desc, count, Pass, fail, error, cid)
|
|
|
|
|
|
REPORT_TEST_WITH_OUTPUT_TMPL = r"""
|
|
<tr id='%(tid)s' class='%(Class)s'>
|
|
<td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
|
|
<td colspan='5' align='center'>
|
|
|
|
<!--css div popup start-->
|
|
<a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
|
|
%(status)s</a>
|
|
|
|
<div id='div_%(tid)s' class="popup_window">
|
|
<div style='text-align: right; color:red;cursor:pointer'>
|
|
<a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
|
|
[x]</a>
|
|
</div>
|
|
<pre>
|
|
%(script)s
|
|
</pre>
|
|
</div>
|
|
<!--css div popup end-->
|
|
|
|
</td>
|
|
</tr>
|
|
""" # variables: (tid, Class, style, desc, status)
|
|
|
|
|
|
REPORT_TEST_NO_OUTPUT_TMPL = r"""
|
|
<tr id='%(tid)s' class='%(Class)s'>
|
|
<td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
|
|
<td colspan='5' align='center'>%(status)s</td>
|
|
</tr>
|
|
""" # variables: (tid, Class, style, desc, status)
|
|
|
|
|
|
REPORT_TEST_OUTPUT_TMPL = r"""
|
|
%(id)s: %(output)s
|
|
""" # variables: (id, output)
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
# ENDING
|
|
#
|
|
|
|
ENDING_TMPL = """<div id='ending'> </div>"""
|
|
|
|
# -------------------- The end of the Template class -------------------
|
|
|
|
|
|
class HtmlOutput(Plugin):
|
|
"""Output test results in html.
|
|
"""
|
|
|
|
name = 'html-output'
|
|
score = 2 # run late
|
|
|
|
def __init__(self):
|
|
super(HtmlOutput, self).__init__()
|
|
self.success_count = 0
|
|
self.failure_count = 0
|
|
self.error_count = 0
|
|
self.result = []
|
|
|
|
def options(self, parser, env=os.environ):
|
|
super(HtmlOutput, self).options(parser, env)
|
|
parser.add_option("--html-out-file", action="store",
|
|
default=env.get('NOSE_HTML_OUT_FILE', 'results.html'),
|
|
dest="html_file",
|
|
metavar="FILE",
|
|
help="Produce results in the specified HTML file.")
|
|
|
|
def configure(self, options, conf):
|
|
super(HtmlOutput, self).configure(options, conf)
|
|
self.html_file = None
|
|
if options.html_file:
|
|
self.html_file = options.html_file
|
|
|
|
def begin(self):
|
|
self.startTime = datetime.datetime.now()
|
|
|
|
def addSuccess(self, test):
|
|
self.success_count += 1
|
|
output = test.shortDescription()
|
|
if output is None:
|
|
output = test.id()
|
|
self.result.append((0, test, output, ''))
|
|
|
|
def addError(self, test, err):
|
|
self.error_count += 1
|
|
_exc_str = self.formatErr(err)
|
|
output = test.shortDescription()
|
|
if output is None:
|
|
output = test.id()
|
|
self.result.append((2, test, output, _exc_str))
|
|
|
|
def addFailure(self, test, err):
|
|
self.failure_count += 1
|
|
_exc_str = self.formatErr(err)
|
|
output = test.shortDescription()
|
|
if output is None:
|
|
output = test.id()
|
|
self.result.append((1, test, output, _exc_str))
|
|
|
|
def formatErr(self, err):
|
|
exctype, value, tb = err
|
|
return ''.join(traceback.format_exception(exctype, value, tb))
|
|
|
|
def setOutputStream(self, stream):
|
|
# grab for Monitoring
|
|
self.stream = stream
|
|
# return dummy stream
|
|
class dummy:
|
|
def write(self, *arg):
|
|
pass
|
|
def writeln(self, *arg):
|
|
pass
|
|
d = dummy()
|
|
#return d
|
|
|
|
def report(self, stream):
|
|
self.stopTime = datetime.datetime.now()
|
|
report_attrs = self._getReportAttributes()
|
|
generator = 'html-output-plugin %s' % __version__
|
|
heading = self._generate_heading(report_attrs)
|
|
report = self._generate_report()
|
|
ending = self._generate_ending()
|
|
output = TemplateData.HTML_TMPL % dict(
|
|
title = saxutils.escape(TemplateData.DEFAULT_TITLE),
|
|
generator = generator,
|
|
stylesheet = TemplateData.STYLESHEET_TMPL,
|
|
heading = heading,
|
|
report = report,
|
|
ending = ending,
|
|
)
|
|
if self.html_file:
|
|
html_file = open(self.html_file, 'w')
|
|
html_file.write(output.encode('utf8'))
|
|
else:
|
|
stream.write(output.encode('utf8'))
|
|
|
|
def _getReportAttributes(self):
|
|
"""Return report attributes as a list of (name, value).
|
|
"""
|
|
startTime = str(self.startTime)[:19]
|
|
duration = str(self.stopTime - self.startTime)
|
|
status = []
|
|
if self.success_count:
|
|
status.append('Pass %s' % self.success_count)
|
|
if self.failure_count:
|
|
status.append('Failure %s' % self.failure_count)
|
|
if self.error_count:
|
|
status.append('Error %s' % self.error_count )
|
|
if status:
|
|
status = ' '.join(status)
|
|
else:
|
|
status = 'none'
|
|
return [
|
|
('Start Time', startTime),
|
|
('Duration', duration),
|
|
('Status', status),
|
|
]
|
|
|
|
def _generate_heading(self, report_attrs):
|
|
a_lines = []
|
|
for name, value in report_attrs:
|
|
line = TemplateData.HEADING_ATTRIBUTE_TMPL % dict(
|
|
name = saxutils.escape(name),
|
|
value = saxutils.escape(value),
|
|
)
|
|
a_lines.append(line)
|
|
heading = TemplateData.HEADING_TMPL % dict(
|
|
title = saxutils.escape(TemplateData.DEFAULT_TITLE),
|
|
parameters = ''.join(a_lines),
|
|
description = saxutils.escape(TemplateData.DEFAULT_DESCRIPTION),
|
|
)
|
|
return heading
|
|
|
|
def _generate_report(self):
|
|
rows = []
|
|
sortedResult = self._sortResult(self.result)
|
|
for cid, (cls, cls_results) in enumerate(sortedResult):
|
|
# subtotal for a class
|
|
np = nf = ne = 0
|
|
for n,t,o,e in cls_results:
|
|
if n == 0: np += 1
|
|
elif n == 1: nf += 1
|
|
else: ne += 1
|
|
|
|
# format class description
|
|
if cls.__module__ == "__main__":
|
|
name = cls.__name__
|
|
else:
|
|
name = "%s.%s" % (cls.__module__, cls.__name__)
|
|
doc = cls.__doc__ and cls.__doc__.split("\n")[0] or ""
|
|
desc = doc and '%s: %s' % (name, doc) or name
|
|
|
|
row = TemplateData.REPORT_CLASS_TMPL % dict(
|
|
style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
|
|
desc = desc,
|
|
count = np+nf+ne,
|
|
Pass = np,
|
|
fail = nf,
|
|
error = ne,
|
|
cid = 'c%s' % (cid+1),
|
|
)
|
|
rows.append(row)
|
|
|
|
for tid, (n,t,o,e) in enumerate(cls_results):
|
|
self._generate_report_test(rows, cid, tid, n, t, o, e)
|
|
|
|
report = TemplateData.REPORT_TMPL % dict(
|
|
test_list = ''.join(rows),
|
|
count = str(self.success_count + self.failure_count + self.error_count),
|
|
Pass = str(self.success_count),
|
|
fail = str(self.failure_count),
|
|
error = str(self.error_count),
|
|
)
|
|
return report
|
|
|
|
def _sortResult(self, result_list):
|
|
# unittest does not seems to run in any particular order.
|
|
# Here at least we want to group them together by class.
|
|
rmap = {}
|
|
classes = []
|
|
for n,t,o,e in result_list:
|
|
cls = t.__class__
|
|
if not rmap.has_key(cls):
|
|
rmap[cls] = []
|
|
classes.append(cls)
|
|
rmap[cls].append((n,t,o,e))
|
|
r = [(cls, rmap[cls]) for cls in classes]
|
|
return r
|
|
|
|
def _generate_report_test(self, rows, cid, tid, n, t, o, e):
|
|
# e.g. 'pt1.1', 'ft1.1', etc
|
|
has_output = bool(o or e)
|
|
tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
|
|
name = t.id().split('.')[-1]
|
|
doc = t.shortDescription() or ""
|
|
desc = doc and ('%s: %s' % (name, doc)) or name
|
|
tmpl = has_output and TemplateData.REPORT_TEST_WITH_OUTPUT_TMPL or TemplateData.REPORT_TEST_NO_OUTPUT_TMPL
|
|
|
|
# Comments below from the original source project.
|
|
# TODO: clean this up within the context of a nose plugin.
|
|
# o and e should be byte string because they are collected from stdout and stderr?
|
|
if isinstance(o,str):
|
|
# TODO: some problem with 'string_escape': it escape \n and mess up formating
|
|
# uo = unicode(o.encode('string_escape'))
|
|
uo = o.decode('latin-1')
|
|
else:
|
|
uo = o
|
|
if isinstance(e,str):
|
|
# TODO: some problem with 'string_escape': it escape \n and mess up formating
|
|
# ue = unicode(e.encode('string_escape'))
|
|
ue = e.decode('latin-1')
|
|
else:
|
|
ue = e
|
|
|
|
script = TemplateData.REPORT_TEST_OUTPUT_TMPL % dict(
|
|
id = tid,
|
|
output = saxutils.escape(uo+ue),
|
|
)
|
|
|
|
row = tmpl % dict(
|
|
tid = tid,
|
|
Class = (n == 0 and 'hiddenRow' or 'none'),
|
|
style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or 'none'),
|
|
desc = desc,
|
|
script = script,
|
|
status = TemplateData.STATUS[n],
|
|
)
|
|
rows.append(row)
|
|
if not has_output:
|
|
return
|
|
|
|
def _generate_ending(self):
|
|
return TemplateData.ENDING_TMPL
|