diff --git a/README.md b/README.md index 24cb19c59..88b797365 100644 --- a/README.md +++ b/README.md @@ -927,6 +927,11 @@ If you are having trouble debugging your test, try Dump mode as follows. ![image](https://user-images.githubusercontent.com/1386930/226364158-a07a0fb0-d8e7-46b7-a495-8dd217faaadb.png) +### Accessibility Check + +Roborazzi Accessibility Checks is a library that integrates accessibility checks into Roborazzi. +Please refer to [Accessibility Check](https://github.com/takahirom/roborazzi/blob/main/roborazzi-accessibility-check/README.md) + ### Roborazzi options Please check out [RoborazziOptions](https://github.com/takahirom/roborazzi/blob/main/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt) for available Roborazzi options. diff --git a/docs/topics/how_to_use.md b/docs/topics/how_to_use.md index e95be494a..066a2625c 100644 --- a/docs/topics/how_to_use.md +++ b/docs/topics/how_to_use.md @@ -604,6 +604,11 @@ If you are having trouble debugging your test, try Dump mode as follows. ![image](https://user-images.githubusercontent.com/1386930/226364158-a07a0fb0-d8e7-46b7-a495-8dd217faaadb.png) +### Accessibility Check + +Roborazzi Accessibility Checks is a library that integrates accessibility checks into Roborazzi. +Please refer to [Accessibility Check](https://github.com/takahirom/roborazzi/blob/main/roborazzi-accessibility-check/README.md) + ### Roborazzi options Please check out [RoborazziOptions](https://github.com/takahirom/roborazzi/blob/main/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt) for available Roborazzi options. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a5d6607f..48b30092d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accessibilityTestFramework = "4.1.1" javaToolchain = "17" javaTarget = "11" agp = "8.6.1" @@ -49,6 +50,7 @@ webpImageio = "0.3.3" composable-preview-scanner = "0.4.0" [libraries] +accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version.ref = "accessibilityTestFramework" } roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi-for-replacing-by-include-build" } roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi-for-replacing-by-include-build" } roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-rule", version.ref = "roborazzi-for-replacing-by-include-build" } diff --git a/include-build/roborazzi-core/src/androidMain/kotlin/com/github/takahirom/roborazzi/ViewScreenshot.kt b/include-build/roborazzi-core/src/androidMain/kotlin/com/github/takahirom/roborazzi/ViewScreenshot.kt index 6fee14b30..971548a63 100644 --- a/include-build/roborazzi-core/src/androidMain/kotlin/com/github/takahirom/roborazzi/ViewScreenshot.kt +++ b/include-build/roborazzi-core/src/androidMain/kotlin/com/github/takahirom/roborazzi/ViewScreenshot.kt @@ -82,7 +82,7 @@ private fun View.generateBitmap( val destBitmap = Bitmap.createBitmap(width, height, pixelBitConfig.toBitmapConfig()) when { Build.VERSION.SDK_INT < 26 -> { - println( + roborazziErrorLog( "**Warning from Roborazzi**: Robolectric may not function properly under API 26, " + "specifically it may fail to capture accurate screenshots. " + "Please add @Config(sdk = [26]) or higher to your test class to ensure proper operation. " + @@ -115,7 +115,7 @@ private fun View.generateBitmap( val window = nullableWindow!! if (Build.VERSION.SDK_INT < 28) { // See: https://github.com/robolectric/robolectric/blob/robolectric-4.10.3/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPixelCopy.java#L32 - println( + roborazziReportLog( "PixelCopy is not supported for API levels below 28. Falling back to View#draw instead of PixelCopy. " + "Consider using API level 28 or higher, e.g., @Config(sdk = [28])." ) @@ -124,7 +124,7 @@ private fun View.generateBitmap( generateBitmapFromPixelCopy(window, destBitmap, bitmapFuture) } } else { - println( + roborazziReportLog( "View.captureToImage Could not find window for view. Falling back to View#draw instead of PixelCopy" + "(If you are using Material3 dialogs, this is expected).", ) diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziAccessibilityOptions.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziAccessibilityOptions.kt new file mode 100644 index 000000000..3344496b7 --- /dev/null +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziAccessibilityOptions.kt @@ -0,0 +1,7 @@ +package com.github.takahirom.roborazzi + +@ExperimentalRoborazziApi +interface RoborazziAccessibilityOptions { + // See `roborazzi-accessibility-check`'s [com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions] + object None: RoborazziAccessibilityOptions +} diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt index 15ce447e7..a04840ff3 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt @@ -63,6 +63,22 @@ class RoborazziContextImpl { ruleOverrideImageExtension = null } + private var ruleOverrideRoborazziAccessibilityOptions: RoborazziAccessibilityOptions? = null + + @InternalRoborazziApi + fun setRuleOverrideAccessibilityOptions(checker: RoborazziAccessibilityOptions?) { + ruleOverrideRoborazziAccessibilityOptions = checker + } + + @InternalRoborazziApi + fun clearRuleOverrideAccessibilityOptions() { + ruleOverrideRoborazziAccessibilityOptions = null + } + + @InternalRoborazziApi + val roborazziAccessibilityOptions: RoborazziAccessibilityOptions + get() = ruleOverrideRoborazziAccessibilityOptions ?: RoborazziAccessibilityOptions.None + @InternalRoborazziApi val imageExtension: String get() = ruleOverrideImageExtension ?: roborazziSystemPropertyImageExtension() diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt index d0b3cd613..6a6962b4b 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt @@ -44,7 +44,7 @@ fun processOutputImageAndReport( "\ngoldenFile:${goldenFile.absolutePath}" } if (taskType.isEnabled() && !roborazziSystemPropertyTaskType().isEnabled()) { - println( + roborazziReportLog( "Roborazzi Warning:\n" + "You have specified '$taskType' without the necessary plugin configuration like roborazzi.test.record=true or ./gradlew recordRoborazziDebug.\n" + "This may complicate your screenshot testing process because the behavior is not changeable. And it doesn't allow Roborazzi plugin to generate test report.\n" + @@ -98,10 +98,10 @@ fun processOutputImageAndReport( ) diffPercentage = comparisonResult.pixelDifferences.toFloat() / comparisonResult.pixelCount val changed = !compareOptions.resultValidator(comparisonResult) - reportLog("${goldenFile.name} The differ result :$comparisonResult changed:$changed") + roborazziReportLog("${goldenFile.name} The differ result :$comparisonResult changed:$changed") changed } else { - reportLog("${goldenFile.name} The image size is changed. actual = (${goldenRoboCanvas.width}, ${goldenRoboCanvas.height}), golden = (${newRoboCanvas.croppedWidth}, ${newRoboCanvas.croppedHeight})") + roborazziReportLog("${goldenFile.name} The image size is changed. actual = (${goldenRoboCanvas.width}, ${goldenRoboCanvas.height}), golden = (${newRoboCanvas.croppedWidth}, ${newRoboCanvas.croppedHeight})") true } diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/reportLog.commonJvm.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/reportLog.commonJvm.kt new file mode 100644 index 000000000..1a135c175 --- /dev/null +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/reportLog.commonJvm.kt @@ -0,0 +1,5 @@ +package com.github.takahirom.roborazzi + +actual fun roborazziErrorLog(message: String) { + System.err.println("Roborazzi: $message") +} \ No newline at end of file diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/reportLog.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/reportLog.kt index 72f9e9cff..dc5ba0d69 100644 --- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/reportLog.kt +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/reportLog.kt @@ -1,5 +1,9 @@ package com.github.takahirom.roborazzi -fun reportLog(message: String) { +@InternalRoborazziApi +fun roborazziReportLog(message: String) { println("Roborazzi: $message") } + +@InternalRoborazziApi +expect fun roborazziErrorLog(message: String) \ No newline at end of file diff --git a/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/reportLog.ios.kt b/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/reportLog.ios.kt new file mode 100644 index 000000000..6dd40c5bb --- /dev/null +++ b/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/reportLog.ios.kt @@ -0,0 +1,10 @@ +package com.github.takahirom.roborazzi + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.posix.fprintf +import platform.posix.stderr + +@OptIn(ExperimentalForeignApi::class) +actual fun roborazziErrorLog(message: String) { + fprintf(stderr, "Roborazzi: %s\n", message) +} \ No newline at end of file diff --git a/roborazzi-accessibility-check/.gitignore b/roborazzi-accessibility-check/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/roborazzi-accessibility-check/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/roborazzi-accessibility-check/README.md b/roborazzi-accessibility-check/README.md new file mode 100644 index 000000000..ca76bbeaf --- /dev/null +++ b/roborazzi-accessibility-check/README.md @@ -0,0 +1,76 @@ +# Roborazzi Acessibility Checks + +Roborazzi Accessibility Checks is a library that integrates accessibility checks into Roborazzi. +It uses [ Accessibility Test Framework](https://github.com/google/Accessibility-Test-Framework-for-Android) to check accessibility. + +## How to use + +### Add dependencies + +| Description | Dependencies | +|---------------------|-----------------------------------------------------------------------------------------------| +| Accessibility Check | `testImplementation("io.github.takahirom.roborazzi:roborazzi-accessibility-check:[version]")` | + +### Configure in Junit Rule + +```kotlin + @get:Rule + val roborazziRule = RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = Options( + roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + checks = setOf(NoRedTextCheck()), + suppressions = matchesElements(withTestTag("suppress")) + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ), + // If you want to automatically check accessibility after a test, use AccessibilityCheckAfterTestStrategy. + accessibilityCheckStrategy = AccessibilityCheckAfterTestStrategy(), + ) + ) +``` + +### Add accessibility checks + +```kotlin +composeTestRule.onNodeWithTag("nothard").checkRoboAccessibility( + // If you don't specify options, the options in RoborazziRule will be used. + roborazziATFAccessibilityCheckOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + preset = AccessibilityCheckPreset.LATEST, + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ) +) +``` + +### Checking Log output + +Particularly with `failureLevel = CheckLevel.LogOnly` the output log of each test will including a11y checks. + +```text +Error: [AccessibilityViewCheckResult check=AccessibilityHierarchyCheckResult ERROR SpeakableTextPresentCheck 4 [ViewHierarchyElement class=android.view.View bounds=Rect(474, 1074 - 606, 1206)] null num_answers:0 view=null] +Warning: [AccessibilityViewCheckResult check=AccessibilityHierarchyCheckResult WARNING TextContrastCheck 11 [ViewHierarchyElement class=android.widget.TextView text=Something hard to read bounds=Rect(403, 1002 - 678, 1092)] {KEY_BACKGROUND_COLOR=-12303292, KEY_CONTRAST_RATIO=1.02, KEY_FOREGROUND_COLOR=-12369085, KEY_REQUIRED_CONTRAST_RATIO=3.0} num_answers:0 view=null] +``` + +### LICENSE + +``` +Copyright 2023 takahirom +Copyright 2019 Square, Inc. +Copyright The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/roborazzi-accessibility-check/build.gradle b/roborazzi-accessibility-check/build.gradle new file mode 100644 index 000000000..c50af0ef5 --- /dev/null +++ b/roborazzi-accessibility-check/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} +if (System.getenv("INTEGRATION_TEST") != "true") { + pluginManager.apply("com.vanniktech.maven.publish") +} + + +android { + namespace 'com.github.takahirom.roborazzi.accessibility.check' + compileSdk 34 + + defaultConfig { + minSdk 21 + targetSdk 32 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + buildFeatures { + } + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +dependencies { + implementation project(':roborazzi-junit-rule') + implementation project(':roborazzi') + compileOnly libs.robolectric + compileOnly libs.androidx.compose.ui.test + api libs.accessibility.test.framework +} \ No newline at end of file diff --git a/roborazzi-accessibility-check/consumer-rules.pro b/roborazzi-accessibility-check/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/roborazzi-accessibility-check/proguard-rules.pro b/roborazzi-accessibility-check/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/roborazzi-accessibility-check/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/roborazzi-accessibility-check/src/androidTest/java/com/github/takahirom/roborazzi/ExampleInstrumentedTest.kt b/roborazzi-accessibility-check/src/androidTest/java/com/github/takahirom/roborazzi/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..f06fdc650 --- /dev/null +++ b/roborazzi-accessibility-check/src/androidTest/java/com/github/takahirom/roborazzi/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.github.takahirom.roborazzi + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.github.takahirom.roborazzi.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/roborazzi-accessibility-check/src/main/AndroidManifest.xml b/roborazzi-accessibility-check/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/roborazzi-accessibility-check/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/roborazzi-accessibility-check/src/main/java/com/github/takahirom/roborazzi/RoborazziATFAccessibilityChecker.kt b/roborazzi-accessibility-check/src/main/java/com/github/takahirom/roborazzi/RoborazziATFAccessibilityChecker.kt new file mode 100644 index 000000000..21114ce26 --- /dev/null +++ b/roborazzi-accessibility-check/src/main/java/com/github/takahirom/roborazzi/RoborazziATFAccessibilityChecker.kt @@ -0,0 +1,237 @@ +package com.github.takahirom.roborazzi + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Build +import android.view.View +import androidx.annotation.RequiresApi +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewInteraction +import com.github.takahirom.roborazzi.RoborazziRule.CaptureRoot +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheck +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult +import com.google.android.apps.common.testing.accessibility.framework.Parameters +import com.google.android.apps.common.testing.accessibility.framework.ViewChecker +import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityViewCheckException +import com.google.android.apps.common.testing.accessibility.framework.utils.contrast.BitmapImage +import com.google.common.collect.ImmutableSet +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.robolectric.shadows.ShadowBuild + +@ExperimentalRoborazziApi +fun SemanticsNodeInteraction.checkRoboAccessibility( + roborazziATFAccessibilityCheckOptions: RoborazziATFAccessibilityCheckOptions = provideATFAccessibilityOptionsOrCreateDefault(), + roborazziOptions: RoborazziOptions = provideRoborazziContext().options, +) { + roborazziATFAccessibilityCheckOptions.checker.runAccessibilityChecks( + checkNode = RoborazziATFAccessibilityChecker.CheckNode.Compose(this), + roborazziOptions = roborazziOptions, + failureLevel = roborazziATFAccessibilityCheckOptions.failureLevel, + ) +} + +@ExperimentalRoborazziApi +fun ViewInteraction.checkRoboAccessibility( + roborazziATFAccessibilityCheckOptions: RoborazziATFAccessibilityCheckOptions = provideATFAccessibilityOptionsOrCreateDefault(), + roborazziOptions: RoborazziOptions = provideRoborazziContext().options, +) { + roborazziATFAccessibilityCheckOptions.checker.runAccessibilityChecks( + checkNode = RoborazziATFAccessibilityChecker.CheckNode.View(this), + roborazziOptions = roborazziOptions, + failureLevel = roborazziATFAccessibilityCheckOptions.failureLevel, + ) +} + +private fun provideATFAccessibilityOptionsOrCreateDefault(): RoborazziATFAccessibilityCheckOptions = + ((provideRoborazziContext().roborazziAccessibilityOptions as? RoborazziATFAccessibilityCheckOptions) + ?: RoborazziATFAccessibilityCheckOptions()) + +@ExperimentalRoborazziApi +data class RoborazziATFAccessibilityCheckOptions( + val checker: RoborazziATFAccessibilityChecker = RoborazziATFAccessibilityChecker(), + val failureLevel: RoborazziATFAccessibilityChecker.CheckLevel = RoborazziATFAccessibilityChecker.CheckLevel.Error, +) : RoborazziAccessibilityOptions + +@ExperimentalRoborazziApi +data class RoborazziATFAccessibilityChecker( + val checks: Set = AccessibilityCheckPreset.getAccessibilityHierarchyChecksForPreset( + AccessibilityCheckPreset.LATEST + ), + val suppressions: Matcher = Matchers.not(Matchers.anything()), +) : RoborazziAccessibilityOptions { + constructor( + preset: AccessibilityCheckPreset, + suppressions: Matcher = Matchers.not(Matchers.anything()), + ) : this( + checks = AccessibilityCheckPreset.getAccessibilityHierarchyChecksForPreset(preset), + suppressions = suppressions, + ) + + internal sealed interface CheckNode { + data class View(val viewInteraction: ViewInteraction) : CheckNode + data class Compose(val semanticsNodeInteraction: SemanticsNodeInteraction) : CheckNode + } + + + @ExperimentalRoborazziApi + enum class CheckLevel(private vararg val failedTypes: AccessibilityCheckResultType) { + Error(AccessibilityCheckResultType.ERROR), + + Warning( + AccessibilityCheckResultType.ERROR, AccessibilityCheckResultType.WARNING + ), + + LogOnly; + + fun isFailure(type: AccessibilityCheckResultType): Boolean = failedTypes.contains(type) + } + + + @SuppressLint("VisibleForTests") + internal fun runAccessibilityChecks( + checkNode: CheckNode, + roborazziOptions: RoborazziOptions, + failureLevel: CheckLevel, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + roborazziErrorLog("Skipping accessibilityChecks on API " + Build.VERSION.SDK_INT + "(< ${Build.VERSION_CODES.UPSIDE_DOWN_CAKE})") + return + } + + if (Build.FINGERPRINT == "robolectric") { + // TODO remove this once ATF doesn't bail out + // https://github.com/google/Accessibility-Test-Framework-for-Android/blob/c65cab02b2a845c29c3da100d6adefd345a144e3/src/main/java/com/google/android/apps/common/testing/accessibility/framework/uielement/AccessibilityHierarchyAndroid.java#L667 + ShadowBuild.setFingerprint("roborazzi") + } + + if (checkNode is CheckNode.Compose) { + val view = + (checkNode.semanticsNodeInteraction.fetchSemanticsNode().root as ViewRootForTest).view + + // Will throw based on configuration + + val screenshot: Bitmap? = RoboComponent.Compose( + node = checkNode.semanticsNodeInteraction.fetchSemanticsNode(), + roborazziOptions = roborazziOptions + ).image + + val results = runAllChecks( + view = view, screenshotBitmap = screenshot, checks = checks, suppressions = suppressions + ) + + reportResults(results, failureLevel) + + } else if (checkNode is CheckNode.View) { + val viewInteraction = checkNode.viewInteraction + // Use perform to get the view + viewInteraction.perform(object : ViewAction { + override fun getDescription(): String { + return "Run accessibility checks" + } + + override fun getConstraints(): Matcher { + return Matchers.any(View::class.java) + } + + override fun perform(uiController: UiController?, view: View?) { + if (view == null) { + throw IllegalStateException("View is null") + } + val results = runAllChecks( + view = view, + screenshotBitmap = RoboComponent.View(view, roborazziOptions).image, + checks = checks, + suppressions = suppressions + ) + reportResults(results, failureLevel) + } + }) + } + } + + @RequiresApi(34) + internal fun runAllChecks( + view: View, + screenshotBitmap: Bitmap?, + checks: Set, + suppressions: Matcher, + ): List { + val parameters = Parameters().apply { + if (screenshotBitmap != null) { + putScreenCapture(BitmapImage(screenshotBitmap)) + } + setSaveViewImages(true) + } + + val viewChecker = ViewChecker().apply { + setObtainCharacterLocations(true) + } + + val results = viewChecker.runChecksOnView(ImmutableSet.copyOf(checks), view, parameters) + + return results.filter { + !suppressions.matches(it) + } + } + + + private fun reportResults( + results: List, failureLevel: CheckLevel + ) { + // Report on any warnings in the log output if not failing + results.forEach { check -> + when (check.type) { + AccessibilityCheckResultType.ERROR -> roborazziErrorLog("Error: $check") + AccessibilityCheckResultType.WARNING -> roborazziErrorLog( + "Warning: $check" + ) + + AccessibilityCheckResultType.INFO -> roborazziReportLog( + "Info: $check" + ) + + else -> {} + } + } + + val failures = results.filter { failureLevel.isFailure(it.type) } + if (failures.isNotEmpty()) { + throw AccessibilityViewCheckException(failures.toMutableList()) + } + } + + companion object +} + +@ExperimentalRoborazziApi +data class AccessibilityCheckAfterTestStrategy( + val accessibilityOptionsFactory: () -> RoborazziATFAccessibilityCheckOptions = { provideATFAccessibilityOptionsOrCreateDefault() }, +) : RoborazziRule.AccessibilityCheckStrategy { + override fun runAccessibilityChecks( + captureRoot: CaptureRoot, roborazziOptions: RoborazziOptions + ) { + val accessibilityOptions = accessibilityOptionsFactory() + accessibilityOptions + .checker + .runAccessibilityChecks( + checkNode = when (captureRoot) { + is CaptureRoot.Compose -> RoborazziATFAccessibilityChecker.CheckNode.Compose( + semanticsNodeInteraction = captureRoot.semanticsNodeInteraction + ) + + CaptureRoot.None -> return + is CaptureRoot.View -> RoborazziATFAccessibilityChecker.CheckNode.View( + viewInteraction = captureRoot.viewInteraction + ) + }, + roborazziOptions = roborazziOptions, + failureLevel = accessibilityOptions.failureLevel, + ) + } +} diff --git a/roborazzi-accessibility-check/src/test/java/com/github/takahirom/roborazzi/ExampleUnitTest.kt b/roborazzi-accessibility-check/src/test/java/com/github/takahirom/roborazzi/ExampleUnitTest.kt new file mode 100644 index 000000000..426caaf20 --- /dev/null +++ b/roborazzi-accessibility-check/src/test/java/com/github/takahirom/roborazzi/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.github.takahirom.roborazzi + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt index f23c3a0e1..b4b6c4c28 100644 --- a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt +++ b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt @@ -1,3 +1,5 @@ +@file:OptIn(InternalRoborazziApi::class) + package io.github.takahirom.roborazzi import androidx.compose.ui.graphics.PixelMap @@ -9,9 +11,10 @@ import androidx.compose.ui.test.captureToImage import com.github.takahirom.roborazzi.CaptureResult import com.github.takahirom.roborazzi.CaptureResults import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.InternalRoborazziApi import com.github.takahirom.roborazzi.RoborazziTaskType import com.github.takahirom.roborazzi.getReportFileName -import com.github.takahirom.roborazzi.reportLog +import com.github.takahirom.roborazzi.roborazziReportLog import com.github.takahirom.roborazzi.roborazziSystemPropertyOutputDirectory import com.github.takahirom.roborazzi.roborazziSystemPropertyProjectPath import com.github.takahirom.roborazzi.roborazziSystemPropertyResultDirectory @@ -209,7 +212,7 @@ private fun unpremultiplyAlpha(cgImage: CGImageRef): CGImageRef? { newImage: UIImage ): CGImageRef? { if (goldenImage == null) { - reportLog("CompareImage Golden image is null") + roborazziReportLog("CompareImage Golden image is null") return newImage.CIImage?.CGImage } @@ -240,7 +243,7 @@ private fun unpremultiplyAlpha(cgImage: CGImageRef): CGImageRef? { bitmapInfo ) if (context == null) { - reportLog("CompareImage Failed to create context") + roborazziReportLog("CompareImage Failed to create context") return null } @@ -333,7 +336,7 @@ private fun unpremultiplyAlpha(cgImage: CGImageRef): CGImageRef? { val cgBitmapContextCreateImage = CGBitmapContextCreateImage(context) if (cgBitmapContextCreateImage == null) { - reportLog("CompareImage Failed to create image") + roborazziReportLog("CompareImage Failed to create image") } return cgBitmapContextCreateImage } @@ -379,7 +382,7 @@ private fun unpremultiplyAlpha(cgImage: CGImageRef): CGImageRef? { newPixelIndex = newPixelIndex, ) ) { - reportLog("Pixel changed at ($x, $y) from rgba(${goldenPtr[goldenPixelIndex]}, ${goldenPtr[goldenPixelIndex + 1]}, ${goldenPtr[goldenPixelIndex + 2]}, ${goldenPtr[goldenPixelIndex + 3]}) to rgba(${newPtr[newPixelIndex]}, ${newPtr[newPixelIndex + 1]}, ${newPtr[newPixelIndex + 2]}, ${newPtr[newPixelIndex + 3]})") + roborazziReportLog("Pixel changed at ($x, $y) from rgba(${goldenPtr[goldenPixelIndex]}, ${goldenPtr[goldenPixelIndex + 1]}, ${goldenPtr[goldenPixelIndex + 2]}, ${goldenPtr[goldenPixelIndex + 3]}) to rgba(${newPtr[newPixelIndex]}, ${newPtr[newPixelIndex + 1]}, ${newPtr[newPixelIndex + 2]}, ${newPtr[newPixelIndex + 3]})") val stringBuilder = StringBuilder() // properties @@ -419,7 +422,7 @@ private fun unpremultiplyAlpha(cgImage: CGImageRef): CGImageRef? { ) ) stringBuilder.appendLine("new CGImageGetBytesPerRow" + CGImageGetBytesPerRow(newCgImage)) - reportLog(stringBuilder.toString()) + roborazziReportLog(stringBuilder.toString()) return true } @@ -697,7 +700,7 @@ private fun writeImage(newImage: UIImage, path: String) { path, true ) - reportLog("Image is saved $path") + roborazziReportLog("Image is saved $path") } private fun loadGoldenImage( @@ -709,11 +712,11 @@ private fun loadGoldenImage( @Suppress("USELESS_CAST") val image: UIImage? = UIImage(filePath) as UIImage? if (image == null) { - reportLog("can't load reference image from $filePath") + roborazziReportLog("can't load reference image from $filePath") } val goldenImage = image ?.let { convertImageFormat(it) } if (goldenImage == null) { - reportLog("can't convert reference image from $filePath") + roborazziReportLog("can't convert reference image from $filePath") } return goldenImage } @@ -759,7 +762,7 @@ private fun writeJson( ), atomically = true ) - reportLog( + roborazziReportLog( "Report file is saved ${ getReportFileName( absolutePath = resultsDir, diff --git a/roborazzi-junit-rule/build.gradle b/roborazzi-junit-rule/build.gradle index 8c9a8b642..ae3504a60 100644 --- a/roborazzi-junit-rule/build.gradle +++ b/roborazzi-junit-rule/build.gradle @@ -39,7 +39,7 @@ android { dependencies { implementation project(':roborazzi') implementation libs.androidx.test.ext.junit.ktx - compileOnly libs.androidx.test.ext.junit.ktx + compileOnly libs.robolectric compileOnly libs.androidx.compose.ui.test compileOnly libs.androidx.compose.ui.test.junit4 } \ No newline at end of file diff --git a/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt b/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt index 521819723..bbc6d4972 100644 --- a/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt +++ b/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt @@ -56,8 +56,28 @@ class RoborazziRule private constructor( val outputFileProvider: FileProvider = provideRoborazziContext().fileProvider ?: defaultFileProvider, val roborazziOptions: RoborazziOptions = provideRoborazziContext().options, + val roborazziAccessibilityOptions: RoborazziAccessibilityOptions = provideRoborazziContext().roborazziAccessibilityOptions, + val accessibilityCheckStrategy: AccessibilityCheckStrategy = AccessibilityCheckStrategy.None, ) + @ExperimentalRoborazziApi + interface AccessibilityCheckStrategy { + @InternalRoborazziApi + fun runAccessibilityChecks( + captureRoot: CaptureRoot, + roborazziOptions: RoborazziOptions, + ) + // Use `roborazzi-accessibility-check`'s [com.github.takahirom.roborazzi.AccessibilityCheckAfterTestStrategy] + data object None : AccessibilityCheckStrategy { + override fun runAccessibilityChecks( + captureRoot: CaptureRoot, + roborazziOptions: RoborazziOptions + ) { + // Do nothing + } + } + } + sealed interface CaptureType { /** * Do not generate images. Just provide the image path to [captureRoboImage]. @@ -95,7 +115,8 @@ class RoborazziRule private constructor( ) : CaptureType } - internal sealed interface CaptureRoot { + @InternalRoborazziApi + sealed interface CaptureRoot { object None : CaptureRoot class Compose( val composeRule: ComposeTestRule, @@ -141,12 +162,14 @@ class RoborazziRule private constructor( provideRoborazziContext().setRuleOverrideRoborazziOptions(options.roborazziOptions) provideRoborazziContext().setRuleOverrideFileProvider(options.outputFileProvider) provideRoborazziContext().setRuleOverrideDescription(description) + provideRoborazziContext().setRuleOverrideAccessibilityOptions(options.roborazziAccessibilityOptions) runTest(base, description, captureRoot) } finally { provideRoborazziContext().clearRuleOverrideOutputDirectory() provideRoborazziContext().clearRuleOverrideRoborazziOptions() provideRoborazziContext().clearRuleOverrideFileProvider() provideRoborazziContext().clearRuleOverrideDescription() + provideRoborazziContext().clearRuleOverrideAccessibilityOptions() } } } @@ -157,9 +180,18 @@ class RoborazziRule private constructor( description: Description, captureRoot: CaptureRoot ) { - val evaluate = { + val evaluate: () -> Unit = { try { + val accessibilityChecks = options.accessibilityCheckStrategy + // TODO enable a11y before showing content + base.evaluate() + + accessibilityChecks.runAccessibilityChecks( + captureRoot = captureRoot, + roborazziOptions = options.roborazziOptions + ) + } catch (e: Exception) { throw e } @@ -254,6 +286,5 @@ class RoborazziRule private constructor( } } } - } } \ No newline at end of file diff --git a/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt b/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt index 336b0c126..e42725547 100644 --- a/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt +++ b/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt @@ -467,7 +467,7 @@ private fun saveLastImage( ) { val roboCanvas = canvases.lastOrNull() if (roboCanvas == null) { - println("Roborazzi could not capture for this test") + roborazziErrorLog("Roborazzi could not capture for this test") return } processOutputImageAndReportWithDefaults( diff --git a/sample-android/build.gradle b/sample-android/build.gradle index 6a967196a..7f82c8842 100644 --- a/sample-android/build.gradle +++ b/sample-android/build.gradle @@ -64,6 +64,7 @@ dependencies { testImplementation(project(":roborazzi-ai-openai")) testImplementation project(":roborazzi-compose") testImplementation project(":roborazzi-junit-rule") + testImplementation project(":roborazzi-accessibility-check") implementation libs.androidx.compose.material3 implementation libs.androidx.compose.ui diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yTest.kt new file mode 100644 index 000000000..19be6f919 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yTest.kt @@ -0,0 +1,222 @@ +package com.github.takahirom.roborazzi.sample + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.AccessibilityCheckAfterTestStrategy +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.github.takahirom.roborazzi.checkRoboAccessibility +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesElements +import com.google.android.apps.common.testing.accessibility.framework.matcher.ElementMatchers.withTestTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4, sdk = [35]) +class ComposeA11yTest { + @Suppress("DEPRECATION") + @get:Rule(order = Int.MIN_VALUE) + var thrown: ExpectedException = ExpectedException.none() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val roborazziRule = RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = Options( + roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( + RoborazziATFAccessibilityChecker( + preset = AccessibilityCheckPreset.LATEST, + suppressions = matchesElements(withTestTag("suppress")), + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning, + ), + accessibilityCheckStrategy = AccessibilityCheckAfterTestStrategy() + ) + ) + + @Test + fun clickableWithoutSemantics() { + thrown.expectMessage("SpeakableTextPresentCheck") + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(48.dp) + .background(Color.Black) + .clickable {}) + } + } + } + + @Test + fun boxWithEmptyContentDescription() { + thrown.expectMessage("SpeakableTextPresentCheck") + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(48.dp) + .background(Color.Black) + .semantics { + contentDescription = "" + }) + } + } + } + + @Test + fun smallClickable() { + // for(ViewHierarchyElement view : getElementsToEvaluate(fromRoot, hierarchy)) { + // if (!Boolean.TRUE.equals(view.isClickable()) && !Boolean.TRUE.equals(view.isLongClickable())) { + // TODO investigate +// thrown.expectMessage("TouchTargetSizeCheck") + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Button( + onClick = {}, modifier = Modifier + .size(30.dp) + .testTag("clickable") + ) { + Text("Something to Click") + } + } + } + + composeTestRule.onNodeWithTag("clickable").assertHasClickAction() + } + + @Test + fun clickableBox() { + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Button(onClick = {}) { + Text("Something to Click") + } + } + } + } + + @Test + fun supressionsTakeEffect() { + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(48.dp) + .background(Color.Black) + .testTag("suppress") + .semantics { + contentDescription = "" + }) + } + } + } + + @Test + fun faintText() { + thrown.expectMessage("TextContrastCheck") + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(100.dp) + .background(Color.DarkGray) + ) { + Text("Something hard to read", color = Color.DarkGray) + } + } + } + } + + @Test + fun normalText() { + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(100.dp) + .background(Color.DarkGray) + ) { + Text("Something not hard to read", color = Color.White) + } + } + } + } + + @Test + fun composableOnly() { + composeTestRule.setContent { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(100.dp) + .background(Color.DarkGray) + .testTag("nothard") + ) { + Text("Something not hard to read", color = Color.White) + } + // Use a single text otherwise the Box and Text both trigger and must be suppressed + Text( + modifier = Modifier + .size(100.dp) + .background(Color.DarkGray) + .testTag("suppress"), + text = "Something hard to read", + color = Color.DarkGray + ) + } + } + + // Now run without suppressions + // Run only against nothard, shouldn't fail because of the hard to read text + composeTestRule.onNodeWithTag("nothard").checkRoboAccessibility( + roborazziATFAccessibilityCheckOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + preset = AccessibilityCheckPreset.LATEST, + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ) + ) + } +} + diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yWithCustomCheckTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yWithCustomCheckTest.kt new file mode 100644 index 000000000..b35f32859 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yWithCustomCheckTest.kt @@ -0,0 +1,191 @@ +package com.github.takahirom.roborazzi.sample + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.AccessibilityCheckAfterTestStrategy +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType.ERROR +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType.INFO +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType.NOT_RUN +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesElements +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheck +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheckResult +import com.google.android.apps.common.testing.accessibility.framework.HashMapResultMetadata +import com.google.android.apps.common.testing.accessibility.framework.Parameters +import com.google.android.apps.common.testing.accessibility.framework.ResultMetadata +import com.google.android.apps.common.testing.accessibility.framework.matcher.ElementMatchers.withTestTag +import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchy +import com.google.android.apps.common.testing.accessibility.framework.uielement.ViewHierarchyElement +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.Locale + +/** + * Test demonstrating a completely custom ATF Check. Expected to be a niche usecase, but critical when required. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4, sdk = [35]) +class ComposeA11yWithCustomCheckTest { + @Suppress("DEPRECATION") + @get:Rule(order = Int.MIN_VALUE) + var thrown: ExpectedException = ExpectedException.none() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val roborazziRule = RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = Options( + roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + checks = setOf(NoRedTextCheck()), + suppressions = matchesElements(withTestTag("suppress")) + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ), + accessibilityCheckStrategy = AccessibilityCheckAfterTestStrategy(), + ) + ) + + /** + * Custom Check that demonstrates accessing the screenshot and element data. + */ + class NoRedTextCheck : AccessibilityHierarchyCheck() { + override fun getHelpTopic(): String? = null + + override fun getCategory(): Category = Category.IMPLEMENTATION + + override fun getTitleMessage(locale: Locale): String = "No Red Text" + + override fun getMessageForResultData(locale: Locale, p1: Int, metadata: ResultMetadata?): String = + "No Red Text $metadata" + + override fun getShortMessageForResultData(locale: Locale, p1: Int, metadata: ResultMetadata?): String = + "No Red Text $metadata" + + override fun runCheckOnHierarchy( + hierarchy: AccessibilityHierarchy, + element: ViewHierarchyElement?, + parameters: Parameters? + ): List { + return getElementsToEvaluate(element, hierarchy).map { childElement -> + val textColors = primaryTextColors(childElement, parameters) + + if (textColors == null) { + result(childElement, NOT_RUN, 1, null) + } else if (textColors.find { it.isMostlyRed() } != null) { + result(childElement, ERROR, 3, textColors) + } else { + result(childElement, INFO, 3, textColors) + } + } + } + + private fun primaryTextColors( + childElement: ViewHierarchyElement, + parameters: Parameters? + ): Set? = if (childElement.text == null) { + null + } else if (childElement.isVisibleToUser != true) { + null + } else { + val textColor = childElement.textColor + if (textColor != null) { + setOf(Color(textColor)) + } else { + val screenCapture = parameters?.screenCapture + val textCharacterLocations = childElement.textCharacterLocations + + if (screenCapture == null || textCharacterLocations.isEmpty()) { + null + } else { + textCharacterLocations.flatMap { rect -> + screenCapture.crop(rect.left, rect.top, rect.width, rect.height).pixels.asSequence() + }.distinct().map { Color(it) }.toSet() + } + } + } + + private fun Color.isMostlyRed(): Boolean { + return red > 0.8f && blue < 0.2f && green < 0.2f + } + + private fun result( + childElement: ViewHierarchyElement?, + result: AccessibilityCheckResultType, + resultId: Int, + textColors: Iterable? + ) = CustomAccessibilityHierarchyCheckResult( + this::class.java, + result, + childElement, + resultId, + HashMapResultMetadata().apply { + if (textColors != null) { + putString("textColors", textColors.joinToString { "0x${it.toArgb().toUInt().toString(16)}" }) + } + } + ) + } + + // TODO fix after https://github.com/google/Accessibility-Test-Framework-for-Android/issues/64 + class CustomAccessibilityHierarchyCheckResult( + val checkClass: Class, + type: AccessibilityCheckResultType, + element: ViewHierarchyElement?, + resultId: Int, + metadata: ResultMetadata? + ) : AccessibilityHierarchyCheckResult(checkClass, type, element, resultId, metadata) { + override fun getMessage(locale: Locale?): CharSequence = + (checkClass.newInstance()).getMessageForResult(locale, this) + } + + @Test + fun redText() { + thrown.expectMessage("NoRedTextCheck") + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(100.dp).background(Color.DarkGray)) { + Text("Something red and inappropriate", color = Color.Red) + } + } + } + } + + @Test + fun normalText() { + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(100.dp).background(Color.White)) { + Text("Something boring and black", color = Color.Black) + } + } + } + } +} + diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ViewA11yTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ViewA11yTest.kt new file mode 100644 index 000000000..7a8bad220 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ViewA11yTest.kt @@ -0,0 +1,198 @@ +package com.github.takahirom.roborazzi.sample + +import android.graphics.Color +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.AccessibilityCheckAfterTestStrategy +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.github.takahirom.roborazzi.checkRoboAccessibility +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesElements +import com.google.android.apps.common.testing.accessibility.framework.matcher.ElementMatchers.withTestTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4, sdk = [35]) +class ViewA11yTest { + @Suppress("DEPRECATION") + @get:Rule(order = Int.MIN_VALUE) + var thrown: ExpectedException = ExpectedException.none() + + @get:Rule + val activityScenarioRule = ActivityScenarioRule(ComponentActivity::class.java) + + @get:Rule + val roborazziRule = RoborazziRule( + captureRoot = Espresso.onView(ViewMatchers.isRoot()), + options = Options( + roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + preset = AccessibilityCheckPreset.LATEST, + suppressions = matchesElements(withTestTag("suppress")) + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ), + accessibilityCheckStrategy = AccessibilityCheckAfterTestStrategy() + ) + ) + + @Test + fun clickableWithoutSemantics() { + thrown.expectMessage("SpeakableTextPresentCheck") + + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + addView( + View(activity).apply { + setBackgroundColor(Color.BLACK) + setOnClickListener { } + } + ) + } + ) + } + } + + @Test + fun smallClickable() { + // for(ViewHierarchyElement view : getElementsToEvaluate(fromRoot, hierarchy)) { + // if (!Boolean.TRUE.equals(view.isClickable()) && !Boolean.TRUE.equals(view.isLongClickable())) { + // TODO investigate +// thrown.expectMessage("TouchTargetSizeCheck") + + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + addView( + View(activity).apply { + contentDescription = "clickable" + setBackgroundColor(Color.BLACK) + setOnClickListener { } + } + ) + } + ) + } + onView(ViewMatchers.withContentDescription("clickable")).check(ViewAssertions.matches(ViewMatchers.isClickable())) + } + + @Test + fun clickableBox() { + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + setBackgroundColor(Color.WHITE) + addView( + TextView(activity).apply { + text = "Something to Click" + setTextColor(Color.BLACK) + setOnClickListener { } + } + ) + } + ) + } + } + + + @Test + fun checkRoboAccessibilityCheck() { + thrown.expectMessage("TextContrastCheck") + activityScenarioRule.scenario.onActivity { activity -> + val frameLayout = FrameLayout(activity) + activity.setContentView( + frameLayout.apply { + setBackgroundColor(Color.BLACK) + addView( + TextView(activity).apply { + text = "Something hard to read" + setTextColor(Color.DKGRAY) + } + ) + } + ) + onView(ViewMatchers.isRoot()) + .checkRoboAccessibility() + frameLayout.removeAllViews() + } + } + + @Test + fun supressionsTakeEffect() { + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + addView( + View(activity).apply { + setBackgroundColor(Color.BLACK) + contentDescription = "" + tag = "suppress" + } + ) + } + ) + } + } + + @Test + fun faintText() { + thrown.expectMessage("TextContrastCheck") + + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + setBackgroundColor(Color.BLACK) + addView( + TextView(activity).apply { + text = "Something hard to read" + setTextColor(Color.DKGRAY) + } + ) + } + ) + } + } + + @Test + fun normalText() { + activityScenarioRule.scenario.onActivity { activity -> + activity.setContentView( + FrameLayout(activity).apply { + id = android.R.id.content + setBackgroundColor(Color.DKGRAY) + addView( + TextView(activity).apply { + text = "Something not hard to read" + setTextColor(Color.WHITE) + } + ) + } + ) + } + } +} + diff --git a/settings.gradle b/settings.gradle index 76a15c9b9..9ce4c11f6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,7 @@ dependencyResolutionManagement { rootProject.name = "roborazzi-root" include ':roborazzi' include ':roborazzi-junit-rule' +include ':roborazzi-accessibility-check' include ':roborazzi-compose-desktop' include ':roborazzi-compose-ios' include ':roborazzi-compose'