Skip to content

Commit

Permalink
Customize test reporting for all runs (#1675)
Browse files Browse the repository at this point in the history
Co-authored-by: Geoff Powell <[email protected]>
  • Loading branch information
jrodbx and geoff-powell authored Nov 6, 2024
1 parent d6a8f1e commit 084dd1b
Show file tree
Hide file tree
Showing 38 changed files with 941 additions and 1,880 deletions.
2 changes: 1 addition & 1 deletion paparazzi-gradle-plugin/api/paparazzi-gradle-plugin.api
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
public final class app/cash/paparazzi/gradle/PaparazziPlugin : org/gradle/api/Plugin {
public fun <init> (Lorg/gradle/api/provider/ProviderFactory;)V
public fun <init> (Lorg/gradle/api/provider/ProviderFactory;Lorg/gradle/internal/operations/BuildOperationRunner;Lorg/gradle/internal/operations/BuildOperationExecutor;)V
public synthetic fun apply (Ljava/lang/Object;)V
public fun apply (Lorg/gradle/api/Project;)V
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package app.cash.paparazzi.gradle

import app.cash.paparazzi.gradle.instrumentation.ResourcesCompatVisitorFactory
import app.cash.paparazzi.gradle.reporting.TestReport
import app.cash.paparazzi.gradle.reporting.PaparazziTestReporter
import app.cash.paparazzi.gradle.utils.artifactViewFor
import app.cash.paparazzi.gradle.utils.relativize
import com.android.build.api.instrumentation.FramesComputationMode
Expand Down Expand Up @@ -45,6 +45,8 @@ import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.options.Option
import org.gradle.api.tasks.testing.AbstractTestTask
import org.gradle.api.tasks.testing.Test
import org.gradle.internal.operations.BuildOperationExecutor
import org.gradle.internal.operations.BuildOperationRunner
import org.gradle.internal.os.OperatingSystem
import org.gradle.language.base.plugins.LifecycleBasePlugin.VERIFICATION_GROUP
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
Expand All @@ -54,7 +56,9 @@ import javax.inject.Inject

@Suppress("unused")
public class PaparazziPlugin @Inject constructor(
private val providerFactory: ProviderFactory
private val providerFactory: ProviderFactory,
private val buildOperationRunner: BuildOperationRunner,
private val buildOperationExecutor: BuildOperationExecutor
) : Plugin<Project> {
override fun apply(project: Project) {
val supportedPlugins = listOf("com.android.application", "com.android.library", "com.android.dynamic-feature")
Expand Down Expand Up @@ -122,7 +126,6 @@ public class PaparazziPlugin @Inject constructor(
val buildDirectory = project.layout.buildDirectory
val gradleUserHomeDir = project.gradle.gradleUserHomeDir
val reportOutputDir = project.extensions.getByType(ReportingExtension::class.java).baseDirectory.dir("paparazzi/${variant.name}")
val failedSnapshotOutputDir = buildDirectory.dir("paparazzi/failures")

val testInstrumentation = testVariant.instrumentation
testInstrumentation.transformClassesWith(
Expand Down Expand Up @@ -185,12 +188,6 @@ public class PaparazziPlugin @Inject constructor(
val isRecordRun = project.objects.property(Boolean::class.java)
val isVerifyRun = project.objects.property(Boolean::class.java)

val snapshotFailures = failedSnapshotOutputDir.flatMap {
project.objects.directoryProperty().apply {
set(if (it.asFile.exists()) it else null)
}
}

project.gradle.taskGraph.whenReady { graph ->
isRecordRun.set(recordTaskProvider.map { graph.hasTask(it) })
isVerifyRun.set(verifyTaskProvider.map { graph.hasTask(it) })
Expand All @@ -199,6 +196,15 @@ public class PaparazziPlugin @Inject constructor(
val testTaskProvider =
project.tasks.withType(Test::class.java).named { it == "test$testVariantSlug" }
testTaskProvider.configureEach { test ->
test.setTestReporter(
PaparazziTestReporter(
buildOperationRunner = buildOperationRunner,
buildOperationExecutor = buildOperationExecutor,
isVerifyRun = isVerifyRun,
failureSnapshotDir = buildDirectory.dir("paparazzi/failures")
)
)

test.systemProperties["paparazzi.test.resources"] =
writeResourcesTask.flatMap { it.paparazziResources.asFile }.get().path
test.systemProperties["paparazzi.project.dir"] = projectDirectory.toString()
Expand Down Expand Up @@ -248,21 +254,6 @@ public class PaparazziPlugin @Inject constructor(

test.outputs.dir(reportOutputDir).withPropertyName("paparazzi.report.dir")

val isVerifying = isVerifyRun.map {
// We only want to run the our custom test reporter when verify task runs.
if (it) {
test.setTestReporter { testResultsProvider, reportDir ->
TestReport(
failureSnapshotDir = snapshotFailures.orNull?.asFile,
applicationId = variant.namespace.get(),
variantKey = variant.name
).generateReport(testResultsProvider = testResultsProvider, reportDir = reportDir)
}
}

it
}

test.doFirst {
// Note: these are lazy properties that are not resolvable in the Gradle configuration phase.
// They need special handling, so they're added as inputs.property above, and systemProperty here.
Expand All @@ -271,7 +262,7 @@ public class PaparazziPlugin @Inject constructor(
test.systemProperties["paparazzi.layoutlib.resources.root"] =
layoutlibResourcesFileCollection.singleFile.absolutePath
test.systemProperties["paparazzi.test.record"] = isRecordRun.get()
test.systemProperties["paparazzi.test.verify"] = isVerifying.get()
test.systemProperties["paparazzi.test.verify"] = isVerifyRun.get()
}

test.doLast {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,55 @@ package app.cash.paparazzi.gradle.reporting
import java.util.TreeMap

/**
*
* Custom test results based on Gradle's AllTestResults
* The model for the test report.
*/
internal class AllTestResults : CompositeTestResults(null) {
private val packages: MutableMap<String, PackageTestResults> = TreeMap()

private val packages: MutableMap<String, PackageTestResults> =
TreeMap<String, PackageTestResults>()
override val title: String = "Test Summary"
override val title: String
get() = "Test Summary"

override val name = null
override val baseUrl: String
get() = "index.html"

fun getPackages(): Collection<PackageTestResults> {
return packages.values
}
fun getPackages(): Collection<PackageTestResults> = packages.values

fun addTest(
classId: Long,
className: String,
classDisplayName: String = className,
testName: String,
duration: Long,
project: String,
flavor: String,
diffImages: List<ScreenshotDiffImage>?
testDisplayName: String = testName,
duration: Long
): TestResult {
val packageResults: PackageTestResults = addPackageForClass(className)
val testResult: TestResult = addTest(
packageResults.addTest(className, testName, duration, project, flavor, diffImages)
val packageResults = addPackageForClass(className)
return addTest(
packageResults.addTest(
classId, className, classDisplayName, testName, testDisplayName, duration
)
)
addVariant(project, flavor, testResult)
return testResult
}

fun addTestClass(
classId: Long,
className: String,
classDisplayName: String = className
): ClassTestResults {
return addPackageForClass(className).addClass(classId, className, classDisplayName)
}

private fun addPackageForClass(className: String): PackageTestResults {
val packageName = className.substringBeforeLast(".", "")
var packageName = className.substringBeforeLast(".")
if (packageName == className) {
packageName = ""
}
return addPackage(packageName)
}

private fun addPackage(packageName: String): PackageTestResults {
var packageResults: PackageTestResults? =
packages[packageName]
var packageResults = packages[packageName]
if (packageResults == null) {
packageResults = PackageTestResults(packageName, this)
standardError.forEach { packageResults.addStandardError(it) }
standardOutput.forEach { packageResults.addStandardOutput(it) }
packages[packageName] = packageResults
}
return packageResults
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package app.cash.paparazzi.gradle.reporting

import org.gradle.api.internal.tasks.testing.junit.result.TestResultsProvider
import org.gradle.api.tasks.testing.TestOutputEvent
import org.gradle.internal.html.SimpleHtmlWriter
import org.gradle.internal.xml.SimpleMarkupWriter
import org.gradle.reporting.CodePanelRenderer
import java.io.File
import java.io.IOException
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

internal class ClassPageRenderer(
private val resultsProvider: TestResultsProvider,
isVerifyRun: Boolean,
failureDir: File
) : PageRenderer<ClassTestResults>() {
private val codePanelRenderer = CodePanelRenderer()
private val imagePanelRenderer = ImagePanelRenderer()

@OptIn(ExperimentalEncodingApi::class)
private val diffImages: List<DiffImage> =
if (isVerifyRun && failureDir.exists()) {
failureDir.listFiles()
?.filter { it.name.startsWith("delta-") }
?.map { diff ->
// TODO: read from failure diff metadata file instead of brittle parsing
val nameSegments = diff.name.split("_", limit = 3)
val testClassPackage = nameSegments[0].replace("delta-", "")
val testClass = "$testClassPackage.${nameSegments[1]}"
val testMethodWithLabel = nameSegments[2].removeSuffix(".png")

return@map DiffImage(
testClass = testClass,
testMethod = testMethodWithLabel,
path = diff.path,
base64EncodedImage = Base64.encode(diff.readBytes())
)
}
?.toList() ?: emptyList()
} else {
emptyList()
}

@Throws(IOException::class)
override fun renderBreadcrumbs(htmlWriter: SimpleHtmlWriter) {
htmlWriter
.startElement("div")
.attribute("class", "breadcrumbs")
.startElement("a")
.attribute("href", results.getUrlTo(results.parent!!.parent!!))
.characters("all").endElement()
.characters(" > ")
.startElement("a")
.attribute("href", results.getUrlTo(results.packageResults))
.characters(results.packageResults.name)
.endElement()
.characters(" > " + results.simpleName)
.endElement()
}

@Throws(IOException::class)
private fun renderTests(htmlWriter: SimpleHtmlWriter) {
val writer = htmlWriter.startElement("table")
renderTableHead(writer, determineTableHeaders())

val methodNameColumnExists = methodNameColumnExists()

for (test in results.testResults) {
renderTableRow(writer, test, determineTableRow(test, methodNameColumnExists))
}
htmlWriter.endElement()
}

private fun determineTableRow(
test: TestResult,
methodNameColumnExists: Boolean
): List<String> =
if (methodNameColumnExists) {
listOf(
test.displayName,
test.name,
test.formattedDuration,
test.formattedResultType
)
} else {
listOf(
test.displayName,
test.formattedDuration,
test.formattedResultType
)
}

private fun determineTableHeaders(): List<String> =
if (methodNameColumnExists()) {
listOf("Test", "Method name", "Duration", "Result")
} else {
listOf("Test", "Duration", "Result")
}

@Throws(IOException::class)
private fun renderTableHead(writer: SimpleMarkupWriter, headers: List<String>) {
writer.startElement("thead").startElement("tr")
for (header in headers) {
writer.startElement("th").characters(header).endElement()
}
writer.endElement().endElement()
}

@Throws(IOException::class)
private fun renderTableRow(
writer: SimpleMarkupWriter,
test: TestResult,
rowCells: List<String>
) {
writer.startElement("tr")
for (cell in rowCells) {
writer
.startElement("td")
.attribute("class", test.statusClass)
.characters(cell)
.endElement()
}
writer.endElement()
}

private fun methodNameColumnExists(): Boolean =
results.testResults.any { it.name != it.displayName }

@Throws(IOException::class)
override fun renderFailures(htmlWriter: SimpleHtmlWriter) {
for (test in results.failures) {
htmlWriter
.startElement("div")
.attribute("class", "test")
.startElement("a")
.attribute("name", test.id.toString()).characters("")
.endElement() // browsers dont understand <a name="..."/>
.startElement("h3")
.attribute("class", test.statusClass)
.characters(test.displayName)
.endElement()
for (failure in test.failures) {
val diffImage = diffImages.find { it.testClass == results.name && it.testMethod == test.name }
if (diffImage != null) {
imagePanelRenderer.render(diffImage, htmlWriter)
}

val message =
if (failure.message.isNullOrBlank() && !failure.stackTrace.contains(failure.message)) {
failure.message + System.lineSeparator() + System.lineSeparator() + failure.stackTrace
} else {
failure.stackTrace
}
codePanelRenderer.render(message, htmlWriter)
}
htmlWriter.endElement()
}
}

override fun registerTabs() {
addFailuresTab()
addTab(
"Tests",
object : ErroringAction<SimpleHtmlWriter>() {
@Throws(IOException::class)
public override fun doExecute(htmlWriter: SimpleHtmlWriter) {
renderTests(htmlWriter)
}
}
)
val classId = model.id
if (resultsProvider.hasOutput(classId, TestOutputEvent.Destination.StdOut)) {
addTab(
"Standard output",
object : ErroringAction<SimpleHtmlWriter>() {
@Throws(IOException::class)
override fun doExecute(htmlWriter: SimpleHtmlWriter) {
htmlWriter
.startElement("span")
.attribute("class", "code")
.startElement("pre")
.characters("")
resultsProvider.writeAllOutput(classId, TestOutputEvent.Destination.StdOut, htmlWriter)
htmlWriter.endElement().endElement()
}
}
)
}
if (resultsProvider.hasOutput(classId, TestOutputEvent.Destination.StdErr)) {
addTab(
"Standard error",
object : ErroringAction<SimpleHtmlWriter>() {
@Throws(Exception::class)
override fun doExecute(htmlWriter: SimpleHtmlWriter) {
htmlWriter
.startElement("span")
.attribute("class", "code")
.startElement("pre")
.characters("")
resultsProvider.writeAllOutput(classId, TestOutputEvent.Destination.StdErr, htmlWriter)
htmlWriter.endElement().endElement()
}
}
)
}
}
}
Loading

0 comments on commit 084dd1b

Please sign in to comment.