Add new test reporting option.

The new test reporting supports writing the test log with an accessible
index during test execution. Follow-up work will allow resuming from the
previous state of a test report.

Bug: 186607794
Change-Id: I22a11d9e86eb1bdcaf8189a2fa2ebcc251f711f9
diff --git a/build.gradle b/build.gradle
index aa1d75d..2180056 100644
--- a/build.gradle
+++ b/build.gradle
@@ -11,6 +11,12 @@
 import tasks.DownloadDependency
 import tasks.GetJarsFromConfiguration
 import utils.Utils
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.StandardOpenOption
 
 buildscript {
     repositories {
@@ -1848,43 +1854,47 @@
     outputs.dir r8LibTestPath
 }
 
+def shouldRetrace() {
+    return project.hasProperty('r8lib') || project.hasProperty('r8lib_no_deps')
+}
+
+def retrace(Throwable exception) {
+    def out = new StringBuffer()
+    def err = new StringBuffer()
+    def command = "python tools/retrace.py --quiet"
+    def header = "RETRACED STACKTRACE";
+    if (System.getenv('BUILDBOT_BUILDERNAME') != null
+            && !System.getenv('BUILDBOT_BUILDERNAME').endsWith("_release")) {
+        header += ": (${command} --commit_hash ${System.getenv('BUILDBOT_REVISION')})";
+    }
+    out.append("\n--------------------------------------\n")
+    out.append("${header}\n")
+    out.append("--------------------------------------\n")
+    Process process = command.execute()
+    def processIn = new PrintStream(process.getOut())
+    process.consumeProcessOutput(out, err)
+    exception.printStackTrace(processIn)
+    processIn.flush()
+    processIn.close()
+    def errorDuringRetracing = process.waitFor() != 0
+    if (errorDuringRetracing) {
+        out.append("ERROR DURING RETRACING\n")
+        out.append(err.toString())
+    }
+    if (project.hasProperty('print_obfuscated_stacktraces') || errorDuringRetracing) {
+        out.append("\n\n--------------------------------------\n")
+        out.append("OBFUSCATED STACKTRACE\n")
+        out.append("--------------------------------------\n")
+    }
+    return out.toString()
+}
+
 def printStackTrace(TestResult result) {
     filterStackTraces(result)
-    if (project.hasProperty('r8lib') || project.hasProperty('r8lib_no_deps')) {
-        def out = new StringBuffer()
-        def err = new StringBuffer()
-        def command = "python tools/retrace.py --quiet"
-        def header = "RETRACED STACKTRACE";
-        if (System.getenv('BUILDBOT_BUILDERNAME') != null
-                && !System.getenv('BUILDBOT_BUILDERNAME').endsWith("_release")) {
-            header += ": (${command} --commit_hash ${System.getenv('BUILDBOT_REVISION')})";
-        }
-        out.append("\n--------------------------------------\n")
-        out.append("${header}\n")
-        out.append("--------------------------------------\n")
-        Process process = command.execute()
-        def processIn = new PrintStream(process.getOut())
-        process.consumeProcessOutput(out, err)
-        result.exception.printStackTrace(processIn)
-        processIn.flush()
-        processIn.close()
-        def errorDuringRetracing = process.waitFor() != 0
-        if (errorDuringRetracing) {
-            out.append("ERROR DURING RETRACING\n")
-            out.append(err.toString())
-        }
-        if (project.hasProperty('print_obfuscated_stacktraces') || errorDuringRetracing) {
-            out.append("\n\n--------------------------------------\n")
-            out.append("OBFUSCATED STACKTRACE\n")
-            out.append("--------------------------------------\n")
-        } else {
-          result.exceptions.clear()
-        }
-        def exception = new Exception(out.toString())
+    if (shouldRetrace()) {
+        def exception = new Exception(retrace(result.exception))
         exception.setStackTrace([] as StackTraceElement[])
         result.exceptions.add(0, exception)
-    } else {
-        result.exception.printStackTrace()
     }
 }
 
@@ -1894,6 +1904,7 @@
     }
 }
 
+// It would be nice to do this in a non-destructive way...
 def filterStackTrace(Throwable exception) {
     if (!project.hasProperty('print_full_stacktraces')) {
         def elements = []
@@ -1911,10 +1922,136 @@
     }
 }
 
