All Downloads are FREE. Search and download functionalities are using the official Maven repository.

desktopMain.androidx.compose.ui.test.junit4.SkiaTest.desktop.kt Maven / Gradle / Ivy

/*
 * Copyright 2020 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 androidx.compose.ui.test.junit4

import androidx.compose.ui.test.InternalTestApi
import java.io.File
import java.security.MessageDigest
import java.util.LinkedList
import org.jetbrains.skia.Image
import org.jetbrains.skia.Surface
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

// TODO(https://github.com/JetBrains/compose-jb/issues/1041): refactor API

// TODO: replace with androidx.test.screenshot.proto.ScreenshotResultProto after MPP
@InternalTestApi
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
    }
}

@InternalTestApi
data class GoldenConfig(
    val fsGoldenPath: String,
    val repoGoldenPath: String,
    val modulePrefix: String
)

@InternalTestApi
class SkiaTestAlbum(val config: GoldenConfig) {
    data class Report(val screenshots: Map)

    private val screenshots: MutableMap = mutableMapOf()
    private val report = Report(screenshots)
    fun snap(surface: Surface, id: String) {
        write(surface.makeImageSnapshot(), id)
    }

    fun write(image: Image, 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 = image.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
        )
    }
}

@InternalTestApi
fun DesktopScreenshotTestRule(
    modulePath: String,
    fsGoldenPath: String = System.getProperty("GOLDEN_PATH"),
    repoGoldenPath: String = "platform/frameworks/support-golden"
): ScreenshotTestRule {
    return ScreenshotTestRule(GoldenConfig(fsGoldenPath, repoGoldenPath, modulePath))
}

@InternalTestApi
class ScreenshotTestRule internal constructor(val config: GoldenConfig) : TestRule {
    private lateinit var testIdentifier: String
    private lateinit var album: SkiaTestAlbum

    val executionQueue = LinkedList<() -> Unit>()

    override fun apply(base: Statement, description: Description?): Statement {
        return object : Statement() {
            override fun evaluate() {
                album = SkiaTestAlbum(config)
                testIdentifier = "${description!!.className}_${description.methodName}"
                    .replace(".", "_").replace(",", "_").replace(" ", "_").replace("__", "_")
                base.evaluate()
                runExecutionQueue()
                handleReport(album.check())
            }
        }
    }

    private fun runExecutionQueue() {
        while (executionQueue.isNotEmpty()) {
            executionQueue.removeFirst()()
        }
    }

    fun snap(surface: Surface, idSuffix: String? = null) {
        write(surface.makeImageSnapshot(), idSuffix)
    }

    fun write(image: Image, idSuffix: String? = null) {
        val id = testIdentifier + if (idSuffix != null) "_$idSuffix" else ""
        album.write(image, id)
    }

    private fun handleReport(report: SkiaTestAlbum.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}"
                    )
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy