diff --git a/.github/actions/get-avd-info/action.yml b/.github/actions/get-avd-info/action.yml deleted file mode 100644 index 8e1f40320..000000000 --- a/.github/actions/get-avd-info/action.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: 'Get AVD Info' -description: 'Get the AVD info based on its API level.' -inputs: - api-level: - description: 'The API level of the AVD.' - required: true -outputs: - arch: - description: 'The architecture of the AVD.' - value: ${{ steps.get-avd-arch.outputs.arch }} - target: - description: 'The target of the AVD.' - value: ${{ steps.get-avd-target.outputs.target }} -runs: - using: "composite" - steps: - - id: get-avd-arch - run: echo "arch=$(if [ ${{ inputs.api-level }} -ge 30 ]; then echo x86_64; else echo x86; fi)" >> $GITHUB_OUTPUT - shell: bash - - id: get-avd-target - run: echo "target=$(echo default)" >> $GITHUB_OUTPUT - shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deb3d8757..f30e8b21f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,8 @@ jobs: timeout-minutes: 60 strategy: matrix: - api-level: [34] + api-level: [29, 30, 31, 33, 35] + fail-fast: false # make sure all tests finish, even if some fail steps: - uses: actions/checkout@v4 @@ -116,33 +117,30 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} cache-read-only: true - # API 30+ emulators only have x86_64 system images. - - name: Get AVD info - uses: ./.github/actions/get-avd-info - id: avd-info - with: - api-level: ${{ matrix.api-level }} - + - name: Accept licenses + run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses + - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - - name: Instrumentation tests - uses: reactivecircus/android-emulator-runner@v2.33.0 + + - name: Cache AVD + uses: actions/cache@v4 with: - api-level: ${{ matrix.api-level }} - arch: ${{ steps.avd-info.outputs.arch }} - target: ${{ steps.avd-info.outputs.target }} - script: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:connectedDebugAndroidTest + path: ~/.config/.android/avd + key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there + + - name: Run device tests + run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:api${{ matrix.api-level }}DebugAndroidTest --stacktrace - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: instrumentation-test-results-${{ matrix.api-level }} - path: app/build/reports/androidTests/connected/debug + path: app/build/reports/androidTests/managedDevice/debug/api${{ matrix.api-level }} final-check: name: CI checks passed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99865050a..273896dfb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,10 +87,51 @@ android { targetCompatibility = javaVersion } - // needed for mockk testOptions { animationsDisabled = true + execution = "ANDROIDX_TEST_ORCHESTRATOR" + + managedDevices { + localDevices { + create("api29") { + device = "Pixel 6a" + apiLevel = 29 + systemImageSource = "aosp" + } + create("api30") { + device = "Pixel 6a" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + create("api31") { + device = "Pixel 6a" + apiLevel = 31 + systemImageSource = "aosp-atd" + } + create("api33") { + device = "Pixel 6a" + apiLevel = 33 + systemImageSource = "aosp-atd" + } + create("api35") { + device = "Pixel 6a" + apiLevel = 35 + systemImageSource = "aosp-atd" + } + } + groups { + create("all") { + targetDevices.add(devices["api29"]) + targetDevices.add(devices["api30"]) + targetDevices.add(devices["api31"]) + targetDevices.add(devices["api33"]) + targetDevices.add(devices["api35"]) + } + } + } + + // needed for mockk packaging { jniLibs { useLegacyPackaging = true } } @@ -220,6 +261,26 @@ tasks.register("fixLicense") { description = "Fixes the license header in all staged files." } +val embedScreenshotsTask = tasks.register("embedScreenshots") { + dependsOn("createDebugApkListingFileRedirect") + dependsOn("createDebugAndroidTestApkListingFileRedirect") + + buildIntermediatesDir.set( + project.layout.projectDirectory.dir("build/intermediates") + ) + managedDeviceReportDirectory.set( + project.layout.projectDirectory.dir("build/reports/androidTests/managedDevice/debug") + ) +} + +tasks.whenTaskAdded { + // Do not attempt to embed screenshots for API level < 29 since + // additional_test_output is not supported. + if (name.matches(regex = Regex("api[3-9][0-9]DebugAndroidTest"))) { + finalizedBy(embedScreenshotsTask) + } +} + dependencies { detektPlugins(libs.detekt.formatting) @@ -318,6 +379,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) // Instrumentation tests + androidTestUtil(libs.androidx.test.orchestrator) androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.android.compiler) androidTestImplementation(libs.junit) @@ -405,3 +467,81 @@ abstract class SetupMusikusTask @Inject constructor( println("Name stored for copyright header: $name\n") } } + +abstract class EmbedScreenshotsTask : DefaultTask() { + + @InputDirectory + val buildIntermediatesDir = project.objects.directoryProperty() + + @OutputDirectory + val managedDeviceReportDirectory = project.objects.directoryProperty() + + @get:Inject abstract val fs: FileSystemOperations + + init { + group = "verification" + description = "Embeds screenshots into JUnit test reports." + } + + @TaskAction + fun embedScreenshots() { + println("Embedding screenshots into JUnit reports...") + + val buildIntermediatesDirFile = buildIntermediatesDir.asFile.get() + val managedDeviceReportDirectoryFile = managedDeviceReportDirectory.asFile.get() + + val additionalTestOutputDirFile = File( + "$buildIntermediatesDirFile/managed_device_android_test_additional_output/debugAndroidTest" + ) + + val deviceDirectory = additionalTestOutputDirFile.listFiles()?.firstOrNull() + + if (deviceDirectory == null) { + println("No device directory found in '$additionalTestOutputDirFile'") + return + } + val deviceName = deviceDirectory.name.replace("DebugAndroidTest", "") + + println("Processing screenshots for device '$deviceName'...") + + val screenshotDirectory = File(managedDeviceReportDirectoryFile, "$deviceName/screenshots") + + println("Copying screenshots to '$screenshotDirectory'...") + + fs.copy { + from(deviceDirectory) + into(screenshotDirectory) + } + + screenshotDirectory.listFiles()?.forEach { failedTestClassDirectory -> + println("Processing screenshots for test class '${failedTestClassDirectory.name}'...") + + val failedTestClassName = failedTestClassDirectory.name + + failedTestClassDirectory.listFiles()?.forEach listFiles@{ failedTestFile -> + println("Embedding screenshot for test '$failedTestFile'...") + val failedTestName = failedTestFile.name + val failedTestNameWithoutExtension = failedTestName.substringBeforeLast('.') + val failedTestClassJunitReportFile = File( + managedDeviceReportDirectoryFile, + "$deviceName/$failedTestClassName.html" + ) + + if (!failedTestClassJunitReportFile.exists()) { + println("Could not find JUnit report file for test class '$failedTestClassJunitReportFile'") + return@listFiles + } + + var failedTestJunitReportContent = failedTestClassJunitReportFile.readText() + + val patternToFind = "

