diff --git a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt index 656c18fcb0667..663ed513db64a 100644 --- a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt @@ -453,8 +453,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor( } override fun runWithoutImplicitWait(block: () -> T): T { - // TODO https://youtrack.jetbrains.com/issue/CMP-10244/ui-test.-Implement-runWithoutImplicitWait - throw NotImplementedError("runWithoutImplicitWait is not implemented.") + return testOwner.withImplicitWaitSuppression(isSuppressed = true, block = block) } override fun waitUntil( @@ -534,6 +533,21 @@ open class SkikoComposeUiTest @InternalTestApi constructor( return captureToImage(fetchSemanticsNode()) } + /** Executes the given [block] while temporarily setting the implicit wait suppression state. */ + private inline fun TestOwner.withImplicitWaitSuppression( + isSuppressed: Boolean, + block: () -> T, + ): T { + val previousState = this.isImplicitWaitSuppressed + this.isImplicitWaitSuppressed = isSuppressed + return try { + block() + } finally { + // Always restore the original synchronization state + this.isImplicitWaitSuppressed = previousState + } + } + @OptIn(InternalComposeUiApi::class) internal inner class SkikoTestOwner : TestOwner { override var isImplicitWaitSuppressed: Boolean = false diff --git a/compose/ui/ui-test/src/skikoTest/kotlin/androidx/compose/ui/test/RunWithoutImplicitWaitTest.kt b/compose/ui/ui-test/src/skikoTest/kotlin/androidx/compose/ui/test/RunWithoutImplicitWaitTest.kt new file mode 100644 index 0000000000000..f805fd8c74d0e --- /dev/null +++ b/compose/ui/ui-test/src/skikoTest/kotlin/androidx/compose/ui/test/RunWithoutImplicitWaitTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 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. + */ + +package androidx.compose.ui.test + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.kruth.assertThat +import kotlin.test.Test + +// Copied from androidDeviceTest +class RunWithoutImplicitWaitTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun triggeredAnimationAndCaptureMotionValues() = runComposeUiTest { + var size by mutableStateOf(64.dp) + + var animationIsDone = false + setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + Box( + modifier = + Modifier.testTag("foo") + .animateContentSize { _, _ -> animationIsDone = true } + .size(size) + .background(Color.Red) + ) + } + } + + mainClock.autoAdvance = false + + val timeSeries = mutableListOf() + size = 32.dp + + while (!animationIsDone) { + mainClock.advanceTimeByFrame() + waitForIdle() + runOnUiThread { runWithoutImplicitWait { captureMotionTestValues(timeSeries) } } + } + assertThat(timeSeries) + .containsExactly( + IntSize(64, 64), + IntSize(64, 64), + IntSize(63, 63), + IntSize(60, 60), + IntSize(56, 56), + IntSize(52, 52), + IntSize(49, 49), + IntSize(46, 46), + IntSize(43, 43), + IntSize(41, 41), + IntSize(39, 39), + IntSize(37, 37), + IntSize(36, 36), + IntSize(35, 35), + IntSize(35, 35), + IntSize(34, 34), + IntSize(34, 34), + IntSize(33, 33), + IntSize(32, 32), + ) + .inOrder() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun runWithoutImplicitWait_doesNotTriggerWaitForIdle() = runComposeUiTest { + var isNodeVisible by mutableStateOf(true) + + setContent { + if (isNodeVisible) { + Box(modifier = Modifier.testTag("dummy_node")) + } + } + + isNodeVisible = false + + runOnUiThread { + runWithoutImplicitWait { + // In a normal context, onNodeWithTag() forces a waitForIdle(), + // which would execute the pending recomposition. + onNodeWithTag("dummy_node") + .assertExists( + "waitForIdle() was called internally, breaking the suppression contract!" + ) + } + } + // Calling onNodeWithTag outside runWithoutImplicitWait will now force idle. + // This executes the pending recomposition, removing the node, and hence it won't exist + // anymore. + onNodeWithTag("dummy_node").assertDoesNotExist() + } + + /** + * Illustrative implementation of a "sample the property values of the current frame" method. + * + * Motion tests do exactly that, just with more syntactic sugar 🍬. + */ + @OptIn(ExperimentalTestApi::class) + private fun ComposeUiTest.captureMotionTestValues(fooSizeTimeSeries: MutableList) { + // Capture a value and add to the time series. + fooSizeTimeSeries.add(onNodeWithTag("foo").fetchSemanticsNode().size) + + repeat(100) { + // simulation of capturing multiple properties. + // For making the point, this just repeatedly captures the same property. + onNodeWithTag("foo").fetchSemanticsNode().size + } + } +}