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 index 3862b8e9..2a0c1ca4 100644 --- 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 @@ -10,7 +10,8 @@ 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.github.takahirom.roborazzi.RoborazziRule.AccessibilityCheckStrategy +import com.github.takahirom.roborazzi.RoborazziRule.RuleCaptureRoot 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 @@ -78,7 +79,6 @@ data class RoborazziATFAccessibilityChecker( data class Compose(val semanticsNodeInteraction: SemanticsNodeInteraction) : CheckNode } - @ExperimentalRoborazziApi enum class CheckLevel(private vararg val failedTypes: AccessibilityCheckResultType) { Error(AccessibilityCheckResultType.ERROR), @@ -226,6 +226,7 @@ data class RoborazziATFAccessibilityChecker( AccessibilityCheckResultType.WARNING -> roborazziErrorLog( "Warning: $check" ) + AccessibilityCheckResultType.SUPPRESSED -> roborazziReportLog( "Suppressed: $check" ) @@ -248,24 +249,26 @@ data class RoborazziATFAccessibilityChecker( } @ExperimentalRoborazziApi -data class AccessibilityCheckAfterTestStrategy( - val accessibilityOptionsFactory: () -> RoborazziATFAccessibilityCheckOptions = { provideATFAccessibilityOptionsOrCreateDefault() }, -) : RoborazziRule.AccessibilityCheckStrategy { - override fun runAccessibilityChecks( - captureRoot: CaptureRoot, roborazziOptions: RoborazziOptions +abstract class BaseAccessibilityCheckStrategy : AccessibilityCheckStrategy { + abstract val accessibilityOptionsFactory: () -> RoborazziATFAccessibilityCheckOptions + + private val accessibilityOptions by lazy { accessibilityOptionsFactory() } + + @InternalRoborazziApi + fun runAccessibilityChecks( + ruleCaptureRoot: RuleCaptureRoot, roborazziOptions: RoborazziOptions ) { - val accessibilityOptions = accessibilityOptionsFactory() accessibilityOptions .checker .runAccessibilityChecks( - checkNode = when (captureRoot) { - is CaptureRoot.Compose -> RoborazziATFAccessibilityChecker.CheckNode.Compose( - semanticsNodeInteraction = captureRoot.semanticsNodeInteraction + checkNode = when (ruleCaptureRoot) { + is RuleCaptureRoot.Compose -> RoborazziATFAccessibilityChecker.CheckNode.Compose( + semanticsNodeInteraction = ruleCaptureRoot.semanticsNodeInteraction ) - CaptureRoot.None -> return - is CaptureRoot.View -> RoborazziATFAccessibilityChecker.CheckNode.View( - viewInteraction = captureRoot.viewInteraction + RuleCaptureRoot.None -> return + is RuleCaptureRoot.View -> RoborazziATFAccessibilityChecker.CheckNode.View( + viewInteraction = ruleCaptureRoot.viewInteraction ) }, roborazziOptions = roborazziOptions, @@ -273,3 +276,26 @@ data class AccessibilityCheckAfterTestStrategy( ) } } + +@ExperimentalRoborazziApi +data class AccessibilityCheckEachScreenshotStrategy( + override val accessibilityOptionsFactory: () -> RoborazziATFAccessibilityCheckOptions = { provideATFAccessibilityOptionsOrCreateDefault() }, +) : BaseAccessibilityCheckStrategy() { + override fun afterScreenshot(ruleCaptureRoot: RuleCaptureRoot, roborazziOptions: RoborazziOptions) { + runAccessibilityChecks(ruleCaptureRoot, roborazziOptions) + } + + override fun afterTest(ruleCaptureRoot: RuleCaptureRoot, roborazziOptions: RoborazziOptions) { + runAccessibilityChecks(ruleCaptureRoot, roborazziOptions) + } +} + +@ExperimentalRoborazziApi +data class AccessibilityCheckAfterTestStrategy( + override val accessibilityOptionsFactory: () -> RoborazziATFAccessibilityCheckOptions = { provideATFAccessibilityOptionsOrCreateDefault() }, +) : BaseAccessibilityCheckStrategy() { + + override fun afterTest(ruleCaptureRoot: RuleCaptureRoot, roborazziOptions: RoborazziOptions) { + runAccessibilityChecks(ruleCaptureRoot, roborazziOptions) + } +} 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 b78cc9db..81b2b38a 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 @@ -25,7 +25,7 @@ private val defaultFileProvider: FileProvider = * 2. Capture screenshots for each test when specifying RoborazziRule.options.captureType. */ class RoborazziRule private constructor( - private val captureRoot: CaptureRoot, + private val ruleCaptureRoot: RuleCaptureRoot, private val options: Options = Options() ) : TestWatcher() { init { @@ -82,20 +82,11 @@ class RoborazziRule private constructor( @ExperimentalRoborazziApi interface AccessibilityCheckStrategy { - @InternalRoborazziApi - fun runAccessibilityChecks( - captureRoot: CaptureRoot, - roborazziOptions: RoborazziOptions, - ) + fun afterScreenshot(ruleCaptureRoot: RuleCaptureRoot, roborazziOptions: RoborazziOptions) {} + fun afterTest(ruleCaptureRoot: RuleCaptureRoot, 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 - } - } + data object None : AccessibilityCheckStrategy } sealed interface CaptureType { @@ -136,21 +127,21 @@ class RoborazziRule private constructor( } @InternalRoborazziApi - sealed interface CaptureRoot { - object None : CaptureRoot + sealed interface RuleCaptureRoot { + object None : RuleCaptureRoot class Compose( val composeRule: ComposeTestRule, val semanticsNodeInteraction: SemanticsNodeInteraction - ) : CaptureRoot + ) : RuleCaptureRoot - class View(val viewInteraction: ViewInteraction) : CaptureRoot + class View(val viewInteraction: ViewInteraction) : RuleCaptureRoot } constructor( captureRoot: ViewInteraction, options: Options = Options() ) : this( - captureRoot = CaptureRoot.View(captureRoot), + ruleCaptureRoot = RuleCaptureRoot.View(captureRoot), options = options ) @@ -159,14 +150,14 @@ class RoborazziRule private constructor( captureRoot: SemanticsNodeInteraction, options: Options = Options() ) : this( - captureRoot = CaptureRoot.Compose(composeRule, captureRoot), + ruleCaptureRoot = RuleCaptureRoot.Compose(composeRule, captureRoot), options = options ) constructor( options: Options = Options() ) : this( - captureRoot = CaptureRoot.None, + ruleCaptureRoot = RuleCaptureRoot.None, options = options ) @@ -183,7 +174,7 @@ class RoborazziRule private constructor( provideRoborazziContext().setRuleOverrideFileProvider(options.outputFileProvider) provideRoborazziContext().setRuleOverrideDescription(description) provideRoborazziContext().setRuleOverrideAccessibilityOptions(options.roborazziAccessibilityOptions) - runTest(base, description, captureRoot) + runTest(base, description, ruleCaptureRoot) } finally { provideRoborazziContext().clearRuleOverrideOutputDirectory() provideRoborazziContext().clearRuleOverrideRoborazziOptions() @@ -198,20 +189,11 @@ class RoborazziRule private constructor( private fun runTest( base: Statement, description: Description, - captureRoot: CaptureRoot + ruleCaptureRoot: RuleCaptureRoot ) { 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 } @@ -239,19 +221,31 @@ class RoborazziRule private constructor( } is CaptureType.AllImage, is CaptureType.Gif -> { - val result = when (captureRoot) { - is CaptureRoot.Compose -> captureRoot.semanticsNodeInteraction.captureComposeNode( - composeRule = captureRoot.composeRule, + val result = when (ruleCaptureRoot) { + is RuleCaptureRoot.Compose -> ruleCaptureRoot.semanticsNodeInteraction.captureComposeNode( + composeRule = ruleCaptureRoot.composeRule, roborazziOptions = roborazziOptions, - block = evaluate + block = evaluate, + onEach = { + options.accessibilityCheckStrategy.afterScreenshot( + ruleCaptureRoot = ruleCaptureRoot, + roborazziOptions = options.roborazziOptions + ) + }, ) - is CaptureRoot.View -> captureRoot.viewInteraction.captureAndroidView( + is RuleCaptureRoot.View -> ruleCaptureRoot.viewInteraction.captureAndroidView( roborazziOptions = roborazziOptions, - block = evaluate + block = evaluate, + onEach = { + options.accessibilityCheckStrategy.afterScreenshot( + ruleCaptureRoot = ruleCaptureRoot, + roborazziOptions = options.roborazziOptions + ) + }, ) - CaptureRoot.None -> { + RuleCaptureRoot.None -> { error("captureRoot is required for AllImage and Gif") } } @@ -281,22 +275,27 @@ class RoborazziRule private constructor( is CaptureType.LastImage -> { val result = runCatching { evaluate() + + options.accessibilityCheckStrategy.afterTest( + ruleCaptureRoot = ruleCaptureRoot, + roborazziOptions = options.roborazziOptions + ) } if (!captureType.onlyFail || result.isFailure) { val outputFile = fileWithRecordFilePathStrategy(DefaultFileNameGenerator.generateFilePath()) - when (captureRoot) { - is CaptureRoot.Compose -> captureRoot.semanticsNodeInteraction.captureRoboImage( + when (ruleCaptureRoot) { + is RuleCaptureRoot.Compose -> ruleCaptureRoot.semanticsNodeInteraction.captureRoboImage( file = outputFile, roborazziOptions = roborazziOptions ) - is CaptureRoot.View -> captureRoot.viewInteraction.captureRoboImage( + is RuleCaptureRoot.View -> ruleCaptureRoot.viewInteraction.captureRoboImage( file = outputFile, roborazziOptions = roborazziOptions ) - CaptureRoot.None -> { + RuleCaptureRoot.None -> { error("captureRoot is required for LastImage") } } 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 e4272554..e481157b 100644 --- a/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt +++ b/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt @@ -53,7 +53,7 @@ fun ViewInteraction.captureRoboImage( roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return - perform(ImageCaptureViewAction(roborazziOptions) { canvas -> + perform(ImageCaptureViewAction(roborazziOptions) { _, canvas -> processOutputImageAndReportWithDefaults( canvas = canvas, goldenFile = file, @@ -149,7 +149,8 @@ fun captureScreenRoboImage( // Invoke rootOracle.listActiveRoots() via reflection val listActiveRoots = rootsOracle.javaClass.getMethod("listActiveRoots") listActiveRoots.isAccessible = true - @Suppress("UNCHECKED_CAST") val roots: List = listActiveRoots.invoke(rootsOracle) as List + @Suppress("UNCHECKED_CAST") val roots: List = + listActiveRoots.invoke(rootsOracle) as List debugLog { "captureScreenRoboImage roots: ${roots.joinToString("\n") { it.toString() }}" } @@ -159,7 +160,7 @@ fun captureScreenRoboImage( roborazziOptions = roborazziOptions ), roborazziOptions = roborazziOptions, - ) { canvas -> + ) { _, canvas -> processOutputImageAndReportWithDefaults( canvas = canvas, goldenFile = file, @@ -222,7 +223,7 @@ fun ViewInteraction.captureRoboGif( ) { // currently, gif compare is not supported if (!roborazziOptions.taskType.isRecording()) return - captureAndroidView(roborazziOptions, block).apply { + captureAndroidView(roborazziOptions = roborazziOptions, onEach = {}, block = block).apply { saveGif(file) clear() result.getOrThrow() @@ -244,7 +245,7 @@ fun ViewInteraction.captureRoboLastImage( block: () -> Unit ) { if (!roborazziOptions.taskType.isEnabled()) return - captureAndroidView(roborazziOptions, block).apply { + captureAndroidView(roborazziOptions = roborazziOptions, onEach = {}, block = block).apply { saveLastImage(file) clear() result.getOrThrow() @@ -257,7 +258,7 @@ fun ViewInteraction.captureRoboAllImage( block: () -> Unit ) { if (!roborazziOptions.taskType.isEnabled()) return - captureAndroidView(roborazziOptions, block).apply { + captureAndroidView(roborazziOptions = roborazziOptions, onEach = {}, block = block).apply { saveAllImage(fileNameCreator) clear() result.getOrThrow() @@ -284,7 +285,7 @@ fun SemanticsNodeInteraction.captureRoboImage( roborazziOptions = roborazziOptions ), roborazziOptions = roborazziOptions, - ) { canvas -> + ) { _, canvas -> processOutputImageAndReportWithDefaults( canvas = canvas, goldenFile = file, @@ -321,7 +322,12 @@ fun SemanticsNodeInteraction.captureRoboGif( ) { // currently, gif compare is not supported if (!roborazziOptions.taskType.isRecording()) return - captureComposeNode(composeRule, roborazziOptions, block).apply { + captureComposeNode( + composeRule = composeRule, + roborazziOptions = roborazziOptions, + onEach = {}, + block = block + ).apply { saveGif(file) clear() result.getOrThrow() @@ -340,6 +346,7 @@ class CaptureInternalResult( @InternalRoborazziApi fun ViewInteraction.captureAndroidView( roborazziOptions: RoborazziOptions, + onEach: () -> Unit = {}, block: () -> Unit ): CaptureInternalResult { var removeListener = {} @@ -350,8 +357,10 @@ fun ViewInteraction.captureAndroidView( val listener = ViewTreeObserver.OnGlobalLayoutListener { handler.postAtFrontOfQueue { this@captureAndroidView.perform( - ImageCaptureViewAction(roborazziOptions) { canvas -> - canvases.addIfChanged(canvas, roborazziOptions) + ImageCaptureViewAction(roborazziOptions) { _, canvas -> + if (canvases.addIfChanged(canvas, roborazziOptions)) { + onEach() + } } ) } @@ -403,8 +412,10 @@ fun ViewInteraction.captureAndroidView( try { // If there is already a screen, we should take the screenshot first not to miss the frame. perform( - ImageCaptureViewAction(roborazziOptions) { canvas -> - canvases.addIfChanged(canvas, roborazziOptions) + ImageCaptureViewAction(roborazziOptions) { _, canvas -> + if (canvases.addIfChanged(canvas, roborazziOptions)) { + onEach() + } } ) perform(viewTreeListenerAction) @@ -445,18 +456,20 @@ fun ViewInteraction.captureAndroidView( private fun MutableList.addIfChanged( next: AwtRoboCanvas, roborazziOptions: RoborazziOptions -) { +): Boolean { val prev = this.lastOrNull() ?: run { this.add(next) - return + return true } val differ: ImageComparator.ComparisonResult = prev.differ(next, 1.0, roborazziOptions.compareOptions.imageComparator) if (!roborazziOptions.compareOptions.resultValidator(differ)) { this.add(next) + return true } else { // If the image is not changed, we should release the image. next.release() + return false } } @@ -481,6 +494,7 @@ private fun saveLastImage( fun SemanticsNodeInteraction.captureComposeNode( composeRule: ComposeTestRule, roborazziOptions: RoborazziOptions = provideRoborazziContext().options, + onEach: () -> Unit = {}, block: () -> Unit ): CaptureInternalResult { val canvases = mutableListOf() @@ -492,8 +506,10 @@ fun SemanticsNodeInteraction.captureComposeNode( roborazziOptions ), roborazziOptions = roborazziOptions - ) { - canvases.addIfChanged(it, roborazziOptions) + ) { _, canvas -> + if (canvases.addIfChanged(canvas, roborazziOptions)) { + onEach() + } } } val handler = Handler(Looper.getMainLooper()) @@ -577,7 +593,7 @@ private fun saveAllImage( private class ImageCaptureViewAction( val roborazziOptions: RoborazziOptions, - val saveAction: (AwtRoboCanvas) -> Unit + val saveAction: (RoboComponent, AwtRoboCanvas) -> Unit ) : ViewAction { override fun getConstraints(): Matcher { @@ -603,7 +619,7 @@ private class ImageCaptureViewAction( internal fun capture( rootComponent: RoboComponent, roborazziOptions: RoborazziOptions, - onCanvas: (AwtRoboCanvas) -> Unit + onCanvas: (RoboComponent, AwtRoboCanvas) -> Unit ) { when (roborazziOptions.captureType) { is Dump -> captureDump( @@ -617,6 +633,7 @@ internal fun capture( val image = rootComponent.image ?: throw IllegalStateException("Unable to find the image of the target root component. Does the rendering element exist?") onCanvas( + rootComponent, AwtRoboCanvas( width = image.width, height = image.height, diff --git a/roborazzi/src/main/java/com/github/takahirom/roborazzi/captureDump.kt b/roborazzi/src/main/java/com/github/takahirom/roborazzi/captureDump.kt index 24fcb38c..bd330e72 100644 --- a/roborazzi/src/main/java/com/github/takahirom/roborazzi/captureDump.kt +++ b/roborazzi/src/main/java/com/github/takahirom/roborazzi/captureDump.kt @@ -23,7 +23,7 @@ internal fun captureDump( rootComponent: RoboComponent, dumpOptions: Dump, recordOptions: RoborazziOptions.RecordOptions, - onCanvas: (AwtRoboCanvas) -> Unit + onCanvas: (RoboComponent, AwtRoboCanvas) -> Unit ) { // val start = System.currentTimeMillis() val basicSize = dumpOptions.basicSize @@ -155,7 +155,7 @@ internal fun captureDump( } } bfs() - onCanvas(canvas) + onCanvas(rootComponent, canvas) // val end = System.currentTimeMillis() // println("roborazzi takes " + (end - start) + "ms") } diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yAfterScreenshotTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yAfterScreenshotTest.kt new file mode 100644 index 00000000..a9b6f856 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ComposeA11yAfterScreenshotTest.kt @@ -0,0 +1,139 @@ +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.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableIntStateOf +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.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.AccessibilityCheckEachScreenshotStrategy +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +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.RoborazziTaskType +import com.github.takahirom.roborazzi.roborazziSystemPropertyTaskType +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.AccessibilityHierarchyCheckResult +import com.google.android.apps.common.testing.accessibility.framework.Parameters +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.Assert.assertEquals +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 + +/** + * Test demonstrating a completely custom ATF Check. Expected to be a niche usecase, but critical when required. + */ +@OptIn(ExperimentalRoborazziApi::class) +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel4, sdk = [35]) +class ComposeA11yAfterScreenshotTest { + @Suppress("DEPRECATION") + @get:Rule(order = Int.MIN_VALUE) + var thrown: ExpectedException = ExpectedException.none() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + val textCollectingCheck = TextCollectingCheck() + + val taskType: RoborazziTaskType = roborazziSystemPropertyTaskType() + + @get:Rule + val roborazziRule = RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = Options( + captureType = RoborazziRule.CaptureType.AllImage(), + roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( + checker = RoborazziATFAccessibilityChecker( + checks = setOf(textCollectingCheck), + ), + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.Warning + ), + accessibilityCheckStrategy = AccessibilityCheckEachScreenshotStrategy(), + ) + ) + + class TextCollectingCheck : CustomAccessibilityHierarchyCheck("Text Collecting Check") { + val foundText = mutableListOf() + + override fun runCheckOnHierarchy( + hierarchy: AccessibilityHierarchy, + element: ViewHierarchyElement?, + parameters: Parameters? + ): List { + return getElementsToEvaluate(element, hierarchy).map { childElement -> + val text = childElement.text?.toString() + + if (text == null) { + result(childElement, NOT_RUN, 1, null) + } else { + foundText.add(text) + result(childElement, INFO, 3, null) + } + } + } + } + + @Test + fun takesScreenshots() { + val count = mutableIntStateOf(0) + + composeTestRule.setContent { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(modifier = Modifier + .size(100.dp) + .background(Color.White)) { + Text("Clicks: ${count.intValue}", color = Color.Black) + Spacer(modifier = Modifier.size(10.dp * count.intValue)) + Button(onClick = { + count.intValue++ + }, modifier = Modifier.testTag("increment")) { + Icon(Icons.Filled.Add, contentDescription = null) + } + } + } + } + + composeTestRule.onNodeWithTag("increment").performClick() + composeTestRule.waitUntil { count.intValue == 1 } + + composeTestRule.onNodeWithTag("increment").performClick() + composeTestRule.waitUntil { count.intValue == 2 } + + if (taskType.isEnabled()) { + // Last check happens after test + assertEquals(listOf("Clicks: 0", "Clicks: 1"), textCollectingCheck.foundText) + } else { + assertEquals(listOf(), textCollectingCheck.foundText) + } + } +} + 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 index 1c6753de..a6d2cadb 100644 --- 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 @@ -8,8 +8,8 @@ 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.material3.Button +import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,8 +28,11 @@ 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.CaptureType import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.github.takahirom.roborazzi.RoborazziTaskType import com.github.takahirom.roborazzi.checkRoboAccessibility +import com.github.takahirom.roborazzi.roborazziSystemPropertyTaskType 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 @@ -52,11 +55,14 @@ class ComposeA11yTest { @get:Rule val composeTestRule = createAndroidComposeRule() + val taskType: RoborazziTaskType = roborazziSystemPropertyTaskType() + @get:Rule val roborazziRule = RoborazziRule( composeRule = composeTestRule, captureRoot = composeTestRule.onRoot(), options = Options( + captureType = CaptureType.LastImage(), roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( RoborazziATFAccessibilityChecker( preset = AccessibilityCheckPreset.LATEST, @@ -70,7 +76,9 @@ class ComposeA11yTest { @Test fun clickableWithoutSemantics() { - thrown.expectMessage("SpeakableTextPresentCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("SpeakableTextPresentCheck") + } composeTestRule.setContent { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -85,7 +93,9 @@ class ComposeA11yTest { @Test fun boxWithEmptyContentDescription() { - thrown.expectMessage("SpeakableTextPresentCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("SpeakableTextPresentCheck") + } composeTestRule.setContent { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -151,7 +161,9 @@ class ComposeA11yTest { @Test fun faintText() { - thrown.expectMessage("TextContrastCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("TextContrastCheck") + } composeTestRule.setContent { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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 index 1dc13b91..fc8dc57e 100644 --- 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 @@ -5,7 +5,7 @@ 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.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -20,7 +20,10 @@ 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.CaptureType import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.github.takahirom.roborazzi.RoborazziTaskType +import com.github.takahirom.roborazzi.roborazziSystemPropertyTaskType 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 @@ -57,11 +60,14 @@ class ComposeA11yWithCustomCheckTest { @get:Rule val composeTestRule = createAndroidComposeRule() + val taskType: RoborazziTaskType = roborazziSystemPropertyTaskType() + @get:Rule val roborazziRule = RoborazziRule( composeRule = composeTestRule, captureRoot = composeTestRule.onRoot(), options = Options( + captureType = CaptureType.LastImage(), roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( checker = RoborazziATFAccessibilityChecker( checks = setOf(NoRedTextCheck()), @@ -76,19 +82,7 @@ class ComposeA11yWithCustomCheckTest { /** * 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" - + class NoRedTextCheck : CustomAccessibilityHierarchyCheck("No Red Text") { override fun runCheckOnHierarchy( hierarchy: AccessibilityHierarchy, element: ViewHierarchyElement?, @@ -98,7 +92,7 @@ class ComposeA11yWithCustomCheckTest { val textColors = primaryTextColors(childElement, parameters) if (textColors == null) { - result(childElement, NOT_RUN, 1, null) + this.result(childElement, NOT_RUN, 1, null) } else if (textColors.find { it.isMostlyRed() } != null) { result(childElement, ERROR, 3, textColors) } else { @@ -135,40 +129,13 @@ class ComposeA11yWithCustomCheckTest { 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.getDeclaredConstructor().newInstance()).getMessageForResult(locale, this) } @Test fun redText() { - thrown.expectMessage("NoRedTextCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("NoRedTextCheck") + } composeTestRule.setContent { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -191,3 +158,48 @@ class ComposeA11yWithCustomCheckTest { } } +// 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.getDeclaredConstructor().newInstance()).getMessageForResult(locale, this) +} + +abstract class CustomAccessibilityHierarchyCheck( + val name: String +) : AccessibilityHierarchyCheck() { + override fun getHelpTopic(): String? = null + + override fun getCategory(): Category = Category.IMPLEMENTATION + + override fun getTitleMessage(locale: Locale): String = name + + override fun getMessageForResultData(locale: Locale, p1: Int, metadata: ResultMetadata?): String = + "$name $metadata" + + override fun getShortMessageForResultData(locale: Locale, p1: Int, metadata: ResultMetadata?): String = + "$name $metadata" + + protected 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)}" }) + } + } + ) +} + 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 index a1b4d754..0576feb4 100644 --- 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 @@ -17,8 +17,11 @@ 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.CaptureType import com.github.takahirom.roborazzi.RoborazziRule.Options +import com.github.takahirom.roborazzi.RoborazziTaskType import com.github.takahirom.roborazzi.checkRoboAccessibility +import com.github.takahirom.roborazzi.roborazziSystemPropertyTaskType 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 @@ -41,10 +44,13 @@ class ViewA11yTest { @get:Rule val activityScenarioRule = ActivityScenarioRule(ComponentActivity::class.java) + val taskType: RoborazziTaskType = roborazziSystemPropertyTaskType() + @get:Rule val roborazziRule = RoborazziRule( - captureRoot = Espresso.onView(ViewMatchers.isRoot()), + captureRoot = onView(ViewMatchers.isRoot()), options = Options( + captureType = CaptureType.LastImage(), roborazziAccessibilityOptions = RoborazziATFAccessibilityCheckOptions( checker = RoborazziATFAccessibilityChecker( preset = AccessibilityCheckPreset.LATEST, @@ -58,7 +64,9 @@ class ViewA11yTest { @Test fun clickableWithoutSemantics() { - thrown.expectMessage("SpeakableTextPresentCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("SpeakableTextPresentCheck") + } activityScenarioRule.scenario.onActivity { activity -> activity.setContentView( @@ -161,7 +169,9 @@ class ViewA11yTest { @Test fun faintText() { - thrown.expectMessage("TextContrastCheck") + if (taskType.isEnabled()) { + thrown.expectMessage("TextContrastCheck") + } activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(