diff --git a/changes/unreleased/support-settings-reactivation-test-hang.md b/changes/unreleased/support-settings-reactivation-test-hang.md new file mode 100644 index 000000000..d42c2f87e --- /dev/null +++ b/changes/unreleased/support-settings-reactivation-test-hang.md @@ -0,0 +1,7 @@ +category: internal +issue: none +pull: none +platforms: desktop +user-facing: no + +The desktop support-settings reactivation test now keeps offering frames while recomposition is pending and cancels its recomposer with bounded waits, so a missed frame fails the test instead of hanging the desktop test suite. diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt index b21e5adae..96ff4e29f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt @@ -6,18 +6,22 @@ import androidx.compose.runtime.Composition import androidx.compose.runtime.Recomposer import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotSame +import kotlin.test.fail class SupportSettingsDraftReactivationTest { @Test - fun `live settings composition acquires a new holder after safe reactivation`() = runBlocking { + fun `live settings composition acquires a new holder after safe reactivation`() = runBlocking { val session = NextcloudSession( serverUrl = "https://support-reactivation.example.test", loginName = "alice", @@ -31,19 +35,31 @@ class SupportSettingsDraftReactivationTest { } val composition = Composition(EmptyUnitApplier(), recomposer) var frameTime = 0L - suspend fun advanceComposition() { - yield() - Snapshot.sendApplyNotifications() - yield() - frameClock.sendFrame(frameTime++) - recomposer.awaitIdle() + + // Apply notifications can also arrive from another thread, for example after an earlier + // desktop UI test started the global snapshot manager. A single frame can then be sent + // before the recomposer waits for one, so keep offering frames while work is pending. + suspend fun advanceCompositionUntil(stage: String, settled: () -> Boolean) { + withTimeoutOrNull(COMPOSITION_TIMEOUT_MILLIS) { + do { + yield() + Snapshot.sendApplyNotifications() + yield() + while (recomposer.hasPendingWork) { + frameClock.sendFrame(frameTime++) + yield() + } + } while (!settled()) + } ?: fail("Composition did not settle within $COMPOSITION_TIMEOUT_MILLIS ms while $stage.") } + var rendered: SupportSettingsDraftState? = null + var teardownFailure: String? = null try { composition.setContent { rendered = rememberAccountSupportSettingsDraftState(session) } - advanceComposition() + advanceCompositionUntil("composing the initial holder") { rendered != null } val retiredHolder = requireNotNull(rendered) val staleCallback = retiredHolder::updateReportDraft retiredHolder.updateReportDraft("Private text before removal") @@ -52,9 +68,7 @@ class SupportSettingsDraftReactivationTest { assertFalse(retiredHolder.hasDraftContent()) AccountPrivateMemoryLifecycle.activateAccount(account) - withTimeout(5_000L) { - while (rendered === retiredHolder) advanceComposition() - } + advanceCompositionUntil("waiting for the reactivated holder") { rendered !== retiredHolder } val currentHolder = requireNotNull(rendered) assertNotSame(retiredHolder, currentHolder) @@ -64,14 +78,33 @@ class SupportSettingsDraftReactivationTest { assertEquals("", retiredHolder.reportDraft) assertEquals("Fresh text after recovery", currentHolder.reportDraft) } finally { - composition.dispose() - recomposer.close() - recomposerJob.join() - AccountPrivateMemoryLifecycle.retireAccount(account) - AccountPrivateMemoryLifecycle.activateAccount(account) + try { + composition.dispose() + teardownFailure = stopRecomposer(recomposer, recomposerJob) + } finally { + AccountPrivateMemoryLifecycle.retireAccount(account) + AccountPrivateMemoryLifecycle.activateAccount(account) + } } + teardownFailure?.let { fail(it) } } + /** + * Recomposer.close() does not resume a runner that already waits for a frame once an effect + * is still completing, and this frame clock sends no more frames, so join() would never + * return. cancel() stops the runner in every state; the runner job is cancelled as a fallback. + */ + private suspend fun stopRecomposer(recomposer: Recomposer, runner: Job): String? = + withContext(NonCancellable) { + recomposer.cancel() + if (withTimeoutOrNull(TEARDOWN_TIMEOUT_MILLIS) { runner.join() } != null) { + null + } else { + runner.cancel() + "Recomposer did not stop within $TEARDOWN_TIMEOUT_MILLIS ms after cancellation." + } + } + private class EmptyUnitApplier : AbstractApplier(Unit) { override fun insertTopDown(index: Int, instance: Unit) = Unit override fun insertBottomUp(index: Int, instance: Unit) = Unit @@ -79,4 +112,9 @@ class SupportSettingsDraftReactivationTest { override fun move(from: Int, to: Int, count: Int) = Unit override fun onClear() = Unit } + + private companion object { + const val COMPOSITION_TIMEOUT_MILLIS = 5_000L + const val TEARDOWN_TIMEOUT_MILLIS = 5_000L + } }