Skip to content

Commit de0039e

Browse files
committed
Add StoreScreenshotTest to generate store screenshots programmatically
1 parent e91067e commit de0039e

File tree

4 files changed

+282
-3
lines changed

4 files changed

+282
-3
lines changed
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.TaskListScaffoldTestTag.ADD_TASK_FAB
42+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.COMPLETED_TASKS_TOGGLE
43+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.TASK_NOTES_FIELD
44+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.TASK_TITLE_FIELD
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(TASK_TITLE_FIELD))
138+
composeTestRule.onNodeWithTag(TASK_TITLE_FIELD)
139+
.performTextInput("Wash the car 🧽")
140+
composeTestRule.waitForIdle()
141+
dismissKeyboard()
142+
143+
composeTestRule.waitUntilExactlyOneExists(hasTestTag(TASK_NOTES_FIELD))
144+
composeTestRule.onNodeWithTag(TASK_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/TaskListScaffold.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import net.opatry.tasks.app.ui.component.TaskListScaffoldTestTag.ADD_TASK_FAB
4141
import net.opatry.tasks.data.TaskListSorting
4242

4343
@VisibleForTesting
44-
internal object TaskListScaffoldTestTag {
44+
object TaskListScaffoldTestTag {
4545
const val ADD_TASK_FAB = "TASK_LIST_SCAFFOLD_ADD_TASK_FAB"
4646
}
4747

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/tasksPane.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import CalendarDays
2626
import ListPlus
2727
import LucideIcons
2828
import NotepadText
29+
import androidx.annotation.VisibleForTesting
2930
import androidx.compose.animation.AnimatedVisibility
3031
import androidx.compose.foundation.layout.Arrangement
3132
import androidx.compose.foundation.layout.Column
@@ -69,6 +70,7 @@ import androidx.compose.runtime.remember
6970
import androidx.compose.runtime.setValue
7071
import androidx.compose.ui.Alignment
7172
import androidx.compose.ui.Modifier
73+
import androidx.compose.ui.platform.testTag
7274
import androidx.compose.ui.text.input.ImeAction
7375
import androidx.compose.ui.text.input.KeyboardType
7476
import androidx.compose.ui.unit.dp
@@ -94,6 +96,9 @@ import net.opatry.tasks.app.ui.component.TaskListEditMenuAction
9496
import net.opatry.tasks.app.ui.component.TaskListScaffold
9597
import net.opatry.tasks.app.ui.component.toColor
9698
import net.opatry.tasks.app.ui.component.toLabel
99+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.TASK_EDITOR_SHEET
100+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.TASK_NOTES_FIELD
101+
import net.opatry.tasks.app.ui.screen.TaskListPaneTestTag.TASK_TITLE_FIELD
97102
import net.opatry.tasks.resources.Res
98103
import net.opatry.tasks.resources.dialog_cancel
99104
import net.opatry.tasks.resources.task_due_date_update_cta
@@ -123,6 +128,14 @@ import net.opatry.tasks.resources.task_menu_move_to_new_list_create_task_list_di
123128
import net.opatry.tasks.resources.task_menu_move_to_new_list_create_task_list_dialog_title
124129
import org.jetbrains.compose.resources.stringResource
125130

131+
@VisibleForTesting
132+
object TaskListPaneTestTag {
133+
const val COMPLETED_TASKS_TOGGLE = "COMPLETED_TASKS_TOGGLE"
134+
const val TASK_TITLE_FIELD = "TASK_TITLE_FIELD"
135+
const val TASK_NOTES_FIELD = "TASK_NOTES_FIELD"
136+
const val TASK_EDITOR_SHEET = "TASK_EDITOR_SHEET"
137+
}
138+
126139
@OptIn(ExperimentalMaterial3Api::class)
127140
@Composable
128141
fun TaskListDetail(
@@ -320,6 +333,7 @@ fun TaskListDetail(
320333
val task = taskOfInterest
321334
ModalBottomSheet(
322335
sheetState = taskEditorSheetState,
336+
modifier = Modifier.testTag(TASK_EDITOR_SHEET),
323337
onDismissRequest = {
324338
taskOfInterest = null
325339
showEditTaskSheet = false
@@ -368,7 +382,9 @@ fun TaskListDetail(
368382
alreadyHadSomeContent = alreadyHadSomeContent || it.isNotBlank()
369383
newTitle = it
370384
},
371-
modifier = Modifier.fillMaxWidth(),
385+
modifier = Modifier
386+
.fillMaxWidth()
387+
.testTag(TASK_TITLE_FIELD),
372388
label = { Text(stringResource(Res.string.task_editor_sheet_title_field_label)) },
373389
placeholder = { Text(stringResource(Res.string.task_editor_sheet_title_field_placeholder)) },
374390
maxLines = 1,
@@ -387,7 +403,9 @@ fun TaskListDetail(
387403
OutlinedTextField(
388404
newNotes,
389405
onValueChange = { newNotes = it },
390-
modifier = Modifier.fillMaxWidth(),
406+
modifier = Modifier
407+
.fillMaxWidth()
408+
.testTag(TASK_NOTES_FIELD),
391409
label = { Text(stringResource(Res.string.task_editor_sheet_notes_field_label)) },
392410
placeholder = { Text(stringResource(Res.string.task_editor_sheet_notes_field_placeholder)) },
393411
leadingIcon = { Icon(LucideIcons.NotepadText, null) },

0 commit comments

Comments
 (0)