Skip to content

Commit d255524

Browse files
committed
SkikoComposeUiTest: Android-shaped idle/draw, decouple draw from the frame clock
Reworks the skiko test harness so waiting and capturing mirror Android's ComposeIdlingResource / waitForNextChoreographerFrame model. - Split the old render() into performFrame() (advances the frame clock) and redraw() (re-renders settled state WITHOUT advancing the clock), the analog of Android's forceRedraw() on capture. captureToImage() drives redraw() itself. - isIdle() no longer gates on draw (matching ComposeIdlingResource.isIdleNow): it waits on snapshot apply + measure/layout only; a pending draw is flushed after the wait by ensureScheduledDrawCompleted(). - Unify waitForIdle()/awaitIdle() over runUntilIdle { pace }, bounded by testTimeout so a never-settling test fails fast instead of hanging. - Apply snapshot changes after each resumed continuation via ApplyingContinuationInterceptor; runInternalSkikoComposeUiTest no longer wraps runTest. - Add DrawOnlyInvalidationTest covering draw-only vs measure vs recomposition invalidation under paused/running clocks (Android-verified expectations).
1 parent 0ffda3b commit d255524

3 files changed

Lines changed: 216 additions & 44 deletions

File tree

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

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import androidx.compose.ui.platform.PlatformWindowInsets
3838
import androidx.compose.ui.platform.WindowInfo
3939
import androidx.compose.ui.scene.CanvasLayersComposeScene
4040
import androidx.compose.ui.scene.ComposeScene
41-
import androidx.compose.ui.scene.hasInvalidations
4241
import androidx.compose.ui.semantics.SemanticsNode
4342
import androidx.compose.ui.unit.Density
4443
import androidx.compose.ui.unit.DpSize
@@ -52,8 +51,7 @@ import kotlin.coroutines.EmptyCoroutineContext
5251
import kotlin.coroutines.cancellation.CancellationException
5352
import kotlin.math.roundToInt
5453
import kotlin.time.Duration
55-
import kotlin.time.DurationUnit
56-
import kotlin.time.toDuration
54+
import kotlin.time.TimeSource
5755
import kotlinx.coroutines.CoroutineDispatcher
5856
import kotlinx.coroutines.CoroutineExceptionHandler
5957
import kotlinx.coroutines.CoroutineScope
@@ -221,7 +219,8 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
221219

222220
private val recomposerCoroutineScope = CoroutineScope(
223221
effectContext +
224-
compositionCoroutineDispatcher +
222+
// Apply snapshot changes after every resumed continuation.
223+
ApplyingContinuationInterceptor(compositionCoroutineDispatcher) +
225224
infiniteAnimationPolicy +
226225
uncaughtExceptionHandler +
227226
Job()
@@ -303,8 +302,11 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
303302
while (isActive) {
304303
delay(FRAME_DELAY_MILLIS)
305304
runOnUiThread {
306-
render(mainClock.currentTime)
305+
frameRecomposer.performFrame(
306+
frameTimeNanos = mainClock.currentTime * NanoSecondsPerMilliSecond,
307+
)
307308
}
309+
redraw()
308310
}
309311
}
310312
block()
@@ -314,13 +316,17 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
314316
}
315317

316318
/**
317-
* Render the scene at the given time.
319+
* Redraws the current (already-settled) state into [surface] WITHOUT advancing the frame clock,
320+
* so a capture reflects the latest state. This is the analog of Android's `View.forceRedraw()` on
321+
* capture: draw is decoupled from idle, so producing an up-to-date image is the capture's
322+
* responsibility rather than the idle loop's.
318323
*/
319-
private fun render(timeMillis: Long) {
320-
surface.canvas.clear(Color.TRANSPARENT)
321-
frameRecomposer.performFrame(timeMillis * NanoSecondsPerMilliSecond)
324+
private fun redraw() = runOnUiThread {
322325
scene.measureAndLayout()
323-
scene.draw(surface.canvas.asComposeCanvas())
326+
with(surface.canvas) {
327+
clear(Color.TRANSPARENT)
328+
scene.draw(asComposeCanvas())
329+
}
324330
}
325331

326332
private fun createScene() {
@@ -349,7 +355,15 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
349355
// The rendering is done by withRenderLoop
350356
} else {
351357
runOnUiThread {
352-
render(mainClock.currentTime)
358+
// Settle non-frame work WITHOUT advancing the frame clock - drain
359+
// the currently-due tasks via the test scheduler, then run measure/layout.
360+
// Never [frameRecomposer.performFrame], so withFrameNanos awaiters are not resumed.
361+
compositionCoroutineDispatcher.scheduler.runCurrent()
362+
scene.measureAndLayout()
363+
364+
// Publish global snapshot writes produced during this settle,
365+
// so the next isIdle() sees an applied state.
366+
Snapshot.sendApplyNotifications()
353367
}
354368
}
355369
}
@@ -360,48 +374,86 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
360374
return false
361375
}
362376

