|
| 1 | +/* |
| 2 | + * Copyright (c) 2026 Composable Horizons |
| 3 | + * |
| 4 | + * Permission is hereby granted, free of charge, to any person obtaining a copy |
| 5 | + * of this software and associated documentation files (the "Software"), to deal |
| 6 | + * in the Software without restriction, including without limitation the rights |
| 7 | + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 8 | + * copies of the Software, and to permit persons to whom the Software is |
| 9 | + * furnished to do so, subject to the following conditions: |
| 10 | + * |
| 11 | + * The above copyright notice and this permission notice shall be included in all |
| 12 | + * copies or substantial portions of the Software. |
| 13 | + * |
| 14 | + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 15 | + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 16 | + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 17 | + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 18 | + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 19 | + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 20 | + * SOFTWARE. |
| 21 | + */ |
| 22 | +package com.composeunstyled.demo |
| 23 | + |
| 24 | +import androidx.compose.foundation.layout.Box |
| 25 | +import androidx.compose.foundation.layout.requiredSize |
| 26 | +import androidx.compose.ui.Modifier |
| 27 | +import androidx.compose.ui.graphics.toAwtImage |
| 28 | +import androidx.compose.ui.platform.testTag |
| 29 | +import androidx.compose.ui.test.ComposeUiTest |
| 30 | +import androidx.compose.ui.test.ExperimentalTestApi |
| 31 | +import androidx.compose.ui.test.captureToImage |
| 32 | +import androidx.compose.ui.test.onNodeWithTag |
| 33 | +import androidx.compose.ui.test.runComposeUiTest |
| 34 | +import androidx.compose.ui.unit.dp |
| 35 | +import java.awt.image.BufferedImage |
| 36 | +import java.io.File |
| 37 | +import javax.imageio.ImageIO |
| 38 | +import kotlin.test.assertEquals |
| 39 | +import kotlin.test.assertTrue |
| 40 | +import kotlin.test.fail |
| 41 | + |
| 42 | +data class DemoScreenshot( |
| 43 | + val name: String, |
| 44 | + val startDestination: String, |
| 45 | +) |
| 46 | + |
| 47 | +val BottomSheetDemoScreenshot = DemoScreenshot( |
| 48 | + name = "bottom-sheet-demo", |
| 49 | + startDestination = "bottom-sheet", |
| 50 | +) |
| 51 | + |
| 52 | +val ModalBottomSheetDemoScreenshot = DemoScreenshot( |
| 53 | + name = "modal-bottom-sheet-demo", |
| 54 | + startDestination = "modal-bottom-sheet", |
| 55 | +) |
| 56 | + |
| 57 | +val DemoScreenshots = listOf( |
| 58 | + BottomSheetDemoScreenshot, |
| 59 | + ModalBottomSheetDemoScreenshot, |
| 60 | +) |
| 61 | + |
| 62 | +@OptIn(ExperimentalTestApi::class) |
| 63 | +fun assertDemoScreenshotMatches(screenshot: DemoScreenshot) = runComposeUiTest { |
| 64 | + val actual = captureDemoScreenshot(screenshot) |
| 65 | + |
| 66 | + val reportDir = File("build/reports/screenshot-tests") |
| 67 | + reportDir.mkdirs() |
| 68 | + ImageIO.write(actual, "png", File(reportDir, "${screenshot.name}.actual.png")) |
| 69 | + |
| 70 | + val expected = javaClass.classLoader |
| 71 | + .getResourceAsStream("screenshots/${screenshot.name}.png") |
| 72 | + ?.use(ImageIO::read) |
| 73 | + ?: fail("Missing expected screenshot. Run `./gradlew :demo:takeScreenshots`.") |
| 74 | + |
| 75 | + assertEquals(expected.width, actual.width, "Screenshot width changed.") |
| 76 | + assertEquals(expected.height, actual.height, "Screenshot height changed.") |
| 77 | + |
| 78 | + val diff = diff(expected, actual) |
| 79 | + if (diff.changedPixels > 0) { |
| 80 | + ImageIO.write(diff.image, "png", File(reportDir, "${screenshot.name}.diff.png")) |
| 81 | + } |
| 82 | + assertTrue( |
| 83 | + actual = diff.changedPixels == 0, |
| 84 | + message = "Screenshot changed by ${diff.changedPixels} pixels. " + |
| 85 | + "See ${reportDir.path}/${screenshot.name}.actual.png and " + |
| 86 | + "${reportDir.path}/${screenshot.name}.diff.png.", |
| 87 | + ) |
| 88 | +} |
| 89 | + |
| 90 | +@OptIn(ExperimentalTestApi::class) |
| 91 | +fun updateDemoScreenshot(screenshot: DemoScreenshot) = runComposeUiTest { |
| 92 | + val actual = captureDemoScreenshot(screenshot) |
| 93 | + val expectedFile = File("src/jvmTest/resources/screenshots/${screenshot.name}.png") |
| 94 | + expectedFile.parentFile.mkdirs() |
| 95 | + ImageIO.write(actual, "png", expectedFile) |
| 96 | +} |
| 97 | + |
| 98 | +@OptIn(ExperimentalTestApi::class) |
| 99 | +private fun ComposeUiTest.captureDemoScreenshot(screenshot: DemoScreenshot): BufferedImage { |
| 100 | + setContent { |
| 101 | + Box( |
| 102 | + modifier = Modifier |
| 103 | + .requiredSize(width = ScreenshotWidth.dp, height = ScreenshotHeight.dp) |
| 104 | + .testTag(ScreenshotTargetTag), |
| 105 | + ) { |
| 106 | + Demo(startDestination = screenshot.startDestination) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + waitForIdle() |
| 111 | + |
| 112 | + return onNodeWithTag(ScreenshotTargetTag).captureToImage().toAwtImage() |
| 113 | +} |
| 114 | + |
| 115 | +private fun diff(expected: BufferedImage, actual: BufferedImage): ScreenshotDiff { |
| 116 | + val diff = BufferedImage(expected.width, expected.height, BufferedImage.TYPE_INT_ARGB) |
| 117 | + var changedPixels = 0 |
| 118 | + |
| 119 | + for (y in 0 until expected.height) { |
| 120 | + for (x in 0 until expected.width) { |
| 121 | + val expectedRgb = expected.getRGB(x, y) |
| 122 | + val actualRgb = actual.getRGB(x, y) |
| 123 | + if (expectedRgb == actualRgb) { |
| 124 | + diff.setRGB(x, y, expectedRgb) |
| 125 | + } else { |
| 126 | + changedPixels++ |
| 127 | + diff.setRGB(x, y, DiffColor) |
| 128 | + } |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + return ScreenshotDiff(diff, changedPixels) |
| 133 | +} |
| 134 | + |
| 135 | +private data class ScreenshotDiff( |
| 136 | + val image: BufferedImage, |
| 137 | + val changedPixels: Int, |
| 138 | +) |
| 139 | + |
| 140 | +private const val ScreenshotTargetTag = "screenshot-target" |
| 141 | +private const val ScreenshotWidth = 1024 |
| 142 | +private const val ScreenshotHeight = 600 |
| 143 | +private const val DiffColor = 0xFFFF00FF.toInt() |
0 commit comments