+def printAllStackTracesToFile(List<Throwable> exceptions, File out) {
+    new PrintStream(new FileOutputStream(out)).withCloseable {printer ->
+        exceptions.forEach { it.printStackTrace(printer) }
+    }
+}
+
+def ensureDir(File dir) {
+    dir.mkdirs()
+    return dir
+}
+
+def getTestReportEntryDir(reportDir, testDesc) {
+    return ensureDir(reportDir.toPath()
+            .resolve(testDesc.getClassName())
+            .resolve(testDesc.getName())
+            .toFile())
+}
+
+def getTestResultEntryOutputFile(reportDir, testDesc, fileName) {
+    def dir = getTestReportEntryDir(reportDir, testDesc).toPath()
+    return dir.resolve(fileName).toFile()
+}
+
+def getGitBranchName() {
+    def out = new StringBuilder()
+    def err = new StringBuilder()
+    def proc = "git rev-parse --abbrev-ref HEAD".execute()
+    proc.waitForProcessOutput(out, err)
+    return out
+}
+
+def setUpNewTestReporting(Test task) {
+    // Hide all test events from the console, they are written to the report.
+    task.testLogging { events = [] }
+
+    def testReportOutput = project.hasProperty('test_report_output')
+            ? file(project.hasProperty('test_report_output'))
+            : file("${buildDir}/testreport")
+    def index = testReportOutput.toPath().resolve("index.html").toFile()
+
+    task.beforeSuite { desc ->
+        if (!desc.parent) {
+            // Start by printing a link to the test report for easy access.
+            println "Test report being written to:"
+            println "file://${index}"
+
+            def branch = getGitBranchName()
+            def title = "${desc} @ ${branch}"
+
+            // TODO(b/186607794): Support resuming testing based on the existing report.
+            delete testReportOutput
+            testReportOutput.mkdirs()
+            index << "<html><head><title>${title}</title></head>"
+            index << "<body><h1>${title}</h1>"
+            index << "<p>Run on: ${new Date()}</p>"
+            index << "<p>Git branch: ${branch}</p>"
+            index << "<p><a href=\"file://${testReportOutput}\">Test directories</a></p>"
+            index << "<h2>Failing tests (reload to refresh)</h2><ul>"
+        }
+    }
+
+    task.afterSuite { desc, result ->
+        if (!desc.parent) {
+            // Update the final test results in the index.
+            index << "</ul>"
+            index << "<h2>Tests finished: ${result.resultType.name()}</h2><ul>"
+            index << "<li>Number of tests: ${result.testCount}"
+            index << "<li>Failing tests: ${result.failedTestCount}"
+            index << "<li>Successful tests: ${result.successfulTestCount}"
+            index << "<li>Skipped tests: ${result.skippedTestCount}"
+            index << "</ul></body></html>"
+        }
+    }
+
+    // Events to stdout/err are appended to the files in the test directories.
+    task.onOutput { desc, event ->
+        getTestResultEntryOutputFile(testReportOutput, desc, event.getDestination().name())
+                << event.getMessage()
+    }
+
+    task.afterTest { desc, result ->
+        if (result.getTestCount() != 1) {
+            throw new IllegalStateException("Unexpected test with more than one result: ${desc}")
+        }
+        def testReportPath = getTestReportEntryDir(testReportOutput, desc)
+        // Emit the result type status in a file of the same name: SUCCESS, FAILURE or SKIPPED.
+        getTestResultEntryOutputFile(testReportOutput, desc, result.getResultType().name())
+                << result.getResultType().name()
+        // Emit the test time.
+        getTestResultEntryOutputFile(testReportOutput, desc, "time")
+                << "${result.getEndTime() - result.getStartTime()}"
+        // For failed tests, update the index and emit stack trace information.
+        if (result.resultType == TestResult.ResultType.FAILURE) {
+            def title = "${desc.className}.${desc.name}"
+            index << "<li><a href=\"file://${testReportPath}\">${title}</a></li>"
+            if (!result.exceptions.isEmpty()) {
+                printAllStackTracesToFile(
+                        result.exceptions,
+                        getTestResultEntryOutputFile(
+                                testReportOutput,
+                                desc,
+                                "exceptions-raw.txt"))
+                filterStackTraces(result)
+                printAllStackTracesToFile(
+                        result.exceptions,
+                        getTestResultEntryOutputFile(
+                                testReportOutput,
+                                desc,
+                                "exceptions-filtered.txt"))
+                if (shouldRetrace()) {
+                    def out = getTestResultEntryOutputFile(
+                            testReportOutput,
+                            desc,
+                            "exceptions-retraced.txt")
+                    result.exceptions.forEach { out << retrace(it) }
+                }
+            }
+        }
+    }
+}
+
 def testTimes = [:]
 def numberOfTestTimesToPrint = 100
 
