@@ -38,7 +38,6 @@ import androidx.compose.ui.platform.PlatformWindowInsets
3838import androidx.compose.ui.platform.WindowInfo
3939import androidx.compose.ui.scene.CanvasLayersComposeScene
4040import androidx.compose.ui.scene.ComposeScene
41- import androidx.compose.ui.scene.hasInvalidations
4241import androidx.compose.ui.semantics.SemanticsNode
4342import androidx.compose.ui.unit.Density
4443import androidx.compose.ui.unit.DpSize
@@ -52,8 +51,7 @@ import kotlin.coroutines.EmptyCoroutineContext
5251import kotlin.coroutines.cancellation.CancellationException
5352import kotlin.math.roundToInt
5453import kotlin.time.Duration
55- import kotlin.time.DurationUnit
56- import kotlin.time.toDuration
54+ import kotlin.time.TimeSource
5755import kotlinx.coroutines.CoroutineDispatcher
5856import kotlinx.coroutines.CoroutineExceptionHandler
5957import 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(),
0 commit comments