$failedTestNameWithoutExtension

" + val patternToReplace = + "$patternToFind
" + + failedTestJunitReportContent = failedTestJunitReportContent.replace(patternToFind, patternToReplace) + + failedTestClassJunitReportFile.writeText(failedTestJunitReportContent) + } + } + } +} diff --git a/app/src/androidTest/java/app/ComposeRule.kt b/app/src/androidTest/java/app/ComposeRule.kt new file mode 100644 index 000000000..ebe368328 --- /dev/null +++ b/app/src/androidTest/java/app/ComposeRule.kt @@ -0,0 +1,37 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2025 Matthias Emde + */ + +package app + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getBoundsInRoot + +/** + * Asserts that the given SemanticsNodeInteractions are vertically ordered on the screen. + * + * This function iterates through the provided nodes and verifies that each node is + * positioned above the subsequent node in the list. It checks if the bottom bound + * of the current node is less than or equal to the top bound of the next node. + * + * If any two consecutive nodes violate this vertical order, an assertion error is thrown. + * + * @param nodes The SemanticsNodeInteractions to be checked for vertical order. + * + * @throws AssertionError If any two consecutive nodes are not vertically ordered as expected. + */ +fun assertNodesInVerticalOrder(vararg nodes: SemanticsNodeInteraction) { + for (i in 0 until nodes.size - 1) { + val currentBounds = nodes[i].getBoundsInRoot() + val nextBounds = nodes[i + 1].getBoundsInRoot() + + check(currentBounds.bottom <= nextBounds.top) { + "Expected node ${i + 1} to be above node ${i + 2}, but was not. " + + "Bounds: current = $currentBounds, next = $nextBounds" + } + } +} diff --git a/app/src/androidTest/java/app/ScreenshotRule.kt b/app/src/androidTest/java/app/ScreenshotRule.kt new file mode 100644 index 000000000..e0824d2f5 --- /dev/null +++ b/app/src/androidTest/java/app/ScreenshotRule.kt @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2024 Matthias Emde + */ + +package app + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File + +// Store screenshots in "/sdcard/Android/media/app.musikus/additional_test_output" +// This way, the files will get automatically synced to app/build/outputs/managed_device_android_test_additional_output +// before the emulator gets shut down. +// Source: https://stackoverflow.com/questions/74069309/copy-data-from-an-android-emulator-that-is-run-by-gradle-managed-devices + +class ScreenshotRule( + private val composeTestRule: ComposeTestRule, +) : TestWatcher() { + + var outputDir: File + + init { + @Suppress("Deprecation") + outputDir = File( + InstrumentationRegistry.getInstrumentation().targetContext.externalMediaDirs.first(), + "additional_test_output" + ) + + // Ensure the directory exists + if (!outputDir.exists()) { + outputDir.mkdirs() + } + } + + override fun failed(e: Throwable?, description: Description) { + val testClassDir = File( + outputDir, + description.className + ) + + if (!testClassDir.exists()) { + testClassDir.mkdirs() + } + + val screenshotName = "${description.methodName}.png" + val screenshotFile = File(testClassDir, screenshotName) + + // Capture the screenshot and save it + composeTestRule.onRoot().captureToImage().asAndroidBitmap().apply { + screenshotFile.outputStream().use { outputStream -> + compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + } + } +} diff --git a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt index 1d71f7aa5..e631b12bc 100644 --- a/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/activesession/presentation/ActiveSessionScreenTest.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.navigation.NavHostController +import androidx.test.filters.SdkSuppress +import app.ScreenshotRule import app.musikus.core.data.Nullable import app.musikus.core.data.SectionWithLibraryItem import app.musikus.core.data.SessionWithSectionsWithLibraryItems @@ -65,6 +67,9 @@ class ActiveSessionScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + lateinit var navController: NavHostController lateinit var mainViewModel: MainViewModel @@ -212,6 +217,7 @@ class ActiveSessionScreenTest { } // @Test +// @SdkSuppress(minSdkVersion = 30) // fun deleteAndRedoSection() = runTest { // // Start session // composeRule.onNodeWithContentDescription("Start practicing").performClick() @@ -275,6 +281,7 @@ class ActiveSessionScreenTest { * Section: 00000000-0000-0000-0000-000000000007 */ @Test + @SdkSuppress(minSdkVersion = 29) // somehow crashes on API < 29 fun finishSession() = runTest { // Start session composeRule.onNodeWithContentDescription("Start practicing").performClick() diff --git a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt index b49145419..1a5eedca4 100644 --- a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalDescriptionDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.goals.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.GoalDescriptionWithInstancesAndLibraryItems import app.musikus.core.data.MusikusDatabase @@ -54,7 +55,7 @@ class GoalDescriptionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -363,12 +364,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateGoalDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.update(UUIDConverter.fromInt(1), GoalDescriptionUpdateAttributes()) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -423,12 +425,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteGoalDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.delete(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -514,12 +517,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.restore(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -596,12 +600,13 @@ class GoalDescriptionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificDescription() = runTest { val goalDescriptionDaoSpy = spyk(goalDescriptionDao) try { goalDescriptionDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } diff --git a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt index d92e05866..ef3b84e6e 100644 --- a/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/goals/data/daos/GoalInstanceDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.goals.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.GoalDescriptionWithLibraryItems import app.musikus.core.data.GoalInstanceWithDescription @@ -53,7 +54,7 @@ class GoalInstanceDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -432,6 +433,7 @@ class GoalInstanceDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateInstance() = runTest { val updateAttributes = GoalInstanceUpdateAttributes( endTimestamp = Nullable(fakeTimeProvider.now()), @@ -445,7 +447,7 @@ class GoalInstanceDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -758,6 +760,7 @@ class GoalInstanceDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificInstance() = runTest { val goalInstanceDaoSpy = spyk(goalInstanceDao) diff --git a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt index d626d7f95..79189509c 100644 --- a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryFolderDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.library.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.LibraryFolderWithItems import app.musikus.core.data.MusikusDatabase @@ -46,7 +47,7 @@ class LibraryFolderDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -93,6 +94,7 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun insertFolder() = runTest { val folder = LibraryFolderCreationAttributes(name = "TestFolder") @@ -149,6 +151,7 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateFolder() = runTest { val updateAttributes = LibraryFolderUpdateAttributes("UpdatedFolder1") @@ -159,7 +162,7 @@ class LibraryFolderDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -213,12 +216,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.delete(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -288,12 +292,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.restore(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -354,12 +359,13 @@ class LibraryFolderDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificFolder() = runTest { val libraryFolderDaoSpy = spyk(libraryFolderDao) try { libraryFolderDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } diff --git a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt index d404bb815..d6975749d 100644 --- a/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/library/data/daos/LibraryItemDaoTest.kt @@ -9,6 +9,7 @@ package app.musikus.library.data.daos import android.database.sqlite.SQLiteConstraintException +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -48,7 +49,7 @@ class LibraryItemDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -114,6 +115,7 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun insertItem() = runTest { val item = LibraryItemCreationAttributes( name = "TestItem", @@ -142,7 +144,8 @@ class LibraryItemDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } @@ -209,6 +212,7 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateItem() = runTest { val updateAttributes = LibraryItemUpdateAttributes( name = "UpdatedItem1", @@ -223,7 +227,7 @@ class LibraryItemDaoTest { UUIDConverter.fromInt(2), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -274,7 +278,8 @@ class LibraryItemDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } @@ -312,12 +317,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.delete(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -399,12 +405,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.restore(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -481,12 +488,13 @@ class LibraryItemDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificItem() = runTest { val libraryItemDaoSpy = spyk(libraryItemDao) try { libraryItemDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt index 9ee511771..db116a96b 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryFolderDetailsScreenTest.kt @@ -9,9 +9,7 @@ package app.musikus.library.presentation import androidx.activity.compose.setContent -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasContentDescription @@ -26,6 +24,9 @@ import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.hilt.navigation.compose.hiltViewModel +import androidx.test.filters.SdkSuppress +import app.ScreenshotRule +import app.assertNodesInVerticalOrder import app.musikus.core.data.UUIDConverter import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity @@ -36,6 +37,7 @@ import app.musikus.library.domain.usecase.LibraryUseCases import app.musikus.library.presentation.libraryfolder.LibraryFolderDetailsScreen import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before @@ -56,6 +58,9 @@ class LibraryFolderDetailsScreenTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + @Before fun setUp() { hiltRule.inject() @@ -109,47 +114,44 @@ class LibraryFolderDetailsScreenTest { "TestItem2" to 5, ) - namesAndColors.forEach { (name, color) -> + namesAndColors.forEachIndexed { index, (name, color) -> composeRule.onNodeWithContentDescription("Add item").performClick() composeRule.onNodeWithTag(TestTags.ITEM_DIALOG_NAME_INPUT).performTextInput(name) composeRule.onNodeWithContentDescription("Color $color").performClick() composeRule.onNodeWithContentDescription("Create").performClick() + // Suspend execution until the item is added to avoid race conditions + libraryUseCases.getAllItems().first { it.size == index + 1 } + fakeTimeProvider.advanceTimeBy(1.seconds) } // Check if items are displayed in correct order - var itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(3) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains(namesAndColors[namesAndColors.lastIndex - i].first) - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem3") + ) // Change sorting mode to name descending clickSortMode("items", "Name") // Check if items are displayed in correct order - itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(namesAndColors.size) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains("TestItem${namesAndColors.size - i}") - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem3"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem1") + ) // Change sorting mode to name ascending clickSortMode("items", "Name") // Check if items are displayed in correct order - itemNodes = composeRule.onAllNodes(hasText("TestItem", substring = true)) - - itemNodes.assertCountEquals(namesAndColors.size) - - for (i in namesAndColors.indices) { - itemNodes[i].assertTextContains("TestItem${i + 1}") - } + assertNodesInVerticalOrder( + composeRule.onNodeWithText("TestItem1"), + composeRule.onNodeWithText("TestItem2"), + composeRule.onNodeWithText("TestItem3") + ) } @Test @@ -167,6 +169,7 @@ class LibraryFolderDetailsScreenTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun editItem() { // Add an item from inside the folder (folder should be pre-selected) composeRule.onNodeWithContentDescription("Add item").performClick() @@ -201,6 +204,7 @@ class LibraryFolderDetailsScreenTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun deleteItem() = runTest { // Add an item composeRule.onNodeWithContentDescription("Add item").performClick() diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt index 7f777f140..4e051a920 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryIntegrationTest.kt @@ -29,6 +29,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute +import androidx.test.filters.SdkSuppress +import app.ScreenshotRule import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.HomeTab import app.musikus.core.presentation.HomeTabNavType @@ -56,6 +58,9 @@ class LibraryIntegrationTest { @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + lateinit var navController: TestNavHostController @Before @@ -98,6 +103,7 @@ class LibraryIntegrationTest { } @Test + @SdkSuppress(excludedSdks = [29]) fun addItemToFolderFromInsideAndOutside() { // Add a folder composeRule.onNodeWithContentDescription("Add folder or item").performClick() diff --git a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt index 5e3982ad2..064612ec5 100644 --- a/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt +++ b/app/src/androidTest/java/app/musikus/library/presentation/LibraryScreenTest.kt @@ -29,13 +29,17 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.core.app.ApplicationProvider +import app.ScreenshotRule import app.musikus.R import app.musikus.core.domain.FakeTimeProvider import app.musikus.core.presentation.MainActivity import app.musikus.core.presentation.MainViewModel import app.musikus.core.presentation.utils.TestTags +import app.musikus.library.domain.usecase.LibraryUseCases import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -46,12 +50,17 @@ import kotlin.time.Duration.Companion.seconds class LibraryScreenTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider + @Inject lateinit var libraryUseCases: LibraryUseCases + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + @get:Rule(order = 2) + val screenshotRule = ScreenshotRule(composeRule) + @Before fun setUp() { hiltRule.inject() @@ -136,20 +145,23 @@ class LibraryScreenTest { } @Test - fun saveNewItems_checkDefaultSortingThenNameSortingDescAndAsc() { + fun saveNewItems_checkDefaultSortingThenNameSortingDescAndAsc() = runTest { val namesAndColors = listOf( "TestItem3" to 3, "TestItem1" to 9, "TestItem2" to 5, ) - namesAndColors.forEach { (name, color) -> + namesAndColors.forEachIndexed { index, (name, color) -> composeRule.onNodeWithContentDescription("Add folder or item").performClick() composeRule.onNodeWithContentDescription("Add item").performClick() composeRule.onNodeWithTag(TestTags.ITEM_DIALOG_NAME_INPUT).performTextInput(name) composeRule.onNodeWithContentDescription("Color $color").performClick() composeRule.onNodeWithContentDescription("Create").performClick() + // Suspend execution until the item is added to avoid race conditions + libraryUseCases.getAllItems().first { it.size == index + 1 } + fakeTimeProvider.advanceTimeBy(1.seconds) } diff --git a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt index bf6e16a38..9cebc791b 100644 --- a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SectionDaoTest.kt @@ -8,6 +8,7 @@ package app.musikus.sessionslist.data.daos +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -48,7 +49,7 @@ class SectionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -185,6 +186,7 @@ class SectionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateSection() = runTest { val updateAttributes = SectionUpdateAttributes(duration = 5.minutes) @@ -195,7 +197,7 @@ class SectionDaoTest { UUIDConverter.fromInt(1), updateAttributes ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Ignore } @@ -327,6 +329,7 @@ class SectionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificSection() = runTest { val sectionDaoSpy = spyk(sectionDao) diff --git a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt index 5922eb14f..4c549431d 100644 --- a/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt +++ b/app/src/androidTest/java/app/musikus/sessionslist/data/daos/SessionDaoTest.kt @@ -9,6 +9,7 @@ package app.musikus.sessionslist.data.daos import android.database.sqlite.SQLiteConstraintException +import androidx.test.filters.SdkSuppress import androidx.test.filters.SmallTest import app.musikus.core.data.MusikusDatabase import app.musikus.core.data.Nullable @@ -55,7 +56,7 @@ class SessionDaoTest { @Inject lateinit var fakeTimeProvider: FakeTimeProvider - @get:Rule + @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @Before @@ -246,12 +247,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun updateSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.update(UUIDConverter.fromInt(1), SessionUpdateAttributes()) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -299,12 +301,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun deleteSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.delete(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -387,12 +390,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun restoreSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.restore(UUIDConverter.fromInt(1)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -446,12 +450,13 @@ class SessionDaoTest { } @Test + @SdkSuppress(minSdkVersion = 28) fun getSpecificSession() = runTest { val sessionDaoSpy = spyk(sessionDao) try { sessionDaoSpy.getAsFlow(UUIDConverter.fromInt(2)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // ignore } @@ -599,7 +604,8 @@ class SessionDaoTest { } } - assertThat(exception.message).isEqualTo( + assertThat(exception.message).isAnyOf( + "FOREIGN KEY constraint failed (code 787)", "FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)" ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0532f6233..c66acdf8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-test-runner = "1.6.1" androidx-test-rules = "1.6.1" androidx-test-espresso = "3.6.1" androidx-test-ext-junit = "1.2.1" +androidx-test-orchestrator = "1.5.1" androidx-hilt-navigation-compose = "1.2.0" # @keep compileSdk = "35" @@ -83,6 +84,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } androidx-test-navigation = { module = "androidx.navigation:navigation-testing", version.ref = "androidx-navigation" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdk-desugar" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt-formatting" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" }