-
Notifications
You must be signed in to change notification settings - Fork 243
Description
Description
When using AccessibilityRenderExtension with ModalBottomSheet containing semantics annotations, the accessibility metadata in the overlay changes non-deterministically between record and verify runs, causing test failures even though the visual content is identical.
The accessibility overlay shows different entries (e.g., "Tabelle schließen" / "Close table" appears/disappears randomly) between runs.
Steps to Reproduce
- Record the snapshot: ./gradlew recordPaparazziDebug --tests "AccessibilityBottomSheetFlakyTest". This only happens to be flaky, when recording multiple tests. I recoded like 100 tests. This caused the missing overlay.
- Verify immediately: ./gradlew verifyPaparazziDebug --tests "AccessibilityBottomSheetFlakyTest"
- The PIXEL_6_ACCESSIBILITY variant fails randomly
Expected behavior
The accessibility overlay should be deterministic - recording and verifying should produce identical results when the composable content hasn't changed.
Only bottom sheets are affected, for normal composables this works fine.
Additional information:
- Paparazzi Version: 2.0.0-alpha04
- OS: macOS (Darwin 25.2.0)
- Compile SDK: 36
- Gradle Version: 9.2.1
- Android Gradle Plugin Version: 8.13.2
Screenshots
Unfortunately my company doesn't allow me to record screenshots, but basically the difference is, the recorded image doesn't have the overlay, the verified image has it.
Sample code
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.accessibility.AccessibilityRenderExtension
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.launch
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Minimal reproducible test case for flaky accessibility overlay on bottom sheets.
*
* Issue: When using AccessibilityRenderExtension with bottom sheets containing
* semantics annotations (heading, liveRegion, selectable, etc.), the accessibility
* metadata changes between record and verify runs, causing test failures.
*
* The "Tabelle schließen" (close table) entry appears/disappears randomly in the
* accessibility overlay between runs.
*/
@RunWith(TestParameterInjector::class)
class AccessibilityBottomSheetFlakyTest {
enum class DeviceVariant(
val deviceConfig: DeviceConfig,
val useAccessibility: Boolean
) {
PIXEL_6_PRO_DE(DeviceConfig.PIXEL_6_PRO.copy(locale = "de"), false),
PIXEL_6_ACCESSIBILITY(DeviceConfig.PIXEL_6.copy(locale = "de"), true)
}
@TestParameter
lateinit var device: DeviceVariant
@get:Rule
val paparazzi by lazy {
Paparazzi(
deviceConfig = device.deviceConfig,
renderExtensions = if (device.useAccessibility) {
setOf(AccessibilityRenderExtension())
} else {
emptySet()
}
)
}
@Test
fun testBottomSheetWithAccessibility() {
paparazzi.snapshot {
CompositionLocalProvider(LocalInspectionMode provides true) {
MaterialTheme {
SimpleBottomSheet(
isVisible = true,
onDismiss = {}
) {
SortOptionBottomSheetContent(
selectedOption = SortOption.OptionA,
onButtonClicked = {}
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SimpleBottomSheet(
isVisible: Boolean,
onDismiss: () -> Unit,
content: @Composable () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var isSheetVisible by rememberSaveable { mutableStateOf(isVisible) }
val isPreview = LocalInspectionMode.current
LaunchedEffect(isVisible) {
if (isVisible) {
isSheetVisible = true
if (!isPreview) {
awaitFrame()
}
sheetState.show()
} else {
isSheetVisible = false
sheetState.hide()
}
}
if (isSheetVisible || sheetState.isVisible) {
ModalBottomSheet(
onDismissRequest = {
scope.launch {
isSheetVisible = false
sheetState.hide()
}.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismiss()
}
}
},
sheetState = sheetState,
dragHandle = { BottomSheetDefaults.DragHandle() },
containerColor = Color.White,
modifier = Modifier.statusBarsPadding()
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
content()
}
}
}
}
@Composable
private fun SortOptionBottomSheetContent(
selectedOption: SortOption,
onButtonClicked: (SortOption) -> Unit,
modifier: Modifier = Modifier
) {
var currentSelection by rememberSaveable {
mutableStateOf(selectedOption)
}
Column(modifier = modifier.padding(16.dp)) {
// Title with semantics
Text(
text = "Sort Options",
modifier = Modifier
.fillMaxWidth()
.semantics {
heading()
liveRegion = LiveRegionMode.Polite
},
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
// Radio options in a card
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(8.dp)) {
SortOption.entries.forEachIndexed { index, option ->
val isSelected = option == currentSelection
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { currentSelection = option }
)
.semantics(mergeDescendants = true) {
liveRegion = LiveRegionMode.Polite
selected = isSelected
role = Role.RadioButton
traversalIndex = index.toFloat()
}
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = { currentSelection = option }
)
Text(
text = option.label,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Button
Button(
onClick = { onButtonClicked(currentSelection) },
modifier = Modifier.fillMaxWidth()
) {
Text("Apply")
}
Spacer(modifier = Modifier.height(16.dp))
}
}
enum class SortOption(val label: String) {
OptionA("Option A"),
OptionB("Option B")
}
}