blob: 1b8907500117577fb8e1301cf7c5e73d91b92bda [file] [log] [blame]
Ian Zernyc2de7b72023-09-06 20:52:16 +02001// Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
2// for details. All rights reserved. Use of this source code is governed by a
3// BSD-style license that can be found in the LICENSE file.
4
5import java.io.File
6import java.io.FileOutputStream
7import java.io.FileWriter
8import java.io.PrintStream
9import java.net.URLEncoder
10import java.nio.file.Path
Ian Zerny9f0345d2023-09-07 11:15:00 +020011import org.gradle.api.Project
Ian Zerny992ce742023-09-13 09:29:58 +020012import org.gradle.api.logging.Logger
13import org.gradle.api.tasks.Exec
Ian Zernyc2de7b72023-09-06 20:52:16 +020014import org.gradle.api.tasks.testing.Test
15import org.gradle.api.tasks.testing.TestDescriptor
16import org.gradle.api.tasks.testing.TestListener
17import org.gradle.api.tasks.testing.TestOutputEvent
18import org.gradle.api.tasks.testing.TestOutputListener
19import org.gradle.api.tasks.testing.TestResult
Ian Zernyf271e8d2023-09-11 12:30:41 +020020import org.gradle.api.tasks.testing.TestResult.ResultType
Ian Zerny992ce742023-09-13 09:29:58 +020021import org.gradle.kotlin.dsl.register
Ian Zernyc2de7b72023-09-06 20:52:16 +020022
Ian Zerny9f0345d2023-09-07 11:15:00 +020023// Utility to install tracking of test results in status files.
Ian Zernyc2de7b72023-09-06 20:52:16 +020024class TestingState {
25 companion object {
26
Ian Zernyf271e8d2023-09-11 12:30:41 +020027 const val MODE_PROPERTY = "testing-state-mode"
28 const val PATH_PROPERTY = "testing-state-path"
29
30 // Operating mode for the test state.
31 enum class Mode { ALL, OUTSTANDING, FAILING, PAST_FAILING }
32
33 // These are the files that are allowed for tracking test status.
Ian Zerny992ce742023-09-13 09:29:58 +020034 enum class StatusType { SUCCESS, FAILURE, PAST_FAILURE }
Ian Zernyf271e8d2023-09-11 12:30:41 +020035
36 fun getRerunMode(project: Project) : Mode? {
37 val prop = project.findProperty(MODE_PROPERTY) ?: return null
38 return when (prop.toString().lowercase()) {
39 "all" -> Mode.ALL
40 "failing" -> Mode.FAILING
41 "past-failing" -> Mode.PAST_FAILING
42 "past_failing" -> Mode.PAST_FAILING
43 "outstanding" -> Mode.OUTSTANDING
44 else -> null
Rico Wind237fc812023-09-11 10:02:24 +000045 }
Ian Zernyf271e8d2023-09-11 12:30:41 +020046 }
47
48 fun setUpTestingState(task: Test) {
49 // Both the path and the mode must be defined for the testing state to be active.
50 val testingStatePath = task.project.findProperty(PATH_PROPERTY) ?: return
51 val testingStateMode = getRerunMode(task.project) ?: return
52
Ian Zerny9f0345d2023-09-07 11:15:00 +020053 val projectName = task.project.name
Ian Zernyf271e8d2023-09-11 12:30:41 +020054 val indexDir = File(testingStatePath.toString())
55 val reportDir = indexDir.resolve(projectName)
Ian Zerny9f0345d2023-09-07 11:15:00 +020056 val index = indexDir.resolve("index.html")
57 val resuming = reportDir.exists()
58 if (resuming) {
Ian Zerny992ce742023-09-13 09:29:58 +020059 applyTestFilters(testingStateMode, task, reportDir, indexDir, projectName)
Ian Zerny9f0345d2023-09-07 11:15:00 +020060 }
61 addTestHandler(task, projectName, index, reportDir)
62 }
Ian Zernyc2de7b72023-09-06 20:52:16 +020063
Ian Zerny992ce742023-09-13 09:29:58 +020064 private fun applyTestFilters(
65 mode: Mode,
66 task: Test,
67 reportDir: File,
68 indexDir: File,
69 projectName: String,
70 ) {
Ian Zernyf271e8d2023-09-11 12:30:41 +020071 if (mode == Mode.ALL) {
72 // Running without filters will (re)run all tests.
Rico Wind237fc812023-09-11 10:02:24 +000073 return
74 }
Ian Zerny992ce742023-09-13 09:29:58 +020075 val statusType = getStatusTypeForMode(mode)
76 val statusOutputFile = indexDir.resolve("${projectName}.${statusType.name}.txt")
77 val findStatusTask = task.project.tasks.register<Exec>("${projectName}-find-status-files")
78 {
79 inputs.dir(reportDir)
80 outputs.file(statusOutputFile)
81 workingDir(reportDir)
82 commandLine(
83 "find", ".", "-name", statusType.name
84 )
85 doFirst {
86 standardOutput = statusOutputFile.outputStream()
87 }
Ian Zernyf271e8d2023-09-11 12:30:41 +020088 }
Ian Zerny992ce742023-09-13 09:29:58 +020089 task.dependsOn(findStatusTask)
90 task.doFirst {
91 if (mode == Mode.OUTSTANDING) {
92 forEachTestReportStatusMatching(
93 statusType,
94 findStatusTask.get().outputs.files.singleFile,
95 task.logger,
96 { clazz, name -> task.filter.excludeTestsMatching("${clazz}.${name}") })
97 } else {
98 val hasMatch = forEachTestReportStatusMatching(
99 statusType,
100 findStatusTask.get().outputs.files.singleFile,
101 task.logger,
102 { clazz, name -> task.filter.includeTestsMatching("${clazz}.${name}") })
103 if (!hasMatch) {
104 // Add a filter that does not match to ensure the test run is not "without filters"
105 // which would run all tests.
106 task.filter.includeTestsMatching("NON_MATCHING_TEST_FILTER")
107 }
108 }
109 }
Ian Zerny9f0345d2023-09-07 11:15:00 +0200110 }
111
112 private fun addTestHandler(
113 task: Test,
114 projectName: String,
115 index: File,
116 reportDir: File) {
117 task.addTestOutputListener(object : TestOutputListener {
118 override fun onOutput(desc: TestDescriptor, event: TestOutputEvent) {
119 withTestResultEntryWriter(reportDir, desc, event.getDestination().name, true, {
120 it.append(event.getMessage())
121 })
122 }
123 })
Ian Zernyc2de7b72023-09-06 20:52:16 +0200124 task.addTestListener(object : TestListener {
Ian Zernyc2de7b72023-09-06 20:52:16 +0200125
Ian Zerny9f0345d2023-09-07 11:15:00 +0200126 override fun beforeSuite(desc: TestDescriptor) {}
Ian Zernyc2de7b72023-09-06 20:52:16 +0200127
128 override fun afterSuite(desc: TestDescriptor, result: TestResult) {
Ian Zerny9f0345d2023-09-07 11:15:00 +0200129 if (desc.parent != null) {
Ian Zernyc2de7b72023-09-06 20:52:16 +0200130 return
131 }
132 // Update the final test results in the index.
Ian Zerny9f0345d2023-09-07 11:15:00 +0200133 val text = StringBuilder()
134 val successColor = "#a2ff99"
135 val failureColor = "#ff6454"
136 val emptyColor = "#d4d4d4"
137 val color: String;
138 if (result.testCount == 0L) {
139 color = emptyColor
140 } else if (result.resultType == TestResult.ResultType.SUCCESS) {
141 color = successColor
Ian Zernyc2de7b72023-09-06 20:52:16 +0200142 } else if (result.resultType == TestResult.ResultType.FAILURE) {
Ian Zerny9f0345d2023-09-07 11:15:00 +0200143 color = failureColor
Ian Zernyc2de7b72023-09-06 20:52:16 +0200144 } else {
Ian Zerny9f0345d2023-09-07 11:15:00 +0200145 color = failureColor
Ian Zernyc2de7b72023-09-06 20:52:16 +0200146 }
Ian Zerny9f0345d2023-09-07 11:15:00 +0200147 // The failure list has an open <ul> so close it before appending the module results.
148 text.append("</ul>")
149 text.append("<div style=\"background-color:${color}\">")
150 text.append("<h2>${projectName}: ${result.resultType.name}</h2>")
151 text.append("<ul>")
152 text.append("<li>Number of tests: ${result.testCount}")
153 text.append("<li>Failing tests: ${result.failedTestCount}")
154 text.append("<li>Successful tests: ${result.successfulTestCount}")
155 text.append("<li>Skipped tests: ${result.skippedTestCount}")
156 text.append("</ul></div>")
157 // Reopen a <ul> as other modules may still append test failures.
158 text.append("<ul>")
159
160 index.appendText(text.toString())
Ian Zernyc2de7b72023-09-06 20:52:16 +0200161 }
162
163 override fun beforeTest(desc: TestDescriptor) {
164 // Remove any stale output files before running the test.
165 for (destType in TestOutputEvent.Destination.values()) {
166 val destFile = getTestResultEntryOutputFile(reportDir, desc, destType.name)
167 if (destFile.exists()) {
168 destFile.delete()
169 }
170 }
171 }
172
173 override fun afterTest(desc: TestDescriptor, result: TestResult) {
174 if (result.testCount != 1L) {
175 throw IllegalStateException("Unexpected test with more than one result: ${desc}")
176 }
Ian Zernyf271e8d2023-09-11 12:30:41 +0200177 updateStatusFiles(reportDir, desc, result.resultType)
Ian Zernyc2de7b72023-09-06 20:52:16 +0200178 // Emit the test time.
179 withTestResultEntryWriter(reportDir, desc, "time", false, {
180 it.append("${result.getEndTime() - result.getStartTime()}")
181 })
182 // For failed tests, update the index and emit stack trace information.
Ian Zernyf271e8d2023-09-11 12:30:41 +0200183 if (result.resultType == ResultType.FAILURE) {
Ian Zerny9f0345d2023-09-07 11:15:00 +0200184 val title = testLinkContent(desc)
Ian Zernyc2de7b72023-09-06 20:52:16 +0200185 val link = getTestReportEntryURL(reportDir, desc)
186 index.appendText("<li><a href=\"${link}\">${title}</a></li>")
187 if (!result.exceptions.isEmpty()) {
188 printAllStackTracesToFile(
189 result.exceptions,
190 getTestResultEntryOutputFile(
191 reportDir,
192 desc,
193 "exceptions-raw.txt"
194 )
195 )
Ian Zerny9f0345d2023-09-07 11:15:00 +0200196 // The raw stacktrace has lots of useless gradle test runner frames.
197 // As a convenience filter out those so the stack is just easier to read.
Ian Zernyc2de7b72023-09-06 20:52:16 +0200198 filterStackTraces(result)
199 printAllStackTracesToFile(
200 result.exceptions,
201 getTestResultEntryOutputFile(
202 reportDir,
203 desc,
204 "exceptions-filtered.txt"
205 )
206 )
207 }
208 }
209 }
210 })
Ian Zernyc2de7b72023-09-06 20:52:16 +0200211 }
212
Ian Zerny992ce742023-09-13 09:29:58 +0200213 private fun getStatusTypeForMode(mode: Mode) : StatusType {
214 return when (mode) {
215 Mode.OUTSTANDING -> StatusType.SUCCESS
216 Mode.FAILING -> StatusType.FAILURE
217 Mode.PAST_FAILING -> StatusType.PAST_FAILURE
218 Mode.ALL -> throw RuntimeException("Unexpected mode 'all' in status determination")
219 }
220 }
221
222 private fun getStatusTypeForResult(result: ResultType) : StatusType {
Ian Zernyf271e8d2023-09-11 12:30:41 +0200223 return when (result) {
Ian Zerny992ce742023-09-13 09:29:58 +0200224 ResultType.FAILURE -> StatusType.FAILURE
225 ResultType.SUCCESS -> StatusType.SUCCESS
226 ResultType.SKIPPED -> StatusType.SUCCESS
Ian Zernyf271e8d2023-09-11 12:30:41 +0200227 }
228 }
229
Ian Zerny9f0345d2023-09-07 11:15:00 +0200230 private fun escapeHtml(string: String): String {
231 return string
232 .replace("&", "&amp;")
233 .replace("<", "&lt;")
234 .replace(">", "&gt;")
235 }
236
237 private fun urlEncode(string: String): String {
Ian Zernyc2de7b72023-09-06 20:52:16 +0200238 // Not sure why, but the + also needs to be converted to have working links.
239 return URLEncoder.encode(string, "UTF-8").replace("+", "%20")
240 }
241
Ian Zerny9f0345d2023-09-07 11:15:00 +0200242 private fun testLinkContent(desc: TestDescriptor) : String {
243 val pkgR8 = "com.android.tools.r8."
244 val className = desc.className!!
245 val shortClassName =
246 if (className.startsWith(pkgR8)) {
247 className.substring(pkgR8.length)
248 } else {
249 className
250 }
251 return escapeHtml("${shortClassName}.${desc.name}")
252 }
253
254 private fun filterStackTraces(result: TestResult) {
255 for (throwable in result.getExceptions()) {
256 filterStackTrace(throwable)
257 }
258 }
259
260 // It would be nice to do this in a non-destructive way...
261 private fun filterStackTrace(exception: Throwable) {
262 val elements = ArrayList<StackTraceElement>()
263 val skipped = ArrayList<StackTraceElement>()
264 for (element in exception.getStackTrace()) {
265 if (element.toString().contains("com.android.tools.r8")) {
266 elements.addAll(skipped)
267 elements.add(element)
268 skipped.clear()
269 } else {
270 skipped.add(element)
271 }
272 }
273 exception.setStackTrace(elements.toTypedArray())
274 }
275
276 private fun printAllStackTracesToFile(exceptions: List<Throwable>, out: File) {
277 PrintStream(FileOutputStream(out))
278 .use({ printer -> exceptions.forEach { it.printStackTrace(printer) } })
279 }
280
281 private fun ensureDir(dir: File): File {
Ian Zernyc2de7b72023-09-06 20:52:16 +0200282 dir.mkdirs()
283 return dir
284 }
285
286 // Some of our test parameters have new lines :-( We really don't want test names to span lines.
Ian Zernyf271e8d2023-09-11 12:30:41 +0200287 private fun sanitizedTestName(testName: String): String {
288 if (testName.contains("\n")) {
289 throw RuntimeException("Unsupported use of newline in test name: '${testName}'")
Ian Zernyc2de7b72023-09-06 20:52:16 +0200290 }
Ian Zernyf271e8d2023-09-11 12:30:41 +0200291 return testName
Ian Zernyc2de7b72023-09-06 20:52:16 +0200292 }
293
Ian Zernyf271e8d2023-09-11 12:30:41 +0200294 private fun getTestReportClassDirPath(reportDir: File, testClass: String): Path {
295 return reportDir.toPath().resolve(testClass)
296 }
297
298 private fun getTestReportEntryDirFromString(reportDir: File, testClass: String, testName: String): File {
Ian Zernyc2de7b72023-09-06 20:52:16 +0200299 return ensureDir(
Ian Zernyf271e8d2023-09-11 12:30:41 +0200300 getTestReportClassDirPath(reportDir, testClass)
301 .resolve(sanitizedTestName(testName))
302 .toFile())
303 }
304
305 private fun getTestReportEntryDirFromTest(reportDir: File, testDesc: TestDescriptor): File {
306 return getTestReportEntryDirFromString(reportDir, testDesc.className!!, testDesc.name)
Ian Zernyc2de7b72023-09-06 20:52:16 +0200307 }
308
Ian Zerny9f0345d2023-09-07 11:15:00 +0200309 private fun getTestReportEntryURL(reportDir: File, testDesc: TestDescriptor): Path {
Ian Zernyf271e8d2023-09-11 12:30:41 +0200310 val classDir = urlEncode(testDesc.className!!)
311 val testDir = urlEncode(sanitizedTestName(testDesc.name))
Ian Zernyc2de7b72023-09-06 20:52:16 +0200312 return reportDir.toPath().resolve(classDir).resolve(testDir)
313 }
314
Ian Zerny9f0345d2023-09-07 11:15:00 +0200315 private fun getTestResultEntryOutputFile(
Ian Zernyc2de7b72023-09-06 20:52:16 +0200316 reportDir: File,
317 testDesc: TestDescriptor,
318 fileName: String
319 ): File {
Ian Zernyf271e8d2023-09-11 12:30:41 +0200320 val dir = getTestReportEntryDirFromTest(reportDir, testDesc).toPath()
Ian Zernyc2de7b72023-09-06 20:52:16 +0200321 return dir.resolve(fileName).toFile()
322 }
323
Ian Zernyf271e8d2023-09-11 12:30:41 +0200324 private fun updateStatusFiles(
325 reportDir: File,
326 desc: TestDescriptor,
327 result: ResultType) {
Ian Zerny992ce742023-09-13 09:29:58 +0200328 val statusFile = getStatusTypeForResult(result)
Ian Zernyf271e8d2023-09-11 12:30:41 +0200329 withTestResultEntryWriter(reportDir, desc, statusFile.name, false, {
330 it.append(statusFile.name)
331 })
Ian Zerny992ce742023-09-13 09:29:58 +0200332 if (statusFile == StatusType.FAILURE) {
333 getTestResultEntryOutputFile(reportDir, desc, StatusType.SUCCESS.name).delete()
334 val pastFailure = StatusType.PAST_FAILURE.name
Ian Zernyf271e8d2023-09-11 12:30:41 +0200335 withTestResultEntryWriter(reportDir, desc, pastFailure, false, {
336 it.append(pastFailure)
337 })
338 } else {
Ian Zerny992ce742023-09-13 09:29:58 +0200339 getTestResultEntryOutputFile(reportDir, desc, StatusType.FAILURE.name).delete()
Ian Zernyf271e8d2023-09-11 12:30:41 +0200340 }
341 }
342
Ian Zerny9f0345d2023-09-07 11:15:00 +0200343 private fun withTestResultEntryWriter(
Ian Zernyc2de7b72023-09-06 20:52:16 +0200344 reportDir: File,
345 testDesc: TestDescriptor,
346 fileName: String,
347 append: Boolean,
348 fn: (FileWriter) -> Unit
349 ) {
350 val file = getTestResultEntryOutputFile(reportDir, testDesc, fileName)
351 FileWriter(file, append).use(fn)
352 }
353
Ian Zerny992ce742023-09-13 09:29:58 +0200354 private fun forEachTestReportStatusMatching(
355 type: StatusType, file: File, logger: Logger, onTest: (String, String) -> Unit
356 ) : Boolean {
357 val fileName = type.name
358 var hasMatch = false
359 for (rawLine in file.bufferedReader().lineSequence()) {
360 // Lines are of the form: ./<class>/<name>/<mode>
Ian Zernyc2de7b72023-09-06 20:52:16 +0200361 try {
362 val trimmed = rawLine.trim()
363 val line = trimmed.substring(2)
364 val sep = line.indexOf("/")
365 val clazz = line.substring(0, sep)
366 val name = line.substring(sep + 1, line.length - fileName.length - 1)
367 onTest(clazz, name)
Ian Zerny992ce742023-09-13 09:29:58 +0200368 hasMatch = true
Ian Zernyc2de7b72023-09-06 20:52:16 +0200369 } catch (e: Exception) {
370 logger.lifecycle("WARNING: failed attempt to read test description from: '${rawLine}'")
371 }
372 }
Ian Zerny992ce742023-09-13 09:29:58 +0200373 return hasMatch
Ian Zernyc2de7b72023-09-06 20:52:16 +0200374 }
375 }
376}