[go: nahoru, domu]

blob: 006659632b0946d540706ab3df331ac12e239d35 [file] [log] [blame]
package androidx.ui.desktop.test
import androidx.compose.ChoreographerFrameCallback
import androidx.compose.EmbeddingContext
import androidx.compose.EmbeddingContextFactory
import kotlinx.coroutines.Dispatchers
import org.jetbrains.skija.Surface
import java.io.File
import java.security.MessageDigest
import org.junit.rules.TestRule
import org.junit.runners.model.Statement
import org.junit.runner.Description
import java.util.LinkedList
// TODO: replace with androidx.test.screenshot.proto.ScreenshotResultProto after MPP
data class ScreenshotResultProto(
val result: Status,
val comparisonStatistics: String,
val repoRootPath: String,
val locationOfGoldenInRepo: String,
val currentScreenshotFileName: String,
val diffImageFileName: String?,
val expectedImageFileName: String
) {
enum class Status {
UNSPECIFIED,
PASSED,
FAILED,
MISSING_GOLDEN,
SIZE_MISMATCH
}
}
data class GoldenConfig(
val fsGoldenPath: String,
val repoGoldenPath: String,
val modulePrefix: String
)
class SkijaTestAlbum(val config: GoldenConfig) {
data class Report(val screenshots: Map<String, ScreenshotResultProto>)
private val screenshots: MutableMap<String, ScreenshotResultProto> = mutableMapOf()
private val report = Report(screenshots)
fun snap(surface: Surface, id: String) {
if (!id.matches("^[A-Za-z0-9_-]+$".toRegex())) {
throw IllegalArgumentException(
"The given golden identifier '$id' does not satisfy the naming " +
"requirement. Allowed characters are: '[A-Za-z0-9_-]'")
}
val actual = surface.makeImageSnapshot().encodeToData().bytes
val expected = readExpectedImage(id)
if (expected == null) {
reportResult(
status = ScreenshotResultProto.Status.MISSING_GOLDEN,
id = id,
actual = actual
)
return
}
val status = if (compareImages(actual = actual, expected = expected)) {
ScreenshotResultProto.Status.PASSED
} else {
ScreenshotResultProto.Status.FAILED
}
reportResult(
status = status,
id = id,
actual = actual
)
}
fun check(): Report {
return report
}
private fun dumpImage(path: String, data: ByteArray) {
val file = File(config.fsGoldenPath, path)
file.writeBytes(data)
}
private val imageExtension = ".png"
private fun inModuleImagePath(id: String, suffix: String? = null) =
if (suffix == null) {
"${config.modulePrefix}/$id$imageExtension"
} else {
"${config.modulePrefix}/${id}_$suffix$imageExtension"
}
private fun readExpectedImage(id: String): ByteArray? {
val file = File(config.fsGoldenPath, inModuleImagePath(id))
if (!file.exists()) {
return null
}
return file.inputStream().readBytes()
}
private fun calcHash(input: ByteArray): ByteArray {
return MessageDigest
.getInstance("SHA-256")
.digest(input)
}
// TODO: switch to androidx.test.screenshot.matchers.BitmapMatcher#compareBitmaps
private fun compareImages(actual: ByteArray, expected: ByteArray): Boolean {
return calcHash(actual).contentEquals(calcHash(expected))
}
private fun ensureDir() {
File(config.fsGoldenPath, config.modulePrefix).mkdirs()
}
private fun reportResult(
status: ScreenshotResultProto.Status,
id: String,
actual: ByteArray,
comparisonStatistics: String? = null
) {
val currentScreenshotFileName: String
if (status != ScreenshotResultProto.Status.PASSED) {
currentScreenshotFileName = inModuleImagePath(id, "actual")
ensureDir()
dumpImage(currentScreenshotFileName, actual)
} else {
currentScreenshotFileName = inModuleImagePath(id)
}
screenshots[id] = ScreenshotResultProto(
result = status,
comparisonStatistics = comparisonStatistics.orEmpty(),
repoRootPath = config.repoGoldenPath,
locationOfGoldenInRepo = inModuleImagePath(id),
currentScreenshotFileName = currentScreenshotFileName,
expectedImageFileName = inModuleImagePath(id),
diffImageFileName = null
)
}
}
fun DesktopScreenshotTestRule(
modulePath: String,
fsGoldenPath: String = System.getProperty("GOLDEN_PATH"),
repoGoldenPath: String = "platform/frameworks/support-golden"
): ScreenshotTestRule {
return ScreenshotTestRule(GoldenConfig(fsGoldenPath, repoGoldenPath, modulePath))
}
class ScreenshotTestRule internal constructor(val config: GoldenConfig) : TestRule,
EmbeddingContext {
private lateinit var testIdentifier: String
private lateinit var album: SkijaTestAlbum
val executionQueue = LinkedList<() -> Unit>()
override fun apply(base: Statement, description: Description?): Statement {
return object : Statement() {
override fun evaluate() {
EmbeddingContextFactory = fun() = this@ScreenshotTestRule
album = SkijaTestAlbum(config)
testIdentifier = "${description!!.className}_${description.methodName}".replace("" +
".", "_")
base.evaluate()
runExecutionQueue()
handleReport(album.check())
}
}
}
private fun runExecutionQueue() {
while (executionQueue.isNotEmpty()) {
executionQueue.removeFirst()()
}
}
fun snap(surface: Surface, idSuffix: String? = null) {
val id = testIdentifier + if (idSuffix != null) "_$idSuffix" else ""
album.snap(surface, id)
}
private fun handleReport(report: SkijaTestAlbum.Report) {
report.screenshots.forEach { (_, sReport) ->
when (sReport.result) {
ScreenshotResultProto.Status.PASSED -> {
}
ScreenshotResultProto.Status.MISSING_GOLDEN ->
throw AssertionError(
"Missing golden image " +
"'${sReport.locationOfGoldenInRepo}'. " +
"Did you mean to check in a new image?"
)
else ->
throw AssertionError("Image mismatch! Expected image ${sReport
.expectedImageFileName}, actual: ${sReport.currentScreenshotFileName}. FS" +
" location: ${config.fsGoldenPath}")
}
}
}
override fun isMainThread() = true
override fun mainThreadCompositionContext() = Dispatchers.Main
override fun postOnMainThread(block: () -> Unit) {
executionQueue.add(block)
}
private val cancelled = mutableSetOf<ChoreographerFrameCallback>()
override fun postFrameCallback(callback: ChoreographerFrameCallback) {
postOnMainThread {
if (callback !in cancelled) {
callback.doFrame(System.currentTimeMillis() * 1000000)
} else {
cancelled.remove(callback)
}
}
}
override fun cancelFrameCallback(callback: ChoreographerFrameCallback) {
cancelled += callback
}
}