Skip to content

Commit 4ddecd6

Browse files
committed
Add StoreScreenshotTest to generate store screenshots programmatically
1 parent 8953aba commit 4ddecd6

File tree

3 files changed

+268
-0
lines changed

3 files changed

+268
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (c) 2025 Olivier Patry
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the Software
9+
* is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
package net.opatry.tasks.app.test
24+
25+
import androidx.test.platform.app.InstrumentationRegistry
26+
import androidx.test.uiautomator.UiDevice
27+
import org.junit.rules.TestWatcher
28+
import org.junit.runner.Description
29+
import java.io.File
30+
31+
@Retention(AnnotationRetention.RUNTIME)
32+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
33+
annotation class NoScreenshot
34+
35+
private fun defaultScreenshotDir() = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "test_failed_screenshots")
36+
37+
class ScreenshotOnFailureRule(private val screenshotsDir: File = defaultScreenshotDir()) : TestWatcher() {
38+
private val Description.allowScreenshot: Boolean
39+
get() = getAnnotation(NoScreenshot::class.java) == null
40+
41+
override fun failed(e: Throwable?, description: Description?) {
42+
description?.let { testDescription ->
43+
if (testDescription.allowScreenshot) {
44+
try {
45+
takeScreenshot(testDescription)
46+
} catch (_: Exception) {
47+
// ignore screenshot processing errors
48+
}
49+
}
50+
}
51+
super.failed(e, description)
52+
}
53+
54+
private fun takeScreenshot(testDescription: Description) {
55+
val fileName = testDescription.displayName.take(150)
56+
val outputFile = File(screenshotsDir, "$fileName.png").also {
57+
it.parentFile?.mkdirs()
58+
}
59+
if (outputFile.exists()) {
60+
outputFile.delete()
61+
}
62+
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
63+
.takeScreenshot(outputFile)
64+
}
65+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright (c) 2025 Olivier Patry
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the Software
9+
* is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
package net.opatry.tasks.app.test.screenshot
24+
25+
import android.content.Context
26+
import androidx.appcompat.app.AppCompatDelegate
27+
import androidx.compose.ui.test.ExperimentalTestApi
28+
import androidx.compose.ui.test.assertIsDisplayed
29+
import androidx.compose.ui.test.hasTestTag
30+
import androidx.compose.ui.test.hasText
31+
import androidx.compose.ui.test.isDialog
32+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
33+
import androidx.compose.ui.test.onNodeWithTag
34+
import androidx.compose.ui.test.onNodeWithText
35+
import androidx.compose.ui.test.performClick
36+
import androidx.compose.ui.test.performTextInput
37+
import androidx.test.platform.app.InstrumentationRegistry
38+
import androidx.test.uiautomator.UiDevice
39+
import kotlinx.coroutines.test.runTest
40+
import net.opatry.tasks.app.MainActivity
41+
import net.opatry.tasks.app.R
42+
import net.opatry.tasks.app.test.ScreenshotOnFailureRule
43+
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.NOTES_FIELD
44+
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.TITLE_FIELD
45+
import net.opatry.tasks.app.ui.component.TaskListScaffoldTestTag.ADD_TASK_FAB
46+
import net.opatry.tasks.app.ui.component.TasksColumnTestTag.COMPLETED_TASKS_TOGGLE
47+
import org.junit.Rule
48+
import org.junit.Test
49+
import java.io.File
50+
51+
52+
@OptIn(ExperimentalTestApi::class)
53+
class StoreScreenshotTest {
54+
55+
@get:Rule
56+
val composeTestRule = createAndroidComposeRule<MainActivity>()
57+
58+
@get:Rule
59+
val screenshotOnFailureRule = ScreenshotOnFailureRule()
60+
61+
private val targetContext: Context
62+
get() = InstrumentationRegistry.getInstrumentation().targetContext
63+
64+
private fun takeScreenshot(name: String) {
65+
val instrumentation = InstrumentationRegistry.getInstrumentation()
66+
val outputDir = File(instrumentation.targetContext.cacheDir, "store_screenshots").also(File::mkdirs)
67+
val outputFile = File(outputDir, "$name.png")
68+
if (outputFile.exists()) {
69+
outputFile.delete()
70+
}
71+
UiDevice.getInstance(instrumentation)
72+
.takeScreenshot(outputFile)
73+
}
74+
75+
private fun pressBack() {
76+
// FIXME how to "press back" with ComposeTestRule (without Espresso)
77+
// UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
78+
// UI Automator doesn't work for navigation (but does for IME dismiss)
79+
composeTestRule.activity.onBackPressed()
80+
}
81+
82+
private fun dismissKeyboard() {
83+
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
84+
}
85+
86+
private fun switchToNightMode(nightMode: Int) {
87+
composeTestRule.activity.runOnUiThread {
88+
composeTestRule.activity.delegate.localNightMode = nightMode
89+
}
90+
composeTestRule.activityRule.scenario.recreate()
91+
composeTestRule.waitForIdle()
92+
}
93+
94+
/**
95+
* This test should be executed with the `demo` flavor which stub content for store screenshots.
96+
*/
97+
@Test
98+
fun storeScreenshotSequence() = runTest {
99+
val initialNightMode = composeTestRule.activity.delegate.localNightMode
100+
101+
composeTestRule.waitForIdle()
102+
takeScreenshot("initial_screen")
103+
104+
switchToNightMode(AppCompatDelegate.MODE_NIGHT_NO)
105+
106+
val defaultTaskTitle = targetContext.getString(R.string.demo_task_list_default)
107+
composeTestRule.waitUntilAtLeastOneExists(hasText(defaultTaskTitle))
108+
composeTestRule.onNodeWithText(defaultTaskTitle)
109+
.assertIsDisplayed()
110+
111+
val homeTaskTitle = targetContext.getString(R.string.demo_task_list_home)
112+
composeTestRule.onNodeWithText(homeTaskTitle)
113+
.assertIsDisplayed()
114+
115+
val groceriesTaskTitle = targetContext.getString(R.string.demo_task_list_groceries)
116+
composeTestRule.onNodeWithText(groceriesTaskTitle)
117+
.assertIsDisplayed()
118+
119+
val workTaskTitle = targetContext.getString(R.string.demo_task_list_work)
120+
composeTestRule.onNodeWithText(workTaskTitle)
121+
.assertIsDisplayed()
122+
123+
takeScreenshot("task_lists_light")
124+
125+
composeTestRule.onNodeWithText(defaultTaskTitle)
126+
.assertIsDisplayed()
127+
.performClick()
128+
val defaultTask1Title = targetContext.getString(R.string.demo_task_list_default_task1)
129+
composeTestRule.waitUntilAtLeastOneExists(hasText(defaultTask1Title))
130+
// FIXME unreliable, need to wait for something else?
131+
takeScreenshot("my_tasks_light")
132+
133+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(ADD_TASK_FAB))
134+
composeTestRule.onNodeWithTag(ADD_TASK_FAB)
135+
.assertIsDisplayed()
136+
.performClick()
137+
composeTestRule.waitUntilExactlyOneExists(isDialog())
138+
139+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(TITLE_FIELD))
140+
composeTestRule.onNodeWithTag(TITLE_FIELD)
141+
.performTextInput("Wash the car 🧽")
142+
composeTestRule.waitForIdle()
143+
dismissKeyboard()
144+
145+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(NOTES_FIELD))
146+
composeTestRule.onNodeWithTag(NOTES_FIELD)
147+
.performTextInput("Keys are in the drawer")
148+
149+
dismissKeyboard()
150+
151+
composeTestRule.waitForIdle()
152+
takeScreenshot("add_task_light")
153+
154+
// FIXME how to dismiss bottom sheet without clicking on the button? (press back somehow? tap outside?)
155+
// FIXME how to use Res strings from :tasks-app-shared?
156+
composeTestRule.onNodeWithText("Cancel")
157+
.assertIsDisplayed()
158+
.performClick()
159+
// go back
160+
pressBack()
161+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTaskTitle))
162+
163+
composeTestRule.onNodeWithText(groceriesTaskTitle)
164+
.assertIsDisplayed()
165+
.performClick()
166+
val groceriesTask1Title = targetContext.getString(R.string.demo_task_list_groceries_task1)
167+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTask1Title))
168+
composeTestRule.onNodeWithTag(COMPLETED_TASKS_TOGGLE)
169+
.assertIsDisplayed()
170+
.performClick()
171+
val groceriesTask3Title = targetContext.getString(R.string.demo_task_list_groceries_task3)
172+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTask3Title))
173+
takeScreenshot("groceries_light")
174+
175+
pressBack()
176+
composeTestRule.waitUntilAtLeastOneExists(hasText(workTaskTitle))
177+
178+
composeTestRule.onNodeWithText(workTaskTitle)
179+
.assertIsDisplayed()
180+
.performClick()
181+
val workTask1Title = targetContext.getString(R.string.demo_task_list_work_task1)
182+
composeTestRule.waitUntilAtLeastOneExists(hasText(workTask1Title))
183+
takeScreenshot("work_light")
184+
185+
pressBack()
186+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTaskTitle))
187+
188+
composeTestRule.onNodeWithText(homeTaskTitle)
189+
.assertIsDisplayed()
190+
.performClick()
191+
val homeTask1Title = targetContext.getString(R.string.demo_task_list_home_task1)
192+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTask1Title))
193+
takeScreenshot("home_light")
194+
195+
switchToNightMode(AppCompatDelegate.MODE_NIGHT_YES)
196+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTask1Title))
197+
takeScreenshot("home_dark")
198+
switchToNightMode(initialNightMode)
199+
}
200+
}

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskEditorBottomSheet.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import androidx.compose.ui.unit.dp
7070
import kotlinx.datetime.LocalDate
7171
import net.opatry.tasks.app.presentation.model.TaskListUIModel
7272
import net.opatry.tasks.app.presentation.model.TaskUIModel
73+
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.BOTTOM_SHEET
7374
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.CANCEL_BUTTON
7475
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.DUE_DATE_CHIP
7576
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.NOTES_FIELD
@@ -94,6 +95,7 @@ import org.jetbrains.compose.resources.stringResource
9495

9596
@VisibleForTesting
9697
object TaskEditorBottomSheetTestTag {
98+
const val BOTTOM_SHEET = "TASK_EDITOR_BOTTOM_SHEET"
9799
const val SHEET_TITLE = "TASK_EDITOR_BOTTOM_SHEET_TITLE"
98100
const val TITLE_FIELD = "TASK_EDITOR_TITLE_FIELD"
99101
const val TITLE_FIELD_ERROR_MESSAGE = "TASK_EDITOR_TITLE_FIELD_ERROR_MESSAGE"
@@ -122,6 +124,7 @@ fun TaskEditorBottomSheet(
122124
onValidate: (TaskListUIModel, String, String, LocalDate?) -> Unit,
123125
) {
124126
ModalBottomSheet(
127+
modifier = Modifier.testTag(BOTTOM_SHEET),
125128
sheetState = sheetState,
126129
onDismissRequest = onDismiss
127130
) {

0 commit comments

Comments
 (0)