Skip to content

Commit 4e33714

Browse files
committed
Add modal bottom sheet demo screenshot test
1 parent 7e6688b commit 4e33714

7 files changed

Lines changed: 233 additions & 113 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Before pushing changes that touch Kotlin (`.kt`) files, you must run `jvmTest` a
77
- Add tests for shared Kotlin/Compose behavior in `commonTest` so they run on both JVM and Android targets. Use platform-specific test source sets only when the behavior is platform-specific.
88
- Put touch-specific tests in the Android test source set. Put mouse-specific tests in the JVM test source set.
99
- When creating a library module with an Android target, add the Android instrumented test dependencies required for Compose UI tests: `libs.androidx.compose.test`, `libs.androidx.compose.test.manifest`, and `libs.androidx.espresso`.
10-
- Screenshot tests must only compare screenshots and write failure artifacts under `build/`; they must not update checked-in baselines while running tests. When screenshot baselines need to be updated, use the dedicated screenshot update task, such as `./gradlew :demo:updateDesktopScreenshots`.
10+
- Screenshot tests must only compare screenshots and write failure artifacts under `build/`; they must not update checked-in baselines while running tests. When screenshot baselines need to be updated, use the dedicated screenshot task, such as `./gradlew :demo:takeScreenshots`.
1111

1212
## Recomposition testing
1313

demo/build.gradle.kts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ kotlin {
8181
}
8282

8383

84-
jvm()
84+
jvm {
85+
val mainCompilation = compilations.getByName("main")
86+
val screenshot by compilations.creating {
87+
associateWith(mainCompilation)
88+
}
89+
}
8590

