blob: 80c94f0d3d4cfbaa1d645b8a63aafd1816d77bb6 [file] [log] [blame]
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.compose
import com.android.tools.render.compose.readComposeScreenshotsJson
import com.android.tools.render.compose.readComposeRenderingResultJson
import com.android.tools.render.compose.ComposeScreenshot
import com.android.tools.render.compose.ComposeScreenshotResult
import java.io.File
import javax.imageio.ImageIO
import org.junit.platform.engine.support.descriptor.EngineDescriptor
import org.junit.platform.engine.EngineDiscoveryRequest
import org.junit.platform.engine.EngineExecutionListener
import org.junit.platform.engine.ExecutionRequest
import org.junit.platform.engine.TestDescriptor
import org.junit.platform.engine.TestEngine
import org.junit.platform.engine.UniqueId
import org.junit.platform.engine.TestExecutionResult
import org.junit.platform.engine.support.discovery.EngineDiscoveryRequestResolver
class PreviewScreenshotTestEngine : TestEngine {
override fun getId(): String {
return "preview-screenshot-test-engine"
}
override fun discover(discoveryRequest: EngineDiscoveryRequest, uniqueId: UniqueId): TestDescriptor {
val engineDescriptor = EngineDescriptor(uniqueId, "Preview Screenshot Test Engine")
val screenshots: List<ComposeScreenshot> = readComposeScreenshotsJson(File(getParam("previews-discovered")).reader())
val testMap = mutableMapOf<String, MutableSet<String>>()
for (screenshot in screenshots) {
val methodName = screenshot.methodFQN.split(".").last()
val className = screenshot.methodFQN.substring(0, screenshot.methodFQN.lastIndexOf("."))
if (testMap.contains(className)) {
testMap[className]!!.add(methodName)
} else {
testMap[className] = mutableSetOf(methodName)
}
}
val tests = Tests(testMap)
EngineDiscoveryRequestResolver.builder<EngineDescriptor>()
.addClassContainerSelectorResolver { testClass ->
tests.classes.contains(testClass.getName())
}
.addSelectorResolver { ctx -> ClassSelectorResolver(ctx.classNameFilter, tests) }
.addSelectorResolver(MethodSelectorResolver(tests))
.build()
.resolve(discoveryRequest, engineDescriptor)
return engineDescriptor
}
override fun execute(request: ExecutionRequest) {
val listener = request.engineExecutionListener
val resultsToSave = mutableListOf<PreviewResult>()
if (request.rootTestDescriptor.children.isEmpty()) return
val resultFile = File(getParam("renderResultsFilePath"))
val screenshotResults = readComposeRenderingResultJson(resultFile.reader()).screenshotResults
val composeScreenshots: List<ComposeScreenshot> = readComposeScreenshotsJson(File(getParam("previews-discovered")).reader())
// Method descriptors are created as type CONTAINER_AND_TEST. For single method tests,
// replace the method descriptor with one of type TEST.
for (classDescriptor in request.rootTestDescriptor.children) {
val methodsToRemove = mutableListOf<TestMethodDescriptor>()
val methodsToAdd = mutableListOf<TestMethodTestDescriptor>()
for (methodDescriptor in classDescriptor.children) {
val className: String = (methodDescriptor as TestMethodDescriptor).className
val methodName: String = methodDescriptor.methodName
val screenshots =
screenshotResults.filter {
getResultIdWithoutSuffix(it.resultId) == "$className.${methodName}"
}
if (screenshots.size == 1) {
val testMethodDescriptor = TestMethodTestDescriptor(methodDescriptor.uniqueId, methodName, className)
methodsToAdd.add(testMethodDescriptor)
methodsToRemove.add(methodDescriptor)
}
}
methodsToAdd.forEach {
classDescriptor.addChild(it)
listener.dynamicTestRegistered(it)
}
methodsToRemove.forEach { classDescriptor.removeChild(it) }
}
for (classDescriptor in request.rootTestDescriptor.children) {
listener.executionStarted(classDescriptor)
for (methodDescriptor in classDescriptor.children) {
val methodResults = mutableListOf<PreviewResult>()
if (methodDescriptor is TestMethodTestDescriptor) {
val className: String = methodDescriptor.className
val methodName: String = methodDescriptor.methodName
val screenshots =
screenshotResults.filter {
getResultIdWithoutSuffix(it.resultId) == "$className.${methodName}"
}
methodResults.add(reportResult(listener, screenshots.single(), methodDescriptor, "${className}.${methodName}"))
} else if (methodDescriptor is TestMethodDescriptor) {
listener.executionStarted(methodDescriptor)
val className: String = methodDescriptor.className
val methodName: String = methodDescriptor.methodName
val screenshots =
screenshotResults.filter {
getResultIdWithoutSuffix(it.resultId) == "$className.${methodName}"
}
for ((run, screenshot) in screenshots.withIndex()) {
val currentComposePreview = composeScreenshots.single() {
it.methodFQN == "$className.$methodName" && screenshot.imagePath!!.contains(it.imageName)
}
var suffix = ""
if (currentComposePreview.previewParams.isNotEmpty()) {
suffix += "_${currentComposePreview.previewParams}"
}
if (currentComposePreview.methodParams.isNotEmpty()) {
// Method parameters can generate multiple screenshots from one preview,
// add the method parameters and the count indicated by the resultId
suffix += "_${currentComposePreview.methodParams}_${screenshot.resultId.split("_").last()}"
}
val previewTestDescriptor = PreviewTestDescriptor(methodDescriptor, methodName, run, suffix)
methodDescriptor.addChild(previewTestDescriptor)
listener.dynamicTestRegistered(previewTestDescriptor)
listener.executionStarted(previewTestDescriptor)
methodResults.add(reportResult(listener, screenshot, previewTestDescriptor,"${className}.${methodName}$suffix"))
}
listener.executionFinished(methodDescriptor, TestExecutionResult.successful())
}
resultsToSave.addAll(methodResults)
}
listener.executionFinished(classDescriptor, TestExecutionResult.successful())
}
if (resultsToSave.isNotEmpty()) {
saveResults(resultsToSave, "${getParam("resultsDirPath")}/TEST-results.xml")
}
}
private fun compareImages(composeScreenshot: ComposeScreenshotResult, testDisplayName: String, startTime: Long): PreviewResult {
// TODO(b/296430073) Support custom image difference threshold from DSL or task argument
val imageDiffer = ImageDiffer.MSSIMMatcher()
val screenshotName = composeScreenshot.resultId
val screenshotNamePng = "$screenshotName.png"
var referencePath = File(getParam("referenceImageDirPath")).toPath().resolve(screenshotNamePng)
var referenceMessage: String? = null
val actualPath = File(composeScreenshot.imagePath).toPath()
var diffPath = File(getParam("diffImageDirPath")).toPath().resolve(screenshotNamePng)
var diffMessage: String? = null
var code = 0
val verifier = Verify(imageDiffer, diffPath)
//If the CLI tool could not render the preview, return the preview result with the
//code and message along with reference path if it exists
if (!actualPath.toFile().exists()) {
if (!referencePath.toFile().exists()) {
referencePath = null
referenceMessage = "Reference image missing"
}
return PreviewResult(1,
composeScreenshot.resultId,
getDurationInSeconds(startTime),
"Image render failed",
referenceImage = ImageDetails(referencePath, referenceMessage),
actualImage = ImageDetails(null, "Image render failed")
)
}
val result =
verifier.assertMatchReference(
referencePath,
ImageIO.read(actualPath.toFile())
)
when (result) {
is Verify.AnalysisResult.Failed -> {
code = 1
}
is Verify.AnalysisResult.Passed -> {
if (result.imageDiff.highlights == null) {
diffPath = null
diffMessage = "Images match!"
}
}
is Verify.AnalysisResult.MissingReference -> {
referencePath = null
diffPath = null
referenceMessage = "Reference image missing"
diffMessage = "No diff available"
code = 1
}
is Verify.AnalysisResult.SizeMismatch -> {
diffMessage = result.message
diffPath = null
code = 1
}
}
return result.toPreviewResponse(code, testDisplayName,
getDurationInSeconds(startTime),
ImageDetails(referencePath, referenceMessage),
ImageDetails(actualPath, null),
ImageDetails(diffPath, diffMessage)
)
}
private fun getParam(key: String): String {
return System.getProperty("com.android.tools.preview.screenshot.junit.engine.${key}")
}
private fun getDurationInSeconds(startTimeMillis: Long): Float {
return (System.currentTimeMillis() - startTimeMillis) / 1000F
}
private fun reportResult(listener: EngineExecutionListener, screenshot: ComposeScreenshotResult, testDescriptor: TestDescriptor, testDisplayName: String): PreviewResult {
val startTime = System.currentTimeMillis()
listener.executionStarted(testDescriptor)
val imageComparison = compareImages(screenshot, testDisplayName, startTime)
val result = if (imageComparison.responseCode != 0) {
TestExecutionResult.failed(AssertionError(imageComparison.message))
} else TestExecutionResult.successful()
listener.executionFinished(testDescriptor, result)
return imageComparison
}
private fun getResultIdWithoutSuffix(resultId: String): String {
return resultId.substringBeforeLast('_').substringBeforeLast('_').substringBeforeLast('_')
}
}