| // 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("&", "&") |
| .replace("<", "<") |
| .replace(">", ">") |
| } |
| |
| 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 |
| } |
| } |
| } |