reporters.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. # -*- coding: utf-8 -*-
  2. """!@package grass.gunittest.reporters
  3. @brief GRASS Python testing framework module for report generation
  4. Copyright (C) 2014 by the GRASS Development Team
  5. This program is free software under the GNU General Public
  6. License (>=v2). Read the file COPYING that comes with GRASS GIS
  7. for details.
  8. @author Vaclav Petras
  9. """
  10. import os
  11. import datetime
  12. import xml.sax.saxutils as saxutils
  13. import xml.etree.ElementTree as et
  14. import subprocess
  15. import StringIO
  16. import collections
  17. import re
  18. import grass.script as gscript
  19. from .utils import ensure_dir
  20. from .checkers import text_to_keyvalue
  21. def replace_in_file(file_path, pattern, repl):
  22. """
  23. :param repl: a repl paramter of ``re.sub()`` function
  24. """
  25. # using tmp file to store the replaced content
  26. tmp_file_path = file_path + '.tmp'
  27. old_file = open(file_path, 'r')
  28. new_file = open(tmp_file_path, 'w')
  29. for line in old_file:
  30. new_file.write(re.sub(pattern=pattern, string=line, repl=repl))
  31. new_file.close()
  32. old_file.close()
  33. # remove old file since it must not exist for rename/move
  34. os.remove(file_path)
  35. # replace old file by new file
  36. os.rename(tmp_file_path, file_path)
  37. class NoopFileAnonymizer(object):
  38. def anonymize(self, filenames):
  39. pass
  40. class FileAnonymizer(object):
  41. def __init__(self, paths_to_remove, remove_gisbase=True,
  42. remove_gisdbase=False):
  43. self._paths_to_remove = []
  44. if remove_gisbase:
  45. gisbase = os.environ['GISBASE']
  46. self._paths_to_remove.append(gisbase)
  47. if remove_gisdbase:
  48. gisdbase = gscript.gis.get['GISDBASE']
  49. self._paths_to_remove.append(gisdbase)
  50. if paths_to_remove:
  51. self._paths_to_remove.extend(paths_to_remove)
  52. def anonymize(self, filenames):
  53. # besides GISBASE and test recursion start directory (which is
  54. # supposed to be source root directory or similar) we can also try
  55. # to remove user home directory and GISDBASE
  56. # we suppuse that we run in standard grass session
  57. # TODO: provide more effective implementation
  58. for path in self._paths_to_remove:
  59. for filename in filenames:
  60. path_end = r'[\\/]?'
  61. replace_in_file(filename, path + path_end, '')
  62. def get_source_url(path, revision, line=None):
  63. """
  64. :param path: directory or file path relative to remote repository root
  65. :param revision: SVN revision (should be a number)
  66. :param line: line in the file (should be None for directories)
  67. """
  68. tracurl = 'http://trac.osgeo.org/grass/browser/'
  69. if line:
  70. return '{tracurl}{path}?rev={revision}#L{line}'.format(**locals())
  71. else:
  72. return '{tracurl}{path}?rev={revision}'.format(**locals())
  73. def html_escape(text):
  74. """Escape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
  75. return saxutils.escape(text)
  76. def html_unescape(text):
  77. """Unescape ``'&amp;'``, ``'&lt;'``, and ``'&gt;'`` in a string of data."""
  78. return saxutils.unescape(text)
  79. def color_error_line(line):
  80. if line.startswith('ERROR: '):
  81. # TODO: use CSS class
  82. # ignoring the issue with \n at the end, HTML don't mind
  83. line = '<span style="color: red">' + line + "</span>"
  84. if line.startswith('FAIL: '):
  85. # TODO: use CSS class
  86. # ignoring the issue with \n at the end, HTML don't mind
  87. line = '<span style="color: red">' + line + "</span>"
  88. if line.startswith('WARNING: '):
  89. # TODO: use CSS class
  90. # ignoring the issue with \n at the end, HTML don't mind
  91. line = '<span style="color: blue">' + line + "</span>"
  92. #if line.startswith('Traceback ('):
  93. # line = '<span style="color: red">' + line + "</span>"
  94. return line
  95. def get_svn_revision():
  96. """Get SVN revision number
  97. :returns: SVN revision number as string or None if it is
  98. not possible to get
  99. """
  100. # TODO: here should be starting directory
  101. # but now we are using current as starting
  102. p = subprocess.Popen(['svnversion', '.'],
  103. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  104. stdout, stderr = p.communicate()
  105. rc = p.poll()
  106. if not rc:
  107. stdout = stdout.strip()
  108. if stdout.endswith('M'):
  109. stdout = stdout[:-1]
  110. if ':' in stdout:
  111. # the first one is the one of source code
  112. stdout = stdout.split(':')[0]
  113. return stdout
  114. else:
  115. return None
  116. def get_svn_info():
  117. """Get important information from ``svn info``
  118. :returns: SVN info as dictionary or None
  119. if it is not possible to obtain it
  120. """
  121. try:
  122. # TODO: introduce directory, not only current
  123. p = subprocess.Popen(['svn', 'info', '.', '--xml'],
  124. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  125. stdout, stderr = p.communicate()
  126. rc = p.poll()
  127. info = {}
  128. if not rc:
  129. root = et.fromstring(stdout)
  130. # TODO: get also date if this make sense
  131. # expecting only one <entry> element
  132. entry = root.find('entry')
  133. info['revision'] = entry.get('revision')
  134. info['url'] = entry.find('url').text
  135. relurl = entry.find('relative-url')
  136. # element which is not found is None
  137. # empty element would be bool(el) == False
  138. if relurl is not None:
  139. relurl = relurl.text
  140. # relative path has ^ at the beginning in SVN version 1.8.8
  141. if relurl.startswith('^'):
  142. relurl = relurl[1:]
  143. else:
  144. # SVN version 1.8.8 supports relative-url but older do not
  145. # so, get relative part from absolute URL
  146. const_url_part = 'https://svn.osgeo.org/grass/'
  147. relurl = info['url'][len(const_url_part):]
  148. info['relative-url'] = relurl
  149. return info
  150. # TODO: add this to svnversion function
  151. except OSError as e:
  152. import errno
  153. # ignore No such file or directory
  154. if e.errno != errno.ENOENT:
  155. raise
  156. return None
  157. def years_ago(date, years):
  158. # dateutil relative delte would be better but this is more portable
  159. return date - datetime.timedelta(weeks=years * 52)
  160. # TODO: these functions should be called only if we know that svn is installed
  161. # this will simplify the functions, caller must handle it anyway
  162. def get_svn_path_authors(path, from_date=None):
  163. """
  164. :returns: a set of authors
  165. """
  166. if from_date is None:
  167. # this is the SVN default for local copies
  168. revision_range = 'BASE:1'
  169. else:
  170. revision_range = 'BASE:{%s}' % from_date
  171. try:
  172. # TODO: allow also usage of --limit
  173. p = subprocess.Popen(['svn', 'log', '--xml',
  174. '--revision', revision_range, path],
  175. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  176. stdout, stderr = p.communicate()
  177. rc = p.poll()
  178. if not rc:
  179. root = et.fromstring(stdout)
  180. # TODO: get also date if this make sense
  181. # expecting only one <entry> element
  182. author_nodes = root.iterfind('*/author')
  183. authors = [n.text for n in author_nodes]
  184. return set(authors)
  185. except OSError as e:
  186. import errno
  187. # ignore No such file or directory
  188. if e.errno != errno.ENOENT:
  189. raise
  190. return None
  191. def get_html_test_authors_table(directory, tests_authors):
  192. # SVN gives us authors of code together with authors of tests
  193. # so test code authors list also contains authors of tests only
  194. # TODO: don't do this for the top level directories?
  195. tests_authors = set(tests_authors)
  196. from_date = years_ago(datetime.date.today(), years=1)
  197. tested_dir_authors = get_svn_path_authors(directory, from_date)
  198. not_testing_authors = tested_dir_authors - tests_authors
  199. if not not_testing_authors:
  200. not_testing_authors = ['all recent authors contributed tests']
  201. test_authors = (
  202. '<h3>Code and test authors</h3>'
  203. '<p style="font-size: 60%"><em>'
  204. 'Note that determination of authors is approximate and only'
  205. ' recent code authors are considered.'
  206. '</em></p>'
  207. '<table><tbody>'
  208. '<tr><td>Test authors:</td><td>{file_authors}</td></tr>'
  209. '<tr><td>Authors of tested code:</td><td>{code_authors}</td></tr>'
  210. '<tr><td>Authors owing tests:</td><td>{not_testing}</td></tr>'
  211. '</tbody></table>'
  212. .format(
  213. file_authors=', '.join(sorted(tests_authors)),
  214. code_authors=', '.join(sorted(tested_dir_authors)),
  215. not_testing=', '.join(sorted(not_testing_authors))
  216. ))
  217. return test_authors
  218. class GrassTestFilesMultiReporter(object):
  219. def __init__(self, reporters, forgiving=False):
  220. self.reporters = reporters
  221. self.forgiving = forgiving
  222. def start(self, results_dir):
  223. # TODO: no directory cleaning (self.clean_before)? now cleaned by caller
  224. # TODO: perhaps only those whoe need it should do it (even multiple times)
  225. # and there is also the delet problem
  226. ensure_dir(os.path.abspath(results_dir))
  227. for reporter in self.reporters:
  228. try:
  229. reporter.start(results_dir)
  230. except AttributeError:
  231. if self.forgiving:
  232. pass
  233. else:
  234. raise
  235. def finish(self):
  236. for reporter in self.reporters:
  237. try:
  238. reporter.finish()
  239. except AttributeError:
  240. if self.forgiving:
  241. pass
  242. else:
  243. raise
  244. def start_file_test(self, module):
  245. for reporter in self.reporters:
  246. try:
  247. reporter.start_file_test(module)
  248. except AttributeError:
  249. if self.forgiving:
  250. pass
  251. else:
  252. raise
  253. def end_file_test(self, **kwargs):
  254. for reporter in self.reporters:
  255. try:
  256. reporter.end_file_test(**kwargs)
  257. except AttributeError:
  258. if self.forgiving:
  259. pass
  260. else:
  261. raise
  262. class GrassTestFilesCountingReporter(object):
  263. def __init__(self):
  264. self.test_files = None
  265. self.files_fail = None
  266. self.files_pass = None
  267. self.file_pass_per = None
  268. self.file_fail_per = None
  269. self.main_start_time = None
  270. self.main_end_time = None
  271. self.main_time = None
  272. self.file_start_time = None
  273. self.file_end_time = None
  274. self.file_time = None
  275. self._start_file_test_called = False
  276. def start(self, results_dir):
  277. self.test_files = 0
  278. self.files_fail = 0
  279. self.files_pass = 0
  280. # this might be moved to some report start method
  281. self.main_start_time = datetime.datetime.now()
  282. def finish(self):
  283. self.main_end_time = datetime.datetime.now()
  284. self.main_time = self.main_end_time - self.main_start_time
  285. assert self.test_files == self.files_fail + self.files_pass
  286. self.file_pass_per = 100 * float(self.files_pass) / self.test_files
  287. self.file_fail_per = 100 * float(self.files_fail) / self.test_files
  288. def start_file_test(self, module):
  289. self.file_start_time = datetime.datetime.now()
  290. self._start_file_test_called = True
  291. self.test_files += 1
  292. def end_file_test(self, returncode, **kwargs):
  293. assert self._start_file_test_called
  294. self.file_end_time = datetime.datetime.now()
  295. self.file_time = self.file_end_time - self.file_start_time
  296. if returncode:
  297. self.files_fail += 1
  298. else:
  299. self.files_pass += 1
  300. self._start_file_test_called = False
  301. def percent_to_html(percent):
  302. if percent > 100 or percent < 0:
  303. return "? {:.2f}% ?".format(percent)
  304. elif percent < 40:
  305. color = 'red'
  306. elif percent < 70:
  307. color = 'orange'
  308. else:
  309. color = 'green'
  310. return '<span style="color: {color}">{percent:.0f}%</span>'.format(
  311. percent=percent, color=color)
  312. def wrap_stdstream_to_html(infile, outfile, module, stream):
  313. before = '<html><body><h1>%s</h1><pre>' % (module.name + ' ' + stream)
  314. after = '</pre></body></html>'
  315. html = open(outfile, 'w')
  316. html.write(before)
  317. with open(infile) as text:
  318. for line in text:
  319. html.write(color_error_line(html_escape(line)))
  320. html.write(after)
  321. html.close()
  322. def html_file_preview(filename):
  323. before = '<pre>'
  324. after = '</pre>'
  325. if not os.path.isfile(filename):
  326. return '<p style="color: red>File %s does not exist<p>' % filename
  327. size = os.path.getsize(filename)
  328. if not size:
  329. return '<p style="color: red>File %s is empty<p>' % filename
  330. max_size = 10000
  331. html = StringIO.StringIO()
  332. html.write(before)
  333. if size < max_size:
  334. with open(filename) as text:
  335. for line in text:
  336. html.write(color_error_line(html_escape(line)))
  337. elif size < 10 * max_size:
  338. def tail(filename, n):
  339. return collections.deque(open(filename), n)
  340. html.write('... (lines omitted)\n')
  341. for line in tail(filename, 50):
  342. html.write(color_error_line(html_escape(line)))
  343. else:
  344. return '<p style="color: red>File %s is too large to show<p>' % filename
  345. html.write(after)
  346. return html.getvalue()
  347. def returncode_to_html_text(returncode):
  348. if returncode:
  349. return '<span style="color: red">FAILED</span>'
  350. else:
  351. # alternatives: SUCCEEDED, passed, OK
  352. return '<span style="color: green">succeeded</span>'
  353. # not used
  354. def returncode_to_html_sentence(returncode):
  355. if returncode:
  356. return ('<span style="color: red">&#x274c;</span>'
  357. ' Test failed (return code %d)' % (returncode))
  358. else:
  359. return ('<span style="color: green">&#x2713;</span>'
  360. ' Test succeeded (return code %d)' % (returncode))
  361. def returncode_to_success_html_par(returncode):
  362. if returncode:
  363. return ('<p> <span style="color: red">&#x274c;</span>'
  364. ' Test failed</p>')
  365. else:
  366. return ('<p> <span style="color: green">&#x2713;</span>'
  367. ' Test succeeded</p>')
  368. def success_to_html_text(total, successes):
  369. if successes < total:
  370. return '<span style="color: red">FAILED</span>'
  371. elif successes == total:
  372. # alternatives: SUCCEEDED, passed, OK
  373. return '<span style="color: green">succeeded</span>'
  374. else:
  375. return ('<span style="color: red; font-size: 60%">'
  376. '? more successes than total ?</span>')
  377. UNKNOWN_NUMBER_HTML = '<span style="font-size: 60%">unknown</span>'
  378. def success_to_html_percent(total, successes):
  379. if total:
  380. pass_per = 100 * (float(successes) / total)
  381. pass_per = percent_to_html(pass_per)
  382. else:
  383. pass_per = UNKNOWN_NUMBER_HTML
  384. return pass_per
  385. class GrassTestFilesHtmlReporter(GrassTestFilesCountingReporter):
  386. unknown_number = UNKNOWN_NUMBER_HTML
  387. def __init__(self, file_anonymizer):
  388. super(GrassTestFilesHtmlReporter, self).__init__()
  389. self.main_index = None
  390. self._file_anonymizer = file_anonymizer
  391. def start(self, results_dir):
  392. super(GrassTestFilesHtmlReporter, self).start(results_dir)
  393. # having all variables public although not really part of API
  394. self.main_index = open(os.path.join(results_dir, 'index.html'), 'w')
  395. # TODO: this can be moved to the counter class
  396. self.failures = 0
  397. self.errors = 0
  398. self.skipped = 0
  399. self.successes = 0
  400. self.expected_failures = 0
  401. self.unexpected_success = 0
  402. self.total = 0
  403. svn_info = get_svn_info()
  404. if not svn_info:
  405. svn_text = ('<span style="font-size: 60%">'
  406. 'SVN revision cannot be be obtained'
  407. '</span>')
  408. else:
  409. url = get_source_url(path=svn_info['relative-url'],
  410. revision=svn_info['revision'])
  411. svn_text = ('SVN revision'
  412. ' <a href="{url}">'
  413. '{rev}</a>'
  414. ).format(url=url, rev=svn_info['revision'])
  415. self.main_index.write('<html><body>'
  416. '<h1>Test results</h1>'
  417. '{time:%Y-%m-%d %H:%M:%S}'
  418. ' ({svn})'
  419. '<table>'
  420. '<thead><tr>'
  421. '<th>Tested directory</th>'
  422. '<th>Test file</th>'
  423. '<th>Status</th>'
  424. '<th>Tests</th><th>Successful</td>'
  425. '<th>Failed</th><th>Percent successful</th>'
  426. '</tr></thead><tbody>'.format(
  427. time=self.main_start_time,
  428. svn=svn_text))
  429. def finish(self):
  430. super(GrassTestFilesHtmlReporter, self).finish()
  431. if self.total:
  432. pass_per = 100 * (float(self.successes) / self.total)
  433. pass_per = percent_to_html(pass_per)
  434. else:
  435. pass_per = self.unknown_number
  436. tfoot = ('<tfoot>'
  437. '<tr>'
  438. '<td>Summary</td>'
  439. '<td>{nfiles} test files</td>'
  440. '<td>{nsper}</td>'
  441. '<td>{total}</td><td>{st}</td><td>{ft}</td><td>{pt}</td>'
  442. '</tr>'
  443. '</tfoot>'.format(
  444. nfiles=self.test_files,
  445. nsper=percent_to_html(self.file_pass_per),
  446. st=self.successes, ft=self.failures + self.errors,
  447. total=self.total, pt=pass_per
  448. ))
  449. summary_sentence = ('Executed {nfiles} test files in {time:}.'
  450. ' From them'
  451. ' {nsfiles} files ({nsper:.0f}%) were successful'
  452. ' and {nffiles} files ({nfper:.0f}%) failed.'
  453. .format(
  454. nfiles=self.test_files,
  455. time=self.main_time,
  456. nsfiles=self.files_pass,
  457. nffiles=self.files_fail,
  458. nsper=self.file_pass_per,
  459. nfper=self.file_fail_per))
  460. self.main_index.write('<tbody>{tfoot}</table>'
  461. '<p>{summary}</p>'
  462. '</body></html>'
  463. .format(
  464. tfoot=tfoot,
  465. summary=summary_sentence))
  466. self.main_index.close()
  467. def start_file_test(self, module):
  468. super(GrassTestFilesHtmlReporter, self).start_file_test(module)
  469. self.main_index.flush() # to get previous lines to the report
  470. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  471. test_summary):
  472. super(GrassTestFilesHtmlReporter, self).end_file_test(
  473. module=module, cwd=cwd, returncode=returncode,
  474. stdout=stdout, stderr=stderr)
  475. # TODO: considering others accoring to total, OK?
  476. total = test_summary.get('total', None)
  477. failures = test_summary.get('failures', 0)
  478. errors = test_summary.get('errors', 0)
  479. # Python unittest TestResult class is reporting success for no
  480. # errors or failures, so skipped, expected failures and unexpected
  481. # success are ignored
  482. # but successful tests are only total - the others
  483. # TODO: add success counter to GrassTestResult base class
  484. skipped = test_summary.get('skipped', 0)
  485. expected_failures = test_summary.get('expected_failures', 0)
  486. unexpected_successes = test_summary.get('unexpected_successes', 0)
  487. successes = test_summary.get('successes', 0)
  488. self.failures += failures
  489. self.errors += errors
  490. self.skipped += skipped
  491. self.expected_failures += expected_failures
  492. self.unexpected_success += unexpected_successes
  493. # TODO: should we test for zero?
  494. if total is not None:
  495. # success are only the clear ones
  496. # percentage is influenced by all
  497. # but putting only failures to table
  498. self.successes += successes
  499. self.total += total
  500. pass_per = 100 * (float(successes) / total)
  501. pass_per = percent_to_html(pass_per)
  502. else:
  503. total = successes = pass_per = self.unknown_number
  504. bad_ones = failures + errors
  505. self.main_index.write(
  506. '<tr><td>{d}</td>'
  507. '<td><a href="{d}/{m}/index.html">{m}</a></td>'
  508. '<td>{status}</td>'
  509. '<td>{ntests}</td><td>{stests}</td>'
  510. '<td>{ftests}</td><td>{ptests}</td>'
  511. '<tr>'.format(
  512. d=module.tested_dir, m=module.name,
  513. status=returncode_to_html_text(returncode),
  514. stests=successes, ftests=bad_ones, ntests=total,
  515. ptests=pass_per))
  516. wrap_stdstream_to_html(infile=stdout,
  517. outfile=os.path.join(cwd, 'stdout.html'),
  518. module=module, stream='stdout')
  519. wrap_stdstream_to_html(infile=stderr,
  520. outfile=os.path.join(cwd, 'stderr.html'),
  521. module=module, stream='stderr')
  522. file_index_path = os.path.join(cwd, 'index.html')
  523. file_index = open(file_index_path, 'w')
  524. file_index.write(
  525. '<html><body>'
  526. '<h1>{m.name}</h1>'
  527. '<h2>{m.tested_dir} &ndash; {m.name}</h2>'
  528. '{status}'
  529. .format(
  530. m=module,
  531. status=returncode_to_success_html_par(returncode),
  532. ))
  533. # TODO: include optionaly link to test suite
  534. summary_section = (
  535. '<table><tbody>'
  536. '<tr><td>Test file</td><td>{m}</td></tr>'
  537. '<tr><td>Testsuite</td><td>{d}</td></tr>'
  538. '<tr><td>Status</td><td>{status}</td></tr>'
  539. '<tr><td>Return code</td><td>{rc}</td></tr>'
  540. '<tr><td>Number of tests</td><td>{ntests}</td></tr>'
  541. '<tr><td>Successful tests</td><td>{stests}</td></tr>'
  542. '<tr><td>Failed tests</td><td>{ftests}</td></tr>'
  543. '<tr><td>Percent successful</td><td>{ptests}</td></tr>'
  544. '<tr><td>Test duration</td><td>{dur}</td></tr>'
  545. .format(
  546. d=module.tested_dir, m=module.name,
  547. status=returncode_to_html_text(returncode),
  548. stests=successes, ftests=bad_ones, ntests=total,
  549. ptests=pass_per, rc=returncode,
  550. dur=self.file_time))
  551. file_index.write(summary_section)
  552. modules = test_summary.get('tested_modules', None)
  553. if modules:
  554. # TODO: replace by better handling of potential lists when parsing
  555. # TODO: create link to module if running in grass or in addons
  556. # alternatively a link to module test summary
  557. if type(modules) is not list:
  558. modules = [modules]
  559. file_index.write(
  560. '<tr><td>Tested modules</td><td>{}</td></tr>'.format(
  561. ', '.join(modules)))
  562. file_index.write('<tbody><table>')
  563. # here we would have also links to coverage, profiling, ...
  564. #'<li><a href="testcodecoverage/index.html">code coverage</a></li>'
  565. files_section = (
  566. '<h3>Supplementary files</h3>'
  567. '<ul>'
  568. '<li><a href="stdout.html">standard output (stdout)</a></li>'
  569. '<li><a href="stderr.html">standard error output (stderr)</a></li>'
  570. )
  571. file_index.write(files_section)
  572. supplementary_files = test_summary.get('supplementary_files', None)
  573. if supplementary_files:
  574. # this is something we might want to do once for all and not
  575. # risk that it will be done twice or rely that somebody else
  576. # will do it for use
  577. # the solution is perhaps do the multi reporter more grass-specific
  578. # and do all common things, so that other can rely on it and
  579. # moreover something can be shared with other explicity
  580. # using constructors as seems advantageous for counting
  581. self._file_anonymizer.anonymize(supplementary_files)
  582. for f in supplementary_files:
  583. file_index.write('<li><a href="{f}">{f}</a></li>'.format(f=f))
  584. file_index.write('</ul>')
  585. if returncode:
  586. file_index.write('<h3>Standard error output (stderr)</h3>')
  587. file_index.write(html_file_preview(stderr))
  588. file_index.write('</body></html>')
  589. file_index.close()
  590. if returncode:
  591. pass
  592. # TODO: here we don't have oportunity to write error file
  593. # to stream (stdout/stderr)
  594. # a stream can be added and if not none, we could write
  595. class GrassTestFilesTextReporter(GrassTestFilesCountingReporter):
  596. def __init__(self, stream):
  597. super(GrassTestFilesTextReporter, self).__init__()
  598. self._stream = stream
  599. def start(self, results_dir):
  600. super(GrassTestFilesTextReporter, self).start(results_dir)
  601. def finish(self):
  602. super(GrassTestFilesTextReporter, self).finish()
  603. summary_sentence = ('\nExecuted {nfiles} test files in {time:}.'
  604. '\nFrom them'
  605. ' {nsfiles} files ({nsper:.0f}%) were successful'
  606. ' and {nffiles} files ({nfper:.0f}%) failed.\n'
  607. .format(
  608. nfiles=self.test_files,
  609. time=self.main_time,
  610. nsfiles=self.files_pass,
  611. nffiles=self.files_fail,
  612. nsper=self.file_pass_per,
  613. nfper=self.file_fail_per))
  614. self._stream.write(summary_sentence)
  615. def start_file_test(self, module):
  616. super(GrassTestFilesTextReporter, self).start_file_test(module)
  617. self._stream.flush() # to get previous lines to the report
  618. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  619. test_summary):
  620. super(GrassTestFilesTextReporter, self).end_file_test(
  621. module=module, cwd=cwd, returncode=returncode,
  622. stdout=stdout, stderr=stderr)
  623. if returncode:
  624. self._stream.write(
  625. '{m} from {d} failed'
  626. .format(
  627. d=module.tested_dir,
  628. m=module.name))
  629. num_failed = test_summary.get('failures', 0)
  630. num_failed += test_summary.get('errors', 0)
  631. if num_failed:
  632. if num_failed > 1:
  633. text = ' ({f} tests failed)'
  634. else:
  635. text = ' ({f} test failed)'
  636. self._stream.write(text.format(f=num_failed))
  637. self._stream.write('\n')
  638. # TODO: here we lost the possibility to include also file name
  639. # of the appropriate report
  640. # TODO: there is a quite a lot duplication between this class and html reporter
  641. # TODO: document: do not use it for two reports, it accumulates the results
  642. # TODO: add also keyvalue summary generation?
  643. # wouldn't this conflict with collecting data from report afterwards?
  644. class TestsuiteDirReporter(object):
  645. def __init__(self, main_page_name, testsuite_page_name='index.html'):
  646. self.main_page_name = main_page_name
  647. self.testsuite_page_name = testsuite_page_name
  648. # TODO: this might be even a object which could add and validate
  649. self.failures = 0
  650. self.errors = 0
  651. self.skipped = 0
  652. self.successes = 0
  653. self.expected_failures = 0
  654. self.unexpected_successes = 0
  655. self.total = 0
  656. self.testsuites = 0
  657. self.testsuites_successes = 0
  658. self.files = 0
  659. self.files_successes = 0
  660. def report_for_dir(self, root, directory, test_files):
  661. # TODO: create object from this, so that it can be passed from
  662. # one function to another
  663. # TODO: put the inside of for loop to another fucntion
  664. dir_failures = 0
  665. dir_errors = 0
  666. dir_skipped = 0
  667. dir_successes = 0
  668. dir_expected_failures = 0
  669. dir_unexpected_success = 0
  670. dir_total = 0
  671. test_files_authors = []
  672. file_total = 0
  673. file_successes = 0
  674. page_name = os.path.join(root, directory, self.testsuite_page_name)
  675. page = open(page_name, 'w')
  676. head = (
  677. '<html><body>'
  678. '<h1>{name} testsuite results</h1>'
  679. .format(name=directory))
  680. tests_table_head = (
  681. '<h3>Test files results</h3>'
  682. '<table>'
  683. '<thead><tr>'
  684. '<th>Test file</th><th>Status</th>'
  685. '<th>Tests</th><th>Successful</td>'
  686. '<th>Failed</th><th>Percent successful</th>'
  687. '</tr></thead><tbody>'
  688. )
  689. page.write(head)
  690. page.write(tests_table_head)
  691. for test_file_name in test_files:
  692. # TODO: put keyvalue fine name to constant
  693. summary_filename = os.path.join(root, directory, test_file_name,
  694. 'test_keyvalue_result.txt')
  695. #if os.path.exists(summary_filename):
  696. with open(summary_filename, 'r') as keyval_file:
  697. summary = text_to_keyvalue(keyval_file.read(), sep='=')
  698. #else:
  699. # TODO: write else here
  700. # summary = None
  701. if 'total' not in summary:
  702. bad_ones = successes = UNKNOWN_NUMBER_HTML
  703. total = None
  704. else:
  705. bad_ones = summary['failures'] + summary['errors']
  706. successes = summary['successes']
  707. total = summary['total']
  708. self.failures += summary['failures']
  709. self.errors += summary['errors']
  710. self.skipped += summary['skipped']
  711. self.successes += summary['successes']
  712. self.expected_failures += summary['expected_failures']
  713. self.unexpected_successes += summary['unexpected_successes']
  714. self.total += summary['total']
  715. dir_failures += summary['failures']
  716. dir_errors += summary['failures']
  717. dir_skipped += summary['skipped']
  718. dir_successes += summary['successes']
  719. dir_expected_failures += summary['expected_failures']
  720. dir_unexpected_success += summary['unexpected_successes']
  721. dir_total += summary['total']
  722. # TODO: keyvalue method should have types for keys function
  723. # perhaps just the current post processing function is enough
  724. test_file_authors = summary['test_file_authors']
  725. if type(test_file_authors) is not list:
  726. test_file_authors = [test_file_authors]
  727. test_files_authors += test_file_authors
  728. file_total += 1
  729. file_successes += 0 if summary['returncode'] else 1
  730. pass_per = success_to_html_percent(total=total,
  731. successes=successes)
  732. row = (
  733. '<tr>'
  734. '<td><a href="{f}/index.html">{f}</a></td>'
  735. '<td>{status}</td>'
  736. '<td>{ntests}</td><td>{stests}</td>'
  737. '<td>{ftests}</td><td>{ptests}</td>'
  738. '<tr>'
  739. .format(
  740. f=test_file_name,
  741. status=returncode_to_html_text(summary['returncode']),
  742. stests=successes, ftests=bad_ones, ntests=total,
  743. ptests=pass_per))
  744. page.write(row)
  745. self.testsuites += 1
  746. self.testsuites_successes += 1 if file_successes == file_total else 0
  747. self.files += file_total
  748. self.files_successes += file_successes
  749. dir_pass_per = success_to_html_percent(total=dir_total,
  750. successes=dir_successes)
  751. file_pass_per = success_to_html_percent(total=file_total,
  752. successes=file_successes)
  753. tests_table_foot = (
  754. '</tbody><tfoot><tr>'
  755. '<td>Summary</td>'
  756. '<td>{status}</td>'
  757. '<td>{ntests}</td><td>{stests}</td>'
  758. '<td>{ftests}</td><td>{ptests}</td>'
  759. '</tr></tfoot></table>'
  760. .format(
  761. status=file_pass_per,
  762. stests=dir_successes, ftests=dir_failures + dir_errors,
  763. ntests=dir_total, ptests=dir_pass_per))
  764. page.write(tests_table_foot)
  765. test_authors = get_html_test_authors_table(
  766. directory=directory, tests_authors=test_files_authors)
  767. page.write(test_authors)
  768. page.write('</body></html>')
  769. status = success_to_html_text(total=file_total, successes=file_successes)
  770. row = (
  771. '<tr>'
  772. '<td><a href="{d}/{page}">{d}</a></td><td>{status}</td>'
  773. '<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>'
  774. '<td>{ntests}</td><td>{stests}</td>'
  775. '<td>{ftests}</td><td>{ptests}</td>'
  776. '<tr>'
  777. .format(
  778. d=directory, page=self.testsuite_page_name, status=status,
  779. nfiles=file_total, sfiles=file_successes, pfiles=file_pass_per,
  780. stests=dir_successes, ftests=dir_failures + dir_errors,
  781. ntests=dir_total, ptests=dir_pass_per))
  782. return row
  783. def report_for_dirs(self, root, directories):
  784. # TODO: this will need chanages accoring to potential chnages in absolute/relative paths
  785. page_name = os.path.join(root, self.main_page_name)
  786. page = open(page_name, 'w')
  787. head = (
  788. '<html><body>'
  789. '<h1>Testsuites results</h1>'
  790. )
  791. tests_table_head = (
  792. '<table>'
  793. '<thead><tr>'
  794. '<th>Testsuite</th>'
  795. '<th>Status</th>'
  796. '<th>Test files</th><th>Successful</td>'
  797. '<th>Percent successful</th>'
  798. '<th>Tests</th><th>Successful</td>'
  799. '<th>Failed</th><th>Percent successful</th>'
  800. '</tr></thead><tbody>'
  801. )
  802. page.write(head)
  803. page.write(tests_table_head)
  804. for directory, test_files in directories.iteritems():
  805. row = self.report_for_dir(root=root, directory=directory,
  806. test_files=test_files)
  807. page.write(row)
  808. pass_per = success_to_html_percent(total=self.total,
  809. successes=self.successes)
  810. file_pass_per = success_to_html_percent(total=self.files,
  811. successes=self.files_successes)
  812. testsuites_pass_per = success_to_html_percent(
  813. total=self.testsuites, successes=self.testsuites_successes)
  814. tests_table_foot = (
  815. '<tfoot>'
  816. '<tr>'
  817. '<td>Summary</td><td>{status}</td>'
  818. '<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>'
  819. '<td>{ntests}</td><td>{stests}</td>'
  820. '<td>{ftests}</td><td>{ptests}</td>'
  821. '</tr>'
  822. '</tfoot>'
  823. .format(
  824. status=testsuites_pass_per, nfiles=self.files,
  825. sfiles=self.files_successes, pfiles=file_pass_per,
  826. stests=self.successes, ftests=self.failures + self.errors,
  827. ntests=self.total, ptests=pass_per))
  828. page.write(tests_table_foot)
  829. page.write('</body></html>')