Skip to content

Commit 7e6688b

Browse files
committed
Add bottom sheet demo screenshot test
1 parent 6d98995 commit 7e6688b

8 files changed

Lines changed: 198 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
set -euo pipefail
2424
2525
test_modules="$(
26-
find . \( -path './*/src/commonTest' -o -path './*/src/jvmTest' -o -path './*/src/androidInstrumentedTest' \) -type d |
26+
find . \( -path './*/src/commonTest' -o -path './*/src/jvmTest' -o -path './*/src/desktopTest' -o -path './*/src/androidInstrumentedTest' \) -type d |
2727
awk -F/ '{ print ":" $2 }' |
2828
sort -u
2929
)"
@@ -74,7 +74,7 @@ jobs:
7474
set -euo pipefail
7575
7676
test_modules="$(
77-
find . \( -path './*/src/commonTest' -o -path './*/src/jvmTest' -o -path './*/src/androidInstrumentedTest' \) -type d |
77+
find . \( -path './*/src/commonTest' -o -path './*/src/jvmTest' -o -path './*/src/desktopTest' -o -path './*/src/androidInstrumentedTest' \) -type d |
7878
awk -F/ '{ print ":" $2 }' |
7979
sort -u
8080
)"
@@ -107,15 +107,37 @@ jobs:
107107
)"
108108
109109
tasks=(spotlessCheck)
110+
all_tasks=""
111+
112+
load_all_tasks() {
113+
if [ -z "$all_tasks" ]; then
114+
all_tasks="$(./gradlew --console=plain tasks --all)"
115+
fi
116+
}
110117
111118
while IFS= read -r module; do
112119
if [ -n "$module" ]; then
113-
tasks+=("$module:jvmTest")
120+
load_all_tasks
121+
module_name="${module#:}"
122+
module_test_tasks="$(
123+
printf '%s\n' "$all_tasks" |
124+
awk -v module="$module_name" '$0 ~ "^" module ":(jvmTest|desktopTest)( |$)" { print ":" $1 }' |
125+
sort -u
126+
)"
127+
128+
if [ -z "$module_test_tasks" ]; then
129+
echo "No JVM/Desktop test tasks found for $module."
130+
exit 1
131+
fi
132+
133+
while IFS= read -r task; do
134+
tasks+=("$task")
135+
done <<< "$module_test_tasks"
114136
fi
115137
done <<< "$jvm_modules"
116138
117139
if [ -n "$compile_modules" ]; then
118-
all_tasks="$(./gradlew --console=plain tasks --all)"
140+
load_all_tasks
119141
while IFS= read -r module; do
120142
if [ -z "$module" ]; then
121143
continue

.github/workflows/nightly-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
uses: gradle/actions/setup-gradle@v5
3434

3535
- name: JVM Tests and Assemble
36-
run: ./gradlew --console=plain spotlessCheck jvmTest assemble
36+
run: ./gradlew --console=plain spotlessCheck jvmTest :demo:jvmTest assemble
3737

3838
discover-android-modules:
3939
runs-on: ubuntu-latest

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
uses: gradle/actions/setup-gradle@v5
5353

5454
- name: Desktop Tests
55-
run: ./gradlew --console=plain jvmTest
55+
run: ./gradlew --console=plain jvmTest :demo:jvmTest
5656

