"""
Writer that collects and reports on Java code coverage, using the JaCoCo library.
"""
__all__ = [
"JavaCoverageWriter",
]
import logging, sys, io, os, shlex, glob, time
from pysys.constants import *
from pysys.writer.api import *
from pysys.writer.testoutput import CollectTestOutputWriter
from pysys.utils.fileutils import mkdir, deletedir, toLongPathSafe, fromLongPathSafe, pathexists
import pysysjava
log = logging.getLogger('pysys.pysysjava.coverage')
def safeGlob(globPattern, expected='>=1', name='Glob pattern'):
if not globPattern:
result = []
else:
assert os.path.isabs(globPattern), globPattern
result = glob.glob(os.path.normpath(globPattern), recursive=True) # todo: add longpathsafe
if eval(str(len(result))+expected):
if expected.replace(' ','')=='==1': return result[0]
return result
raise FileNotFoundError('%s should return %s result(s) but got %d: "%s"'%(name, expected, len(result), globPattern))
[docs]class JavaCoverageWriter(CollectTestOutputWriter):
"""Writer that collects that coverage data in a single directory and writes coverage XML and HTML reports during
runner cleanup.
Requires the "JaCoCo" code coverage library.
To enable this, run with ``-XcodeCoverage`` (or ``-XjavaCoverage``) and configure the ``destDir`` plugin property
in pysysproject.xml (e.g. to ``__coverage_java.${outDirName}``).
When enabled, the `getCoverageJVMArgs` method can be used to generate the JVM arguments needed to collect coverage
data (for example, `pysysjava.javaplugin.JavaPlugin.startJava` calls this method). You must configure the
writer with the alias ``javaCoverageWriter`` so that the plugin knows to use it when starting Java processes.
Note that this writer uses ``output=file`` JaCoCo agent option which
dumps coverage information to a file when the Java process exits. This means background processes that are still
running when the PySys test finishes (and get killed by PySys) will not generate any coverage - so try to ensure
clean termination where possible. (An alternative approach would be to implement a Java TCP server using the JaCoCo
API which each Java process connects to, but this writer doesn't go that far).
If coverage is generated, the directory containing all coverage files is published as an artifact named
"coverageDestDir". Optionally an archive of this directory can be generated by setting the
``destArchive`` property (see `pysys.writer.testoutput.CollectTestOutputWriter`), and published as "JavaCoverageArchive".
The following properties can be set in the project configuration for this writer:
"""
# override CollectTestOutputWriter property values
destDir = u''
fileIncludesRegex = u'.*[.]javacoverage' # executed against the path relative to the test root dir e.g. (pattern1|pattern2)
outputPattern = u'@TESTID@_@FILENAME@.@UNIQUE@.@FILENAME_EXT@'
publishArtifactDirCategory = u'JavaCoverageDir'
publishArtifactArchiveCategory = u'JavaCoverageArchive'
jacocoDir = ''
"""
This mandatory writer property configures the directory containing the JaCoCo jar files ``org.jacoco.agent*.jar``
and ``org.jacoco.cli*.jar``.
"""
agentArgs = ''
"""
A comma-separated string of additional agent parameters to pass to the JaCoCo agent to control gathering of coverage data.
For example "includes=myorg.myserver.*,excludes=myorg.myserver.tests.*" can be used to include/exclude packages
from coverage data. See the JaCoCo documentation for more information about the Java Agent.
"""
classpath = ''
"""
The classpath containing the application classes which should be included in the code coverage report.
If this property is not set then no HTML or XML report will be generated.
Glob ``*`` expressions can be used. For full details of how this plugin handles the classpath string see
`pysysjava.javaplugin.JavaPlugin.toClasspathList()`.
"""
sourceDirs = ''
"""
A semi-colon separated list of directories containing the ``.java`` source files used to compile the classpath classes.
This is required for the HTML report to show the line-by-line source file coverage.
"""
reportArgs = ''
"""
A space-separated string of additional command line arguments to pass to the JaCoCo report command line.
For example "--encoding utf-8".
"""
def isEnabled(self, record=False, **kwargs):
enabled = (self.runner.getBoolProperty('javaCoverage', default=self.runner.getBoolProperty('codeCoverage')))
if not enabled:
self.__agentJar = None
return
if not self.destDir: raise Exception('The destDir JavaCoverageWriter property must be set')
if (not self.jacocoDir) or (not os.path.isdir(self.jacocoDir)): raise Exception('The jacocoDir JavaCoverageWriter property must be set and must exist: "%s"'%self.jacocoDir)
self.__agentJar = safeGlob(self.jacocoDir+'/*jacoco*agent*.jar', expected='==1', name='JaCoCo agent jar (from the jacocoDir)').replace('\\','/')
return True
[docs] def getCoverageJVMArgs(self, owner, stdouterr=None):
"""
Get the JVM arguments needed to add Java coverage to a new Java process, or empty if this coverage writer is
not currently enabled.
This is called by `pysysjava.javaplugin.JavaPlugin.startJava` (and any custom methods that start JVMs)
to add coverage collection.
:param pysys.basetest.BaseTest owner: The BaseTest/BaseRunner owner of the process that is to be started.
This is used to keep track of coverage names already allocated, and str(owner) is used as sessionId metadata.
:param str stdouterr: The name of the stdouterr for the process being measured (or None if not available), used
to contribute to the coverage filename.
"""
if self.__agentJar is None: return []
sessionid = str(owner)
# pick a unique name for the coverage file
destfile = 'jacoco'
if stdouterr and isinstance(stdouterr, str):
sessionid += '.'+os.path.basename(stdouterr)
destfile += '-'+os.path.basename(stdouterr)
destfile += '%s.javacoverage'
namesUsed = getattr(owner, '__JavaCoverageWriter.coverageFileNamesUsed', set())
setattr(owner, '__JavaCoverageWriter.coverageFileNamesUsed', namesUsed)
uniquer, n = '', 1
while destfile % uniquer in namesUsed: n, uniquer = n+1, '.%s'%n
agentArgs = self.agentArgs
if agentArgs: agentArgs = ','+agentArgs
if 'output=' not in agentArgs:
agentArgs = f',output=file,destfile={destfile % uniquer}{agentArgs}'
return [f'-javaagent:{self.__agentJar}=sessionid={sessionid}{agentArgs}']
def cleanup(self, **kwargs):
java = pysysjava.javaplugin.JavaPlugin()
java.setup(self.runner)
coverageDestDir = self.destDir
assert os.path.isabs(coverageDestDir) # The base class is responsible for absolutizing this config property
coverageDestDir = os.path.normpath(fromLongPathSafe(coverageDestDir))
if not pathexists(coverageDestDir):
log.info('No Java coverage files were generated.')
return
log.info('Preparing Java coverage report in: %s', coverageDestDir)
cliJar = safeGlob(self.jacocoDir+'/*jacoco*cli*.jar', expected='==1', name='JaCoCo CLI jar (from the jacocoDir)')
coveragefiles = [f for f in os.listdir(coverageDestDir) if f.endswith('.javacoverage')]
java.startJava(cliJar, ['merge']+coveragefiles+['--destfile', 'jacoco-merged-java-coverage.exec'], abortOnError=True,
workingDir=coverageDestDir, stdouterr=coverageDestDir+'/java-coverage-merge',
disableCoverage=True, onError=lambda process:
'Failed to merge Java code coverage data: %s'%self.runner.getExprFromFile(process.stderr, '.+', returnAll=True)[-1]
or self.runner.logFileContents(process.stderr, maxLines=0))
for f in coveragefiles: os.remove(toLongPathSafe(coverageDestDir+os.sep+f))
classpath = java.toClasspathList(self.classpath)
if not classpath:
log.info('No Java report will be generated as no classpath was specified')
else:
log.debug('Application classpath for the coverage report is: \n%s', '\n'.join(" cp #%-2d : %s%s"%(
i+1, pathelement, '' if os.path.exists(pathelement) else ' (does not exist!)') for i, pathelement in enumerate(classpath)))
sourceDirs = java.toClasspathList(self.sourceDirs) # not really a classpath, but or consistency, parse it the same way
args = []
for x in classpath: args.extend(['--classfiles', x])
for x in sourceDirs: args.extend(['--sourcefiles', x])
if sourceDirs:
(log.warn if any(not os.path.exists(p) for p in sourceDirs) else log.debug)('Java source directories for the coverage report are: \n%s', '\n'.join(" dir #%-2d : %s%s"%(
i+1, pathelement, '' if os.path.exists(pathelement) else ' (does not exist!)') for i, pathelement in enumerate(sourceDirs)))
else:
log.info('No source directories were provided so the coverage HTML report will not include line-by-line highlighted source files')
java.startJava(cliJar, ['report', 'jacoco-merged-java-coverage.exec', '--xml', 'java-coverage.xml', '--html', '.']
+java._splitShellArgs(self.reportArgs)+args,
abortOnError=True,
workingDir=coverageDestDir, stdouterr=coverageDestDir+'/java-coverage-report',
disableCoverage=True, onError=lambda process:
'Failed to create Java code coverage report: %s'%self.runner.getExprFromFile(process.stderr, '.+', returnAll=True)[-1]
or self.runner.logFileContents(process.stderr, maxLines=0))
# to avoid confusion, remove any zero byte out/err files from the above
for p in os.listdir(coverageDestDir):
p = os.path.join(coverageDestDir, p)
if p.endswith(('.out', '.err')) and os.path.getsize(p)==0:
os.remove(p)
try:
self.archiveAndPublish()
except PermissionError: # pragma: no cover - can occur transiently on Windows due to file system locking
time.sleep(5.0)
self.archiveAndPublish()