blob: 1b8907500117577fb8e1301cf7c5e73d91b92bda [file] [log] [blame]
// Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import java.io.File
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.PrintStream
import java.net.URLEncoder
import java.nio.file.Path
import org.gradle.api.Project
import org.gradle.api.logging.Logger
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.testing.Test
import org.gradle.api.tasks.testing.TestDescriptor
import org.gradle.api.tasks.testing.TestListener
import org.gradle.api.tasks.testing.TestOutputEvent
import org.gradle.api.tasks.testing.TestOutputListener
import org.gradle.api.tasks.testing.TestResult
import org.gradle.api.tasks.testing.TestResult.ResultType
import org.gradle.kotlin.dsl.register
// Utility to install tracking of test results in status files.
class TestingState {
companion object {
const val MODE_PROPERTY = "testing-state-mode"
const val PATH_PROPERTY = "testing-state-path"
// Operating mode for the test state.
enum class Mode { ALL, OUTSTANDING, FAILING, PAST_FAILING }
// These are the files that are allowed for tracking test status.
enum class StatusType { SUCCESS, FAILURE, PAST_FAILURE }
fun getRerunMode(project: Project) : Mode? {
val prop = project.findProperty(MODE_PROPERTY) ?: return null
return when (prop.toString().lowercase()) {
"all" -> Mode.ALL
"failing" -> Mode.FAILING
"past-failing" -> Mode.PAST_FAILING
"past_failing" -> Mode.PAST_FAILING
"outstanding" -> Mode.OUTSTANDING
else -> null
}
}
fun setUpTestingState(task: Test) {
// Both the path and the mode must be defined for the testing state to be active.
val testingStatePath = task.project.findProperty(PATH_PROPERTY) ?: return
val testingStateMode = getRerunMode(task.project) ?: return
val projectName = task.project.name
val indexDir = File(testingStatePath.toString())
val reportDir = indexDir.resolve(projectName)
val index = indexDir.resolve("index.html")
val resuming = reportDir.exists()
if (resuming) {
applyTestFilters(testingStateMode, task, reportDir, indexDir, projectName)
}
addTestHandler(task, projectName, index, reportDir)
}
private fun applyTestFilters(
mode: Mode,
task: Test,
reportDir: File,
indexDir: File,
projectName: String,
) {
if (mode == Mode.ALL) {
// Running without filters will (re)run all tests.
return
}
val statusType = getStatusTypeForMode(mode)
val statusOutputFile = indexDir.resolve("${projectName}.${statusType.name}.txt")
val findStatusTask = task.project.tasks.register<Exec>("${projectName}-find-status-files")
{
inputs.dir(reportDir)
outputs.file(statusOutputFile)
workingDir(reportDir)
commandLine(
"find", ".", "-name", statusType.name
)
doFirst {
standardOutput = statusOutputFile.outputStream()
}
}
task.dependsOn(findStatusTask)
task.doFirst {
if (mode == Mode.OUTSTANDING) {
forEachTestReportStatusMatching(
statusType,
findStatusTask.get().outputs.files.singleFile,
task.logger,
{ clazz, name -> task.filter.excludeTestsMatching("${clazz}.${name}") })
} else {
val hasMatch = forEachTestReportStatusMatching(
statusType,
findStatusTask.get().outputs.files.singleFile,
task.logger,
{ clazz, name -> task.filter.includeTestsMatching("${clazz}.${name}") })
if (!hasMatch) {
// Add a filter that does not match to ensure the test run is not "without filters"
// which would run all tests.
task.filter.includeTestsMatching("NON_MATCHING_TEST_FILTER")
}
}
}
}
private fun addTestHandler(
task: Test,
projectName: String,
index: File,
reportDir: File) {
task.addTestOutputListener(object : TestOutputListener {
override fun onOutput(desc: TestDescriptor, event: TestOutputEvent) {
withTestResultEntryWriter(reportDir, desc, event.getDestination().name, true, {
it.append(event.getMessage())
})
}
})
task.addTestListener(object : TestListener {
override fun beforeSuite(desc: TestDescriptor) {}
override fun afterSuite(desc: TestDescriptor, result: TestResult) {
if (desc.parent != null) {
return
}
// Update the final test results in the index.
val text = StringBuilder()
val successColor = "#a2ff99"
val failureColor = "#ff6454"
val emptyColor = "#d4d4d4"
val color: String;
if (result.testCount == 0L) {
color = emptyColor
} else if (result.resultType == TestResult.ResultType.SUCCESS) {
color = successColor
} else if (result.resultType == TestResult.ResultType.FAILURE) {
color = failureColor
} else {
color = failureColor
}
// The failure list has an open <ul> so close it before appending the module results.
text.append("</ul>")
text.append("<div style=\"background-color:${color}\">")
text.append("<h2>${projectName}: ${result.resultType.name}</h2>")
text.append("<ul>")
text.append("<li>Number of tests: ${result.testCount}")
text.append("<li>Failing tests: ${result.failedTestCount}")
text.append("<li>Successful tests: ${result.successfulTestCount}")
text.append("<li>Skipped tests: ${result.skippedTestCount}")
text.append("</ul></div>")
// Reopen a <ul> as other modules may still append test failures.
text.append("<ul>")
index.appendText(text.toString())
}
override fun beforeTest(desc: TestDescriptor) {
// Remove any stale output files before running the test.
for (destType in TestOutputEvent.Destination.values()) {
val destFile = getTestResultEntryOutputFile(reportDir, desc, destType.name)
if (destFile.exists()) {
destFile.delete()
}
}
}
override fun afterTest(desc: TestDescriptor, result: TestResult) {
if (result.testCount != 1L) {
throw IllegalStateException("Unexpected test with more than one result: ${desc}")
}
updateStatusFiles(reportDir, desc, result.resultType)
// Emit the test time.
withTestResultEntryWriter(reportDir, desc, "time", false, {
it.append("${result.getEndTime() - result.getStartTime()}")
})
// For failed tests, update the index and emit stack trace information.
if (result.resultType == ResultType.FAILURE) {
val title = testLinkContent(desc)
val link = getTestReportEntryURL(reportDir, desc)
index.appendText("<li><a href=\"${link}\">${title}</a></li>")
if (!result.exceptions.isEmpty()) {
printAllStackTracesToFile(
result.exceptions,
getTestResultEntryOutputFile(
reportDir,
desc,
"exceptions-raw.txt"
)
)
// The raw stacktrace has lots of useless gradle test runner frames.
// As a convenience filter out those so the stack is just easier to read.
filterStackTraces(result)
printAllStackTracesToFile(
result.exceptions,
getTestResultEntryOutputFile(
reportDir,
desc,
"exceptions-filtered.txt"
)
)
}
}
}
})
}
private fun getStatusTypeForMode(mode: Mode) : StatusType {
return when (mode) {
Mode.OUTSTANDING -> StatusType.SUCCESS
Mode.FAILING -> StatusType.FAILURE
Mode.PAST_FAILING -> StatusType.PAST_FAILURE
Mode.ALL -> throw RuntimeException("Unexpected mode 'all' in status determination")
}
}
private fun getStatusTypeForResult(result: ResultType) : StatusType {
return when (result) {
ResultType.FAILURE -> StatusType.FAILURE
ResultType.SUCCESS -> StatusType.SUCCESS
ResultType.SKIPPED -> StatusType.SUCCESS
}
}
private fun escapeHtml(string: String): String {
return string
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
private fun urlEncode(string: String): String {
// Not sure why, but the + also needs to be converted to have working links.
return URLEncoder.encode(string, "UTF-8").replace("+", "%20")
}
private fun testLinkContent(desc: TestDescriptor) : String {
val pkgR8 = "com.android.tools.r8."
val className = desc.className!!
val shortClassName =
if (className.startsWith(pkgR8)) {
className.substring(pkgR8.length)
} else {
className
}
return escapeHtml("${shortClassName}.${desc.name}")
}
private fun filterStackTraces(result: TestResult) {
for (throwable in result.getExceptions()) {
filterStackTrace(throwable)
}
}
// It would be nice to do this in a non-destructive way...
private fun filterStackTrace(exception: Throwable) {
val elements = ArrayList<StackTraceElement>()
val skipped = ArrayList<StackTraceElement>()
for (element in exception.getStackTrace()) {
if (element.toString().contains("com.android.tools.r8")) {
elements.addAll(skipped)
elements.add(element)
skipped.clear()
} else {
skipped.add(element)
}
}
exception.setStackTrace(elements.toTypedArray())
}
private fun printAllStackTracesToFile(exceptions: List<Throwable>, out: File) {
PrintStream(FileOutputStream(out))
.use({ printer -> exceptions.forEach { it.printStackTrace(printer) } })
}
private fun ensureDir(dir: File): File {
dir.mkdirs()
return dir
}
// Some of our test parameters have new lines :-( We really don't want test names to span lines.
private fun sanitizedTestName(testName: String): String {
if (testName.contains("\n")) {
throw RuntimeException("Unsupported use of newline in test name: '${testName}'")
}
return testName
}
private fun getTestReportClassDirPath(reportDir: File, testClass: String): Path {
return reportDir.toPath().resolve(testClass)
}
private fun getTestReportEntryDirFromString(reportDir: File, testClass: String, testName: String): File {
return ensureDir(
getTestReportClassDirPath(reportDir, testClass)
.resolve(sanitizedTestName(testName))
.toFile())
}
private fun getTestReportEntryDirFromTest(reportDir: File, testDesc: TestDescriptor): File {
return getTestReportEntryDirFromString(reportDir, testDesc.className!!, testDesc.name)
}
private fun getTestReportEntryURL(reportDir: File, testDesc: TestDescriptor): Path {
val classDir = urlEncode(testDesc.className!!)
val testDir = urlEncode(sanitizedTestName(testDesc.name))
return reportDir.toPath().resolve(classDir).resolve(testDir)
}
private fun getTestResultEntryOutputFile(
reportDir: File,
testDesc: TestDescriptor,
fileName: String
): File {
val dir = getTestReportEntryDirFromTest(reportDir, testDesc).toPath()
return dir.resolve(fileName).toFile()
}
private fun updateStatusFiles(
reportDir: File,
desc: TestDescriptor,
result: ResultType) {
val statusFile = getStatusTypeForResult(result)
withTestResultEntryWriter(reportDir, desc, statusFile.name, false, {
it.append(statusFile.name)
})
if (statusFile == StatusType.FAILURE) {
getTestResultEntryOutputFile(reportDir, desc, StatusType.SUCCESS.name).delete()
val pastFailure = StatusType.PAST_FAILURE.name
withTestResultEntryWriter(reportDir, desc, pastFailure, false, {
it.append(pastFailure)
})
} else {
getTestResultEntryOutputFile(reportDir, desc, StatusType.FAILURE.name).delete()
}
}
private fun withTestResultEntryWriter(
reportDir: File,
testDesc: TestDescriptor,
fileName: String,
append: Boolean,
fn: (FileWriter) -> Unit
) {
val file = getTestResultEntryOutputFile(reportDir, testDesc, fileName)
FileWriter(file, append).use(fn)
}
private fun forEachTestReportStatusMatching(
type: StatusType, file: File, logger: Logger, onTest: (String, String) -> Unit
) : Boolean {
val fileName = type.name
var hasMatch = false
for (rawLine in file.bufferedReader().lineSequence()) {
// Lines are of the form: ./<class>/<name>/<mode>
try {
val trimmed = rawLine.trim()
val line = trimmed.substring(2)
val sep = line.indexOf("/")
val clazz = line.substring(0, sep)
val name = line.substring(sep + 1, line.length - fileName.length - 1)
onTest(clazz, name)
hasMatch = true
} catch (e: Exception) {
logger.lifecycle("WARNING: failed attempt to read test description from: '${rawLine}'")
}
}
return hasMatch
}
}
}