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'