5757
discover-android-modules:
5858
runs-on: ubuntu-latest

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +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`.
1011

1112
## Recomposition testing
1213

demo/build.gradle.kts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ kotlin {
145145
}
146146
}
147147

148+
jvmTest.dependencies {
149+
implementation(kotlin("test"))
150+
implementation(libs.compose.ui.test.junit4)
151+
}
152+
148153
val androidMain by getting {
149154
dependencies {
150155
implementation(libs.androidx.activitycompose)
@@ -176,3 +181,20 @@ androidComponents {
176181
variantBuilder.enable = false
177182
}
178183
}
184+
185+
val jvmTestCompilation = kotlin.targets
186+
.getByName("jvm")
187+
.compilations
188+
.getByName("test")
189+
190+
tasks.register<JavaExec>("updateDesktopScreenshots") {
191+
group = "verification"
192+
description = "Updates desktop screenshot test baselines."
193+
dependsOn("jvmTestClasses")
194+
mainClass.set("com.composeunstyled.demo.BottomSheetDemoScreenshotTestKt")
195+
classpath = files(
196+
jvmTestCompilation.output.allOutputs,
197+
jvmTestCompilation.runtimeDependencyFiles,
198+
)
199+
workingDir = projectDir
200+
}

demo/src/commonMain/kotlin/Demo.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Arrangement
2828
import androidx.compose.foundation.layout.Box
2929
import androidx.compose.foundation.layout.Column
3030
import androidx.compose.foundation.layout.ColumnScope
31+
import androidx.compose.foundation.layout.PaddingValues
3132
import androidx.compose.foundation.layout.Row
3233
import androidx.compose.foundation.layout.Spacer
3334
import androidx.compose.foundation.layout.WindowInsets
@@ -67,9 +68,9 @@ import com.composeunstyled.UnstyledIcon
6768
import com.composeunstyled.currentWindowContainerSize
6869

6970
@Composable
70-
fun Demo() {
71+
fun Demo(startDestination: String = "home") {
7172
Box(Modifier.fillMaxSize().background(Color(0xFFFAFAFA))) {
72-
DemoSelection()
73+
DemoSelection(startDestination)
7374
}
7475
}
7576

@@ -82,10 +83,16 @@ private data class DemoItem(
8283

8384
private data class PreviewOptions(
8485
val contentAlignment: Alignment = Alignment.Center,
86+
val padding: PaddingValues = PaddingValues(16.dp),
8587
)
8688

8789
private val availablePrimitives = listOf(
88-
DemoItem("Bottom Sheet", "bottom-sheet", { BottomSheetDemo() }),
90+
DemoItem(
91+
"Bottom Sheet",
92+
"bottom-sheet",
93+
{ BottomSheetDemo() },
94+
previewOptions = PreviewOptions(padding = PaddingValues(0.dp)),
95+
),
8996
DemoItem("Bottom Sheet (Modal)", "modal-bottom-sheet", { ModalBottomSheetDemo() }),
9097
DemoItem("Button", "button", { ButtonDemo() }),
9198
DemoItem("Checkbox", "checkbox", { CheckboxDemo() }),
@@ -156,12 +163,12 @@ fun ModifierDemo(content: @Composable () -> Unit) {
156163
}
157164

158165
@Composable
159-
private fun DemoSelection() {
166+
private fun DemoSelection(startDestination: String) {
160167
val navController = rememberNavController()
161168

162169
NavHost(
163170
navController = navController,
164-
startDestination = "home",
171+
startDestination = startDestination,
165172
enterTransition = {
166173
EnterTransition.None
167174
},
@@ -217,8 +224,11 @@ private fun DemoSelection() {
217224

218225
availableDemos.forEach { component ->
219226
composable(component.id) {
227+
val launchedFromDemoList = startDestination == "home"
220228
Column {
221-
AppBar(onUpClick = { navController.navigateUp() }, title = component.name)
229+
if (launchedFromDemoList) {
230+
AppBar(onUpClick = { navController.navigateUp() }, title = component.name)
231+
}
222232
DemoContainer(component.previewOptions) {
223233
component.demo()
224234
}
@@ -238,7 +248,7 @@ private fun ColumnScope.DemoContainer(
238248
.fillMaxWidth()
239249
.weight(1f)
240250
.background(Color.White)
241-
.padding(16.dp),
251+
.padding(previewOptions.padding),
242252
contentAlignment = previewOptions.contentAlignment,
243253
) {
244254
content()
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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.Test
39+
import kotlin.test.assertEquals
40+
import kotlin.test.assertTrue
41+
import kotlin.test.fail
42+
43+
@OptIn(ExperimentalTestApi::class)
44+
class BottomSheetDemoScreenshotTest {
45+
@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+
)
73+
}
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+
)
99+
}
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()
6.48 KB
Loading

0 commit comments

Comments
 (0)