Skip to content

Commit 3d056ea

Browse files
authored
Implement SkikoComposeUiTest.runWithoutImplicitWait (#3168)
1 parent 3e64450 commit 3d056ea

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,8 +453,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
453453
}
454454

455455
override fun <T> runWithoutImplicitWait(block: () -> T): T {
456-
// TODO https://youtrack.jetbrains.com/issue/CMP-10244/ui-test.-Implement-runWithoutImplicitWait
457-
throw NotImplementedError("runWithoutImplicitWait is not implemented.")
456+
return testOwner.withImplicitWaitSuppression(isSuppressed = true, block = block)
458457
}
459458

460459
override fun waitUntil(
@@ -534,6 +533,21 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
534533
return captureToImage(fetchSemanticsNode())
535534
}
536535

536+
/** Executes the given [block] while temporarily setting the implicit wait suppression state. */
537+
private inline fun <T> TestOwner.withImplicitWaitSuppression(
538+
isSuppressed: Boolean,
539+
block: () -> T,
540+
): T {
541+
val previousState = this.isImplicitWaitSuppressed
542+
this.isImplicitWaitSuppressed = isSuppressed
543+
return try {
544+
block()
545+
} finally {
546+
// Always restore the original synchronization state
547+
this.isImplicitWaitSuppressed = previousState
548+
}
549+
}
550+
537551
@OptIn(InternalComposeUiApi::class)
538552
internal inner class SkikoTestOwner : TestOwner {
539553
override var isImplicitWaitSuppressed: Boolean = false
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.test
18+
19+
import androidx.compose.animation.animateContentSize
20+
import androidx.compose.foundation.background
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.foundation.layout.size
23+
import androidx.compose.runtime.CompositionLocalProvider
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.setValue
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.graphics.Color
29+
import androidx.compose.ui.platform.LocalDensity
30+
import androidx.compose.ui.platform.testTag
31+
import androidx.compose.ui.test.v2.runComposeUiTest
32+
import androidx.compose.ui.unit.Density
33+
import androidx.compose.ui.unit.IntSize
34+
import androidx.compose.ui.unit.dp
35+
import androidx.kruth.assertThat
36+
import kotlin.test.Test
37+
38+
// Copied from androidDeviceTest
39+
class RunWithoutImplicitWaitTest {
40+
@OptIn(ExperimentalTestApi::class)
41+
@Test
42+
fun triggeredAnimationAndCaptureMotionValues() = runComposeUiTest {
43+
var size by mutableStateOf(64.dp)
44+
45+
var animationIsDone = false
46+
setContent {
47+
CompositionLocalProvider(LocalDensity provides Density(1f)) {
48+
Box(
49+
modifier =
50+
Modifier.testTag("foo")
51+
.animateContentSize { _, _ -> animationIsDone = true }
52+
.size(size)
53+
.background(Color.Red)
54+
)
55+
}
56+
}
57+
58+
mainClock.autoAdvance = false
59+
60+
val timeSeries = mutableListOf<IntSize>()
61+
size = 32.dp
62+
63+
while (!animationIsDone) {
64+
mainClock.advanceTimeByFrame()
65+
waitForIdle()
66+
runOnUiThread { runWithoutImplicitWait { captureMotionTestValues(timeSeries) } }
67+
}
68+
assertThat(timeSeries)
69+
.containsExactly(
70+
IntSize(64, 64),
71+
IntSize(64, 64),
72+
IntSize(63, 63),
73+
IntSize(60, 60),
74+
IntSize(56, 56),
75+
IntSize(52, 52),
76+
IntSize(49, 49),
77+
IntSize(46, 46),
78+
IntSize(43, 43),
79+
IntSize(41, 41),
80+
IntSize(39, 39),
81+
IntSize(37, 37),
82+
IntSize(36, 36),
83+
IntSize(35, 35),
84+
IntSize(35, 35),
85+
IntSize(34, 34),
86+
IntSize(34, 34),
87+
IntSize(33, 33),
88+
IntSize(32, 32),
89+
)
90+
.inOrder()
91+
}
92+
93+
@OptIn(ExperimentalTestApi::class)
94+
@Test
95+
fun runWithoutImplicitWait_doesNotTriggerWaitForIdle() = runComposeUiTest {
96+
var isNodeVisible by mutableStateOf(true)
97+
98+
setContent {
99+
if (isNodeVisible) {
100+
Box(modifier = Modifier.testTag("dummy_node"))
101+
}
102+
}
103+
104+
isNodeVisible = false
105+
106+
runOnUiThread {
107+
runWithoutImplicitWait {
108+
// In a normal context, onNodeWithTag() forces a waitForIdle(),
109+
// which would execute the pending recomposition.
110+
onNodeWithTag("dummy_node")
111+
.assertExists(
112+
"waitForIdle() was called internally, breaking the suppression contract!"
113+
)
114+
}
115+
}
116+
// Calling onNodeWithTag outside runWithoutImplicitWait will now force idle.
117+
// This executes the pending recomposition, removing the node, and hence it won't exist
118+
// anymore.
119+
onNodeWithTag("dummy_node").assertDoesNotExist()
120+
}
121+
122+
/**
123+
* Illustrative implementation of a "sample the property values of the current frame" method.
124+
*
125+
* Motion tests do exactly that, just with more syntactic sugar 🍬.
126+
*/
127+
@OptIn(ExperimentalTestApi::class)
128+
private fun ComposeUiTest.captureMotionTestValues(fooSizeTimeSeries: MutableList<IntSize>) {
129+
// Capture a value and add to the time series.
130+
fooSizeTimeSeries.add(onNodeWithTag("foo").fetchSemanticsNode().size)
131+
132+
repeat(100) {
133+
// simulation of capturing multiple properties.
134+
// For making the point, this just repeatedly captures the same property.
135+
onNodeWithTag("foo").fetchSemanticsNode().size
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)