package com.simpligility.maven.plugins.android; import com.android.ddmlib.AdbCommandRejectedException; import com.android.ddmlib.IDevice; import com.android.ddmlib.IShellOutputReceiver; import com.android.ddmlib.ShellCommandUnresponsiveException; import com.android.ddmlib.TimeoutException; import com.android.ddmlib.testrunner.ITestRunListener; import com.android.ddmlib.testrunner.TestIdentifier; import com.simpligility.maven.plugins.android.common.DeviceHelper; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.maven.plugin.logging.Log; import org.apache.maven.surefire.ObjectFactory; import org.apache.maven.surefire.Testsuite; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import java.io.File; import java.io.IOException; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.Map; /** * AndroidTestRunListener produces a nice output for the log for the test run as well as an xml file compatible with * the junit xml report file format understood by many tools. * * It will do so for each device/emulator the tests run on. */ public class AndroidTestRunListener implements ITestRunListener { private static final String SCREENSHOT_SUFFIX = "_screenshot.png"; /** * the indent used in the log to group items that belong together visually * */ private static final String INDENT = " "; /** * time format for the output of milliseconds in seconds in the xml file * */ private final NumberFormat timeFormatter = new DecimalFormat( "#0.000" ); private int testCount = 0; private int testRunCount = 0; private int testIgnoredCount = 0; private int testFailureCount = 0; private int testErrorCount = 0; private String testRunFailureCause = null; /** * the emulator or device we are running the tests on * */ private final IDevice device; private final Log log; private final Boolean createReport; private final Boolean takeScreenshotOnFailure; private final String screenshotsPathOnDevice; private final String reportSuffix; private final File targetDirectory; private final String deviceLogLinePrefix; private final ObjectFactory objectFactory = new ObjectFactory(); private Testsuite report; private Testsuite.Testcase currentTestCase; /** * start time of current test case in millis, reset with each test start */ private long currentTestCaseStartTime; // we track if we have problems and then report upstream private boolean threwException = false; private final StringBuilder exceptionMessages = new StringBuilder(); /** * Create a new test run listener. * * @param device * the device on which test is executed. */ public AndroidTestRunListener( IDevice device, Log log, Boolean createReport, Boolean takeScreenshotOnFailure, String screenshotsPathOnDevice, String reportSuffix, File targetDirectory ) { this.device = device; this.deviceLogLinePrefix = DeviceHelper.getDeviceLogLinePrefix( device ); this.log = log; this.createReport = createReport; this.takeScreenshotOnFailure = takeScreenshotOnFailure; this.screenshotsPathOnDevice = screenshotsPathOnDevice; this.reportSuffix = reportSuffix; this.targetDirectory = targetDirectory; } public Log getLog() { return this.log; } @Override public void testRunStarted( String runName, int tCount ) { if ( takeScreenshotOnFailure ) { executeOnAdbShell( "rm -f " + screenshotsPathOnDevice + "/*screenshot.png" ); executeOnAdbShell( "mkdir " + screenshotsPathOnDevice ); } this.testCount = tCount; getLog().info( deviceLogLinePrefix + INDENT + "Run started: " + runName + ", " + testCount + " tests:" ); if ( createReport ) { report = new Testsuite(); report.setName( runName ); final Testsuite.Properties props = new Testsuite.Properties(); report.getProperties().add( props ); for ( Map.Entry< Object, Object > systemProperty : System.getProperties().entrySet() ) { final Testsuite.Properties.Property property = new Testsuite.Properties.Property(); property.setName( systemProperty.getKey().toString() ); property.setValue( systemProperty.getValue().toString() ); props.getProperty().add( property ); } Map< String, String > deviceProperties = device.getProperties(); for ( Map.Entry< String, String > deviceProperty : deviceProperties.entrySet() ) { final Testsuite.Properties.Property property = new Testsuite.Properties.Property(); property.setName( deviceProperty.getKey() ); property.setValue( deviceProperty.getValue() ); props.getProperty().add( property ); } } } @Override public void testIgnored( TestIdentifier testIdentifier ) { ++testIgnoredCount; getLog().info( deviceLogLinePrefix + INDENT + INDENT + testIdentifier.toString() ); } @Override public void testStarted( TestIdentifier testIdentifier ) { testRunCount++; getLog().info( deviceLogLinePrefix + String.format( "%1$s%1$sStart [%2$d/%3$d]: %4$s", INDENT, testRunCount, testCount, testIdentifier.toString() ) ); if ( createReport ) { // reset start time for each test run currentTestCaseStartTime = System.currentTimeMillis(); currentTestCase = new Testsuite.Testcase(); currentTestCase.setClassname( testIdentifier.getClassName() ); currentTestCase.setName( testIdentifier.getTestName() ); } } @Override public void testFailed( TestIdentifier testIdentifier, String trace ) { if ( takeScreenshotOnFailure ) { String suffix = "_error"; String filepath = testIdentifier.getTestName() + suffix + SCREENSHOT_SUFFIX; executeOnAdbShell( "screencap -p " + screenshotsPathOnDevice + "/" + filepath ); getLog().info( deviceLogLinePrefix + INDENT + INDENT + filepath + " saved." ); } ++testErrorCount; getLog().info( deviceLogLinePrefix + INDENT + INDENT + testIdentifier.toString() ); getLog().info( deviceLogLinePrefix + INDENT + INDENT + trace ); if ( createReport ) { final Testsuite.Testcase.Error error = new Testsuite.Testcase.Error(); error.setValue( trace ); error.setMessage( parseForMessage( trace ) ); error.setType( parseForException( trace ) ); currentTestCase.setError( objectFactory.createTestsuiteTestcaseError( error ) ); } } @Override public void testAssumptionFailure( TestIdentifier testIdentifier, String trace ) { if ( takeScreenshotOnFailure ) { String suffix = "_failure"; String filepath = testIdentifier.getTestName() + suffix + SCREENSHOT_SUFFIX; executeOnAdbShell( "screencap -p " + screenshotsPathOnDevice + "/" + filepath ); getLog().info( deviceLogLinePrefix + INDENT + INDENT + filepath + " saved." ); } ++testFailureCount; getLog().info( deviceLogLinePrefix + INDENT + INDENT + testIdentifier.toString() ); getLog().info( deviceLogLinePrefix + INDENT + INDENT + trace ); if ( createReport ) { final Testsuite.Testcase.Failure failure = new Testsuite.Testcase.Failure(); failure.setValue( trace ); failure.setMessage( parseForMessage( trace ) ); failure.setType( parseForException( trace ) ); currentTestCase.getFailure().add( failure ); } } private void executeOnAdbShell( String command ) { try { device.executeShellCommand( command, new IShellOutputReceiver() { @Override public boolean isCancelled() { return false; } @Override public void flush() { } @Override public void addOutput( byte[] data, int offset, int length ) { } } ); } catch ( TimeoutException | AdbCommandRejectedException | IOException | ShellCommandUnresponsiveException e ) { getLog().error( e ); } } @Override public void testEnded( TestIdentifier testIdentifier, Map< String, String > testMetrics ) { getLog().info( deviceLogLinePrefix + String.format( "%1$s%1$sEnd [%2$d/%3$d]: %4$s", INDENT, testRunCount, testCount, testIdentifier.toString() ) ); logMetrics( testMetrics ); if ( createReport ) { double seconds = ( System.currentTimeMillis() - currentTestCaseStartTime ) / 1000.0; currentTestCase.setTime( timeFormatter.format( seconds ) ); report.getTestcase().add( currentTestCase ); } } @Override public void testRunEnded( long elapsedTime, Map< String, String > runMetrics ) { getLog().info( deviceLogLinePrefix + INDENT + "Run ended: " + elapsedTime + " ms" ); if ( hasFailuresOrErrors() ) { getLog().error( deviceLogLinePrefix + INDENT + "FAILURES!!!" ); } getLog().info( INDENT + "Tests run: " + testRunCount + ( testRunCount < testCount ? " (of " + testCount + ")" : "" ) + ", Failures: " + testFailureCount + ", Errors: " + testErrorCount + ", Ignored: " + testIgnoredCount ); if ( createReport ) { report.setTests( Integer.toString( testCount ) ); report.setFailures( Integer.toString( testFailureCount ) ); report.setErrors( Integer.toString( testErrorCount ) ); report.setSkipped( Integer.toString( testIgnoredCount ) ); report.setTime( timeFormatter.format( elapsedTime / 1000.0 ) ); } logMetrics( runMetrics ); if ( createReport ) { writeJunitReportToFile(); } } @Override public void testRunFailed( String errorMessage ) { testRunFailureCause = errorMessage; getLog().info( deviceLogLinePrefix + INDENT + "Run failed: " + errorMessage ); } @Override public void testRunStopped( long elapsedTime ) { getLog().info( deviceLogLinePrefix + INDENT + "Run stopped:" + elapsedTime ); } /** * Parse a trace string for the message in it. Assumes that the message is located after ":" and before "\r\n". * * @param trace stack trace from android tests * @return message or empty string */ private String parseForMessage( String trace ) { if ( StringUtils.isNotBlank( trace ) ) { String newline = "\r\n"; // if there is message like // junit.junit.framework.AssertionFailedError ... there is no // message int messageEnd = trace.indexOf( newline ); boolean hasMessage = !trace.startsWith( "junit." ) && messageEnd > 0; if ( hasMessage ) { int messageStart = trace.indexOf( ":" ) + 2; if ( messageStart > messageEnd ) { messageEnd = trace.indexOf( newline + "at" ); // match start of stack trace "\r\nat org.junit....." if ( messageStart > messageEnd ) { // ':' wasn't found in message but in stack trace messageStart = 0; } } return trace.substring( messageStart, messageEnd ); } else { return StringUtils.EMPTY; } } else { return StringUtils.EMPTY; } } /** * Parse a trace string for the exception class. Assumes that it is the start of the trace and ends at the first * ":". * * @param trace stack trace from android tests * @return Exception class as string or empty string */ private String parseForException( String trace ) { if ( StringUtils.isNotBlank( trace ) ) { return trace.substring( 0, trace.indexOf( ":" ) ); } else { return StringUtils.EMPTY; } } /** * Write the junit report xml file. */ private void writeJunitReportToFile() { try { final String directory = String.valueOf( targetDirectory ) + "/surefire-reports"; FileUtils.forceMkdir( new File ( directory ) ); final StringBuilder b = new StringBuilder( directory ).append( "/TEST-" ) .append( DeviceHelper.getDescriptiveName( device ) ); if ( StringUtils.isNotBlank( reportSuffix ) ) { //Safety first b.append( reportSuffix.replace( "/", "" ).replace( "\\", "" ) ); } final File reportFile = new File( b.append( ".xml" ).toString() ); final JAXBContext jaxbContext = JAXBContext.newInstance( ObjectFactory.class ); final Marshaller marshaller = jaxbContext.createMarshaller(); marshaller.marshal( report, reportFile ); getLog().info( deviceLogLinePrefix + "Report file written to " + reportFile.getAbsolutePath() ); } catch ( IOException e ) { threwException = true; exceptionMessages.append( "Failed to write test report file" ); exceptionMessages.append( e.getMessage() ); } catch ( JAXBException e ) { threwException = true; exceptionMessages.append( "Failed to create jaxb context" ); exceptionMessages.append( e.getMessage() ); } } /** * Log all the metrics out in to key: value lines. * * @param metrics key-value pairs reported at the end of a test run */ private void logMetrics( Map< String, String > metrics ) { for ( Map.Entry< String, String > entry : metrics.entrySet() ) { getLog().info( deviceLogLinePrefix + INDENT + INDENT + entry.getKey() + ": " + entry.getValue() ); } } /** * @return if any failures or errors occurred in the test run. */ public boolean hasFailuresOrErrors() { return testErrorCount > 0 || testFailureCount > 0; } /** * @return if the test run itself failed - a failure in the test infrastructure, not a test failure. */ public boolean testRunFailed() { return testRunFailureCause != null; } /** * @return the cause of test failure if any. */ public String getTestRunFailureCause() { return testRunFailureCause; } /** * @return if any exception was thrown during the test run on the build system (not the Android device or * emulator) */ public boolean threwException() { return threwException; } /** * @return all exception messages thrown during test execution on the test run time (not the Android device or * emulator) */ public String getExceptionMessages() { return exceptionMessages.toString(); } }