Skip to content

Commit df21956

Browse files
committed
Add StoreScreenshotTest to generate store screenshots programmatically
1 parent d7de907 commit df21956

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2024 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+
import androidx.test.platform.app.InstrumentationRegistry
24+
import androidx.test.uiautomator.UiDevice
25+
import org.junit.rules.TestWatcher
26+
import org.junit.runner.Description
27+
import java.io.File
28+
29+
@Retention(AnnotationRetention.RUNTIME)
30+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
31+
annotation class NoScreenshot
32+
33+
private fun defaultScreenshotDir() = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "test_failed_screenshots")
34+
35+
class ScreenshotOnFailureRule(private val screenshotsDir: File = defaultScreenshotDir()) : TestWatcher() {
36+
private val Description.allowScreenshot: Boolean
37+
get() = getAnnotation(NoScreenshot::class.java) == null
38+
39+
override fun failed(e: Throwable?, description: Description?) {
40+
description?.let { testDescription ->
41+
if (testDescription.allowScreenshot) {
42+
try {
43+
takeScreenshot(testDescription)
44+
} catch (_: Exception) {
45+
// ignore screenshot processing errors
46+
}
47+
}
48+
}
49+
super.failed(e, description)
50+
}
51+
52+
private fun takeScreenshot(testDescription: Description) {
53+
val fileName = testDescription.displayName.take(150)
54+
val outputFile = File(screenshotsDir, "$fileName.png").also {
55+
it.parentFile?.mkdirs()
56+
}
57+
if (outputFile.exists()) {
58+
outputFile.delete()
59+
}
60+
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
61+
.takeScreenshot(outputFile)
62+
}
63+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
24+
import android.content.Context
25+
import androidx.appcompat.app.AppCompatDelegate
26+
import androidx.compose.ui.test.ExperimentalTestApi
27+
import androidx.compose.ui.test.assertIsDisplayed
28+
import androidx.compose.ui.test.hasTestTag
29+
import androidx.compose.ui.test.hasText
30+
import androidx.compose.ui.test.isDialog
31+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
32+
import androidx.compose.ui.test.onNodeWithTag
33+
import androidx.compose.ui.test.onNodeWithText
34+
import androidx.compose.ui.test.performClick
35+
import androidx.compose.ui.test.performTextInput
36+
import androidx.test.platform.app.InstrumentationRegistry
37+
import androidx.test.uiautomator.UiDevice
38+
import kotlinx.coroutines.test.runTest
39+
import net.opatry.tasks.app.MainActivity
40+
import net.opatry.tasks.app.R
41+
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.NOTES_FIELD
42+
import net.opatry.tasks.app.ui.component.TaskEditorBottomSheetTestTag.TITLE_FIELD
43+
import net.opatry.tasks.app.ui.component.TaskListScaffoldTestTag.ADD_TASK_FAB
44+
import net.opatry.tasks.app.ui.component.TasksColumnTestTag.COMPLETED_TASKS_TOGGLE
45+
import org.junit.Rule
46+
import org.junit.Test
47+
import java.io.File
48+
49+
50+
@OptIn(ExperimentalTestApi::class)
51+
class StoreScreenshotTest {
52+
53+
@get:Rule
54+
val composeTestRule = createAndroidComposeRule<MainActivity>()
55+
56+
@get:Rule
57+
val screenshotOnFailureRule = ScreenshotOnFailureRule()
58+
59+
private val targetContext: Context
60+
get() = InstrumentationRegistry.getInstrumentation().targetContext
61+
62+
private fun takeScreenshot(name: String) {
63+
val instrumentation = InstrumentationRegistry.getInstrumentation()
64+
val outputDir = File(instrumentation.targetContext.cacheDir, "store_screenshots").also(File::mkdirs)
65+
val outputFile = File(outputDir, "$name.png")
66+
if (outputFile.exists()) {
67+
outputFile.delete()
68+
}
69+
UiDevice.getInstance(instrumentation)
70+
.takeScreenshot(outputFile)
71+
}
72+
73+
private fun pressBack() {
74+
// FIXME how to "press back" with ComposeTestRule (without Espresso)
75+
// UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
76+
// UI Automator doesn't work for navigation (but does for IME dismiss)
77+
composeTestRule.activity.onBackPressed()
78+
}
79+
80+
private fun dismissKeyboard() {
81+
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
82+
}
83+
84+
private fun switchToNightMode(nightMode: Int) {
85+
composeTestRule.activity.runOnUiThread {
86+
composeTestRule.activity.delegate.localNightMode = nightMode
87+
}
88+
composeTestRule.activityRule.scenario.recreate()
89+
composeTestRule.waitForIdle()
90+
}
91+
92+
/**
93+
* This test should be executed with the `demo` flavor which stub content for store screenshots.
94+
*/
95+
@Test
96+
fun storeScreenshotSequence() = runTest {
97+
val initialNightMode = composeTestRule.activity.delegate.localNightMode
98+
99+
composeTestRule.waitForIdle()
100+
takeScreenshot("initial_screen")
101+
102+
switchToNightMode(AppCompatDelegate.MODE_NIGHT_NO)
103+
104+
val defaultTaskTitle = targetContext.getString(R.string.demo_task_list_default)
105+
composeTestRule.waitUntilAtLeastOneExists(hasText(defaultTaskTitle))
106+
composeTestRule.onNodeWithText(defaultTaskTitle)
107+
.assertIsDisplayed()
108+
109+
val homeTaskTitle = targetContext.getString(R.string.demo_task_list_home)
110+
composeTestRule.onNodeWithText(homeTaskTitle)
111+
.assertIsDisplayed()
112+
113+
val groceriesTaskTitle = targetContext.getString(R.string.demo_task_list_groceries)
114+
composeTestRule.onNodeWithText(groceriesTaskTitle)
115+
.assertIsDisplayed()
116+
117+
val workTaskTitle = targetContext.getString(R.string.demo_task_list_work)
118+
composeTestRule.onNodeWithText(workTaskTitle)
119+
.assertIsDisplayed()
120+
121+
takeScreenshot("task_lists_light")
122+
123+
composeTestRule.onNodeWithText(defaultTaskTitle)
124+
.assertIsDisplayed()
125+
.performClick()
126+
val defaultTask1Title = targetContext.getString(R.string.demo_task_list_default_task1)
127+
composeTestRule.waitUntilAtLeastOneExists(hasText(defaultTask1Title))
128+
// FIXME unreliable, need to wait for something else?
129+
takeScreenshot("my_tasks_light")
130+
131+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(ADD_TASK_FAB))
132+
composeTestRule.onNodeWithTag(ADD_TASK_FAB)
133+
.assertIsDisplayed()
134+
.performClick()
135+
composeTestRule.waitUntilExactlyOneExists(isDialog())
136+
137+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(TITLE_FIELD))
138+
composeTestRule.onNodeWithTag(TITLE_FIELD)
139+
.performTextInput("Wash the car 🧽")
140+
composeTestRule.waitForIdle()
141+
dismissKeyboard()
142+
143+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(NOTES_FIELD))
144+
composeTestRule.onNodeWithTag(NOTES_FIELD)
145+
.performTextInput("Keys are in the drawer")
146+
147+
dismissKeyboard()
148+
149+
composeTestRule.waitForIdle()
150+
takeScreenshot("add_task_light")
151+
152+
// FIXME how to dismiss bottom sheet without clicking on the button? (press back somehow? tap outside?)
153+
// FIXME how to use Res strings from :tasks-app-shared?
154+
composeTestRule.onNodeWithText("Cancel")
155+
.assertIsDisplayed()
156+
.performClick()
157+
// go back
158+
pressBack()
159+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTaskTitle))
160+
161+
composeTestRule.onNodeWithText(groceriesTaskTitle)
162+
.assertIsDisplayed()
163+
.performClick()
164+
val groceriesTask1Title = targetContext.getString(R.string.demo_task_list_groceries_task1)
165+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTask1Title))
166+
composeTestRule.onNodeWithTag(COMPLETED_TASKS_TOGGLE)
167+
.assertIsDisplayed()
168+
.performClick()
169+
val groceriesTask3Title = targetContext.getString(R.string.demo_task_list_groceries_task3)
170+
composeTestRule.waitUntilAtLeastOneExists(hasText(groceriesTask3Title))
171+
takeScreenshot("groceries_light")
172+
173+
pressBack()
174+
composeTestRule.waitUntilAtLeastOneExists(hasText(workTaskTitle))
175+
176+
composeTestRule.onNodeWithText(workTaskTitle)
177+
.assertIsDisplayed()
178+
.performClick()
179+
val workTask1Title = targetContext.getString(R.string.demo_task_list_work_task1)
180+
composeTestRule.waitUntilAtLeastOneExists(hasText(workTask1Title))
181+
takeScreenshot("work_light")
182+
183+
pressBack()
184+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTaskTitle))
185+
186+
composeTestRule.onNodeWithText(homeTaskTitle)
187+
.assertIsDisplayed()
188+
.performClick()
189+
val homeTask1Title = targetContext.getString(R.string.demo_task_list_home_task1)
190+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTask1Title))
191+
takeScreenshot("home_light")
192+
193+
switchToNightMode(AppCompatDelegate.MODE_NIGHT_YES)
194+
composeTestRule.waitUntilAtLeastOneExists(hasText(homeTask1Title))
195+
takeScreenshot("home_dark")
196+
switchToNightMode(initialNightMode)
197+
}
198+
}

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)