-test {
+test { task ->
+    def newTestingReport = project.hasProperty('testing-report')
+    if (newTestingReport) {
+        setUpNewTestReporting(task)
+    }
+
     if (project.hasProperty('generate_golden_files_to')) {
         systemProperty 'generate_golden_files_to', project.property('generate_golden_files_to')
         assert project.hasProperty('HEAD_sha1')
@@ -1927,13 +2064,11 @@
         systemProperty 'test_git_HEAD_sha1', project.property('HEAD_sha1')
     }
 
-    dependsOn buildLibraryDesugarConversions
-    dependsOn getJarsFromSupportLibs
-    // R8.jar is required for running bootstrap tests.
-    dependsOn r8
-    testLogging.exceptionFormat = 'full'
-    if (project.hasProperty('print_test_stdout')) {
-        testLogging.showStandardStreams = true
+    if (!newTestingReport) {
+        testLogging.exceptionFormat = 'full'
+        if (project.hasProperty('print_test_stdout')) {
+            testLogging.showStandardStreams = true
+        }
     }
     if (project.hasProperty('dex_vm') && project.property('dex_vm') != 'default') {
         println "NOTE: Running with non default vm: " + project.property('dex_vm')
@@ -1958,39 +2093,42 @@
         systemProperty 'desugar_jdk_libs', project.property('desugar_jdk_libs')
     }
 
-    if (project.hasProperty('print_times') || project.hasProperty('one_line_per_test')) {
-        afterTest { desc, result ->
-            def executionTime = (result.endTime - result.startTime) / 1000
-            testTimes["${desc.name} [${desc.className}]"] = executionTime
-        }
-        afterSuite { desc, result ->
-          // parent is null if all tests are done.
-          if (desc.parent == null) {
-            def sortedTimes = testTimes.sort({e1, e2 -> e2.value <=> e1.value})
-            sortedTimes.eachWithIndex{key, value, i ->
-                if (i < numberOfTestTimesToPrint) println "$key: $value"}
-          }
-        }
-    }
-
-    if (project.hasProperty('one_line_per_test')) {
-        beforeTest { desc ->
-            println "Start executing test ${desc.name} [${desc.className}]"
+    if (!newTestingReport) {
+        if (project.hasProperty('print_times') || project.hasProperty('one_line_per_test')) {
+            afterTest { desc, result ->
+                def executionTime = (result.endTime - result.startTime) / 1000
+                testTimes["${desc.name} [${desc.className}]"] = executionTime
+            }
+            afterSuite { desc, result ->
+                // parent is null if all tests are done.
+                if (desc.parent == null) {
+                    def sortedTimes = testTimes.sort({ e1, e2 -> e2.value <=> e1.value })
+                    sortedTimes.eachWithIndex { key, value, i ->
+                        if (i < numberOfTestTimesToPrint) println "$key: $value"
+                    }
+                }
+            }
         }
 
-        afterTest { desc, result ->
-            if (result.resultType == TestResult.ResultType.FAILURE) {
-                printStackTrace(result)
+        if (project.hasProperty('one_line_per_test')) {
+            beforeTest { desc ->
+                println "Start executing test ${desc.name} [${desc.className}]"
             }
-            if (project.hasProperty('update_test_timestamp')) {
-                file(project.getProperty('update_test_timestamp')).text = new Date().getTime()
+
+            afterTest { desc, result ->
+                if (result.resultType == TestResult.ResultType.FAILURE) {
+                    printStackTrace(result)
+                }
+                if (project.hasProperty('update_test_timestamp')) {
+                    file(project.getProperty('update_test_timestamp')).text = new Date().getTime()
+                }
+                println "Done executing test ${desc.name} [${desc.className}] with result: ${result.resultType}"
             }
-            println "Done executing test ${desc.name} [${desc.className}] with result: ${result.resultType}"
-        }
-    } else {
-        afterTest { desc, result ->
-            if (result.resultType == TestResult.ResultType.FAILURE) {
-                printStackTrace(result)
+        } else {
+            afterTest { desc, result ->
+                if (result.resultType == TestResult.ResultType.FAILURE) {
+                    printStackTrace(result)
+                }
             }
         }
     }