8691
androidTarget {
8792
compilerOptions {
@@ -145,11 +150,25 @@ kotlin {
145150
}
146151
}
147152

148-
jvmTest.dependencies {
149-
implementation(kotlin("test"))
150-
implementation(libs.compose.ui.test.junit4)
153+
val jvmScreenshotSharedDir = "src/jvmScreenshotShared/kotlin"
154+
155+
val jvmScreenshot by getting {
156+
kotlin.srcDir(jvmScreenshotSharedDir)
157+
dependencies {
158+
implementation(kotlin("test"))
159+
implementation(libs.compose.ui.test.junit4)
160+
}
161+
}
162+
163+
jvmTest {
164+
kotlin.srcDir(jvmScreenshotSharedDir)
165+
dependencies {
166+
implementation(kotlin("test"))
167+
implementation(libs.compose.ui.test.junit4)
168+
}
151169
}
152170

171+
153172
val androidMain by getting {
154173
dependencies {
155174
implementation(libs.androidx.activitycompose)
@@ -182,19 +201,19 @@ androidComponents {
182201
}
183202
}
184203

185-
val jvmTestCompilation = kotlin.targets
204+
val jvmScreenshotCompilation = kotlin.targets
186205
.getByName("jvm")
187206
.compilations
188-
.getByName("test")
207+
.getByName("screenshot")
189208

190-
tasks.register<JavaExec>("updateDesktopScreenshots") {
209+
tasks.register<JavaExec>("takeScreenshots") {
191210
group = "verification"
192211
description = "Updates desktop screenshot test baselines."
193-
dependsOn("jvmTestClasses")
194-
mainClass.set("com.composeunstyled.demo.BottomSheetDemoScreenshotTestKt")
212+
dependsOn(jvmScreenshotCompilation.compileTaskProvider)
213+
mainClass.set("com.composeunstyled.demo.DemoScreenshotBaselinesKt")
195214
classpath = files(
196-
jvmTestCompilation.output.allOutputs,
197-
jvmTestCompilation.runtimeDependencyFiles,
215+
jvmScreenshotCompilation.output.allOutputs,
216+
jvmScreenshotCompilation.runtimeDependencyFiles,
198217
)
199218
workingDir = projectDir
200219
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
fun main() {
25+
DemoScreenshots.forEach(::updateDemoScreenshot)
26+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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()

demo/src/jvmTest/kotlin/com/composeunstyled/demo/BottomSheetDemoScreenshotTest.kt

Lines changed: 2 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -21,110 +21,11 @@
2121
*/
2222
package com.composeunstyled.demo
2323

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
3824
import kotlin.test.Test
39-
import kotlin.test.assertEquals
40-
import kotlin.test.assertTrue
41-
import kotlin.test.fail
4225

43-
@OptIn(ExperimentalTestApi::class)
4426
class BottomSheetDemoScreenshotTest {
4527
@Test
46-
fun bottomSheetDemoMatchesScreenshot() = runComposeUiTest {
47-
val actual = captureBottomSheetDemoScreenshot()
48-
49-
val reportDir = File("build/reports/screenshot-tests")
50-
reportDir.mkdirs()
51-
ImageIO.write(actual, "png", File(reportDir, "$ScreenshotName.actual.png"))
52-
53-
val expected = javaClass.classLoader
54-
.getResourceAsStream("screenshots/$ScreenshotName.png")
55-
?.use(ImageIO::read)
56-
?: fail(
57-
"Missing expected screenshot. Run `./gradlew :demo:updateDesktopScreenshots`.",
58-
)
59-
60-
assertEquals(expected.width, actual.width, "Screenshot width changed.")
61-
assertEquals(expected.height, actual.height, "Screenshot height changed.")
62-
63-
val diff = diff(expected, actual)
64-
if (diff.changedPixels > 0) {
65-
ImageIO.write(diff.image, "png", File(reportDir, "$ScreenshotName.diff.png"))
66-
}
67-
assertTrue(
68-
actual = diff.changedPixels == 0,
69-
message = "Screenshot changed by ${diff.changedPixels} pixels. " +
70-
"See ${reportDir.path}/$ScreenshotName.actual.png and " +
71-
"${reportDir.path}/$ScreenshotName.diff.png.",
72-
)
28+
fun bottomSheetDemoMatchesScreenshot() {
29+
assertDemoScreenshotMatches(BottomSheetDemoScreenshot)
7330
}
74-
75-
private fun diff(expected: BufferedImage, actual: BufferedImage): ScreenshotDiff {
76-
val diff = BufferedImage(expected.width, expected.height, BufferedImage.TYPE_INT_ARGB)
77-
var changedPixels = 0
78-
79-
for (y in 0 until expected.height) {
80-
for (x in 0 until expected.width) {
81-
val expectedRgb = expected.getRGB(x, y)
82-
val actualRgb = actual.getRGB(x, y)
83-
if (expectedRgb == actualRgb) {
84-
diff.setRGB(x, y, expectedRgb)
85-
} else {
86-
changedPixels++
87-
diff.setRGB(x, y, DiffColor)
88-
}
89-
}
90-
}
91-
92-
return ScreenshotDiff(diff, changedPixels)
93-
}
94-
95-
private data class ScreenshotDiff(
96-
val image: BufferedImage,
97-
val changedPixels: Int,
98-
)
9931
}
100-
101-
@OptIn(ExperimentalTestApi::class)
102-
private fun ComposeUiTest.captureBottomSheetDemoScreenshot(): BufferedImage {
103-
setContent {
104-
Box(
105-
modifier = Modifier
106-
.requiredSize(width = ScreenshotWidth.dp, height = ScreenshotHeight.dp)
107-
.testTag(ScreenshotTargetTag),
108-
) {
109-
Demo(startDestination = "bottom-sheet")
110-
}
111-
}
112-
113-
waitForIdle()
114-
115-
return onNodeWithTag(ScreenshotTargetTag).captureToImage().toAwtImage()
116-
}
117-
118-
@OptIn(ExperimentalTestApi::class)
119-
fun main() = runComposeUiTest {
120-
val actual = captureBottomSheetDemoScreenshot()
121-
val expectedFile = File("src/jvmTest/resources/screenshots/$ScreenshotName.png")
122-
expectedFile.parentFile.mkdirs()
123-
ImageIO.write(actual, "png", expectedFile)
124-
}
125-
126-
private const val ScreenshotName = "bottom-sheet-demo"
127-
private const val ScreenshotTargetTag = "screenshot-target"
128-
private const val ScreenshotWidth = 1024
129-
private const val ScreenshotHeight = 600
130-
private const val DiffColor = 0xFFFF00FF.toInt()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 kotlin.test.Test
25+
26+
class ModalBottomSheetDemoScreenshotTest {
27+
@Test
28+
fun modalBottomSheetDemoMatchesScreenshot() {
29+
assertDemoScreenshotMatches(ModalBottomSheetDemoScreenshot)
30+
}
31+
}
5.14 KB
Loading

0 commit comments

Comments
 (0)