363-
if (!mainClock.autoAdvance) {
364-
return true
377+
// Only an auto-advancing clock waits for recomposition/effects/frame work to drain.
378+
// With a frozen clock the test drives frames itself, so a parked withFrameNanos awaiter
379+
// only resumes when the test advances the clock - gating on it here would hang.
380+
if (mainClock.autoAdvance && frameRecomposer.hasPendingWork()) {
381+
return false
365382
}
366383

384+
// Idle once pending snapshot writes are applied and measure/layout has settled.
385+
// Do NOT gate on draw, matching Android's ComposeIdlingResource.isIdleNow (which checks
386+
// recomposition + measure/layout, not draw). A pending draw is instead flushed after the
387+
// wait loop by [ensureScheduledDrawCompleted], mirroring Android's waitForNextChoreographerFrame.
367388
return !Snapshot.current.hasPendingChanges()
368389
&& !Snapshot.isApplyObserverNotificationPending
369-
&& !frameRecomposer.hasPendingWork()
370-
&& !scene.hasInvalidations()
390+
&& !scene.hasPendingMeasureOrLayout
371391
&& areAllResourcesIdle()
372392
}
373393

374394
override fun waitForIdle() {
375-
val startedAt = currentNanoTime().toDuration(DurationUnit.NANOSECONDS)
376-
var lastReportedElapsedSeconds = 0L
377-
// always check even if we are idle
378-
uncaughtExceptionHandler.throwUncaught()
379-
while (!isIdle()) {
380-
advanceIfNeededAndRenderNextFrame()
381-
uncaughtExceptionHandler.throwUncaught()
395+
// Mirrors Android's two-step waitForIdle(): await idleness, then ensure the scheduled draw
396+
// has completed (cf. idlingStrategy.runUntilIdle() + waitForNextChoreographerFrame()).
397+
runUntilIdle {
382398
if (!areAllResourcesIdle()) {
383399
sleep(IDLING_RESOURCES_CHECK_INTERVAL_MS)
384400
}
385-
val currentTime = currentNanoTime().toDuration(DurationUnit.NANOSECONDS)
386-
val elapsedSeconds = (currentTime - startedAt).inWholeSeconds
387-
if (elapsedSeconds > lastReportedElapsedSeconds) {
388-
println("Suspicious! waitForIdle has not finished after $elapsedSeconds seconds.")
389-
lastReportedElapsedSeconds = elapsedSeconds
390-
}
391401
}
402+
ensureScheduledDrawCompleted()
392403
}
393404

394-
override suspend fun awaitIdle() {
395-
// always check even if we are idle
405+
/**
406+
* Awaits composition, measure and layout - the analog of Android's idlingStrategy.runUntilIdle().
407+
* Draw is intentionally NOT awaited here (see [isIdle], mirroring Android's
408+
* `ComposeIdlingResource.isIdleNow`); callers flush it afterwards via [ensureScheduledDrawCompleted].
409+
*
410+
* [pace] runs after each rendered frame to back off before re-checking idleness: a blocking sleep
411+
* for [waitForIdle], a suspending delay/yield for [awaitIdle]. It is [inline] so [pace] can suspend
412+
* when called from [awaitIdle].
413+
*/
414+
private inline fun runUntilIdle(pace: () -> Unit) {
415+
// Always check even if we are idle.
396416
uncaughtExceptionHandler.throwUncaught()
417+
val startedAt = TimeSource.Monotonic.markNow()
397418
while (!isIdle()) {
398419
advanceIfNeededAndRenderNextFrame()
399420
uncaughtExceptionHandler.throwUncaught()
421+
// Bound the wait by [testTimeout] so a test that never settles fails fast.
422+
if (startedAt.elapsedNow() > testTimeout) {
423+
throw ComposeTimeoutException("waitForIdle: timeout $testTimeout reached")
424+
}
425+
pace()
426+
}
427+
}
428+
429+
/**
430+
* Ensures a scheduled draw has completed before returning from a wait - the skiko analog of the
431+
* `waitForNextChoreographerFrame()` step at the end of Android's `waitForIdle()` ("wait for the
432+
* next frame to ensure any scheduled drawing has completed"). On Android the draw is produced by
433+
* the Choreographer/ViewRootImpl pipeline running during the wait; there is no such pipeline here
434+
* and [withRenderLoop] is inert under a paused clock, so we render the pending draw directly.
435+
*
436+
* A snapshot read only in draw scope (e.g. drawBehind/Canvas) invalidates the layer without any
437+
* pending measure/layout, so it is intentionally not part of [isIdle] - matching Android, whose
438+
* `ComposeIdlingResource.isIdleNow` also excludes draw. Verified on the Android runtime: under a
439+
* paused clock a draw-only state change is observed after `waitForIdle`. Never resumes
440+
* withFrameNanos awaiters ([redraw] does measure/layout + draw only, no performFrame).
441+
*/
442+
private fun ensureScheduledDrawCompleted() {
443+
if (scene.hasPendingDraw) {
444+
redraw()
445+
}
446+
}
447+
448+
override suspend fun awaitIdle() {
449+
// Same two-step shape as [waitForIdle], but suspending: await idleness, then flush the draw.
450+
runUntilIdle {
400451
if (!areAllResourcesIdle()) {
401452
delay(IDLING_RESOURCES_CHECK_INTERVAL_MS)
402453
}
403454
yield()
404455
}
456+
ensureScheduledDrawCompleted()
405457
}
406458

407459
override fun <T> runOnUiThread(action: () -> T): T {
@@ -476,10 +528,12 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
476528

477529
fun captureToImage(): ImageBitmap {
478530
waitForIdle()
531+
redraw()
479532
return surface.makeImageSnapshot().toComposeImageBitmap()
480533
}
481534

482535
fun captureToImage(semanticsNode: SemanticsNode): ImageBitmap {
536+
redraw()
483537
val rect = semanticsNode.boundsInWindow
484538
val image = surface.makeImageSnapshot(
485539
rect.left.toInt(),

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

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ fun runSkikoComposeUiTest(
138138
}
139139

140140
@InternalTestApi
141-
@OptIn(InternalComposeUiApi::class, ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
141+
@OptIn(ExperimentalTestApi::class, InternalComposeUiApi::class)
142142
fun runInternalSkikoComposeUiTest(
143143
width: Int = 1024,
144144
height: Int = 768,
@@ -150,17 +150,15 @@ fun runInternalSkikoComposeUiTest(
150150
windowInsets: PlatformWindowInsets? = null,
151151
block: suspend SkikoComposeUiTest.() -> Unit,
152152
): TestResult {
153-
return runTest {
154-
SkikoComposeUiTest(
155-
width = width,
156-
height = height,
157-
effectContext = effectContext,
158-
runTestContext = runTestContext,
159-
testTimeout = testTimeout,
160-
density = density,
161-
semanticsOwnerListener = semanticsOwnerListener,
162-
windowInsets = windowInsets,
163-
useStandardTestDispatcherForComposition = true,
164-
).runTest(block)
165-
}
153+
return SkikoComposeUiTest(
154+
width = width,
155+
height = height,
156+
effectContext = effectContext,
157+
runTestContext = runTestContext,
158+
testTimeout = testTimeout,
159+
density = density,
160+
semanticsOwnerListener = semanticsOwnerListener,
161+
windowInsets = windowInsets,
162+
useStandardTestDispatcherForComposition = true,
163+
).runTest(block)
166164
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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.runtime.mutableStateOf
20+
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.draw.drawBehind
22+
import androidx.compose.ui.layout.Layout
23+
import kotlin.test.Test
24+
import kotlin.test.assertEquals
25+
26+
@OptIn(ExperimentalTestApi::class)
27+
class DrawOnlyInvalidationTest {
28+
29+
/**
30+
* Paused clock: the first frame is drawn by `waitForIdle`, and a subsequent draw-only
31+
* invalidation is also flushed by `waitForIdle` - without `captureToImage`. Android-verified:
32+
* `afterSetContent=[0] afterFirstWait=[0] afterMutate=[0] afterWaitAfterMutate=[0, 1]`.
33+
*/
34+
@Test
35+
fun pausedClock_drawOnlyInvalidationIsFlushedByWaitForIdle() = runComposeUiTest {
36+
mainClock.autoAdvance = false
37+
val state = mutableStateOf(0)
38+
val drawn = mutableListOf<Int>()
39+
setContent {
40+
Layout(
41+
modifier = Modifier.drawBehind { drawn.add(state.value) },
42+
measurePolicy = { _, _ -> layout(10, 10) {} }
43+
)
44+
}
45+
waitForIdle()
46+
assertEquals(listOf(0), drawn, "first frame should be drawn by waitForIdle")
47+
48+
state.value = 1 // draw-only invalidation: no measure/layout/recomposition
49+
waitForIdle() // no clock advance, no captureToImage
50+
assertEquals(listOf(0, 1), drawn, "draw-only update should be flushed by waitForIdle")
51+
}
52+
53+
/**
54+
* Paused clock, measure invalidation (state read in measure): flushed by `waitForIdle`.
55+
* Android-verified: draw count 1 -> 2.
56+
*/
57+
@Test
58+
fun pausedClock_measureInvalidationIsFlushedByWaitForIdle() = runComposeUiTest {
59+
mainClock.autoAdvance = false
60+
val state = mutableStateOf(0)
61+
val drawn = mutableListOf<Int>()
62+
setContent {
63+
Layout(
64+
modifier = Modifier.drawBehind { drawn.add(-1) },
65+
measurePolicy = { _, _ ->
66+
val s = state.value // read in measure -> measure invalidation
67+
layout(10 + s, 10) {}
68+
}
69+
)
70+
}
71+
waitForIdle()
72+
assertEquals(1, drawn.size)
73+
state.value = 1
74+
waitForIdle()
75+
assertEquals(2, drawn.size, "measure invalidation should trigger a redraw")
76+
}
77+
78+
/**
79+
* Paused clock, recomposition invalidation (state read in composition) is NOT flushed without
80+
* advancing the clock: recomposition is driven by the frame clock, so a paused clock leaves it
81+
* pending (and, per [MainTestClock], pending recompositions are not awaited when autoAdvance is
82+
* false). Android-verified: `[0] -> [0]`.
83+
*/
84+
@Test
85+
fun pausedClock_recompositionIsNotFlushedWithoutAdvancingClock() = runComposeUiTest {
86+
mainClock.autoAdvance = false
87+
val state = mutableStateOf(0)
88+
val drawn = mutableListOf<Int>()
89+
setContent {
90+
val v = state.value // read in composition -> recomposition invalidation
91+
Layout(
92+
modifier = Modifier.drawBehind { drawn.add(v) },
93+
measurePolicy = { _, _ -> layout(10, 10) {} }
94+
)
95+
}
96+
waitForIdle()
97+
assertEquals(listOf(0), drawn)
98+
state.value = 1
99+
waitForIdle()
100+
assertEquals(listOf(0), drawn, "recomposition should stay pending under a paused clock")
101+
}
102+
103+
/** Running clock (autoAdvance=true, the default): draw-only invalidation flushed. */
104+
@Test
105+
fun runningClock_drawOnlyInvalidationIsFlushed() = runComposeUiTest {
106+
val state = mutableStateOf(0)
107+
val drawn = mutableListOf<Int>()
108+
setContent {
109+
Layout(
110+
modifier = Modifier.drawBehind { drawn.add(state.value) },
111+
measurePolicy = { _, _ -> layout(10, 10) {} }
112+
)
113+
}
114+
waitForIdle()
115+
assertEquals(listOf(0), drawn)
116+
state.value = 1
117+
waitForIdle()
118+
assertEquals(listOf(0, 1), drawn)
119+
}
120+
}

0 commit comments

Comments
 (0)