Merge pull request #2754 from vitorpamplona/claude/fix-jvm-test-timeout-oBEdj

Fix NestViewModelTest hanging by properly tearing down VMs
This commit is contained in:
Vitor Pamplona
2026-05-06 18:37:24 -04:00
committed by GitHub
@@ -55,6 +55,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
/**
* Drives [NestViewModel] with a fake [NestsListenerConnector] and
@@ -72,6 +73,16 @@ import kotlin.test.assertTrue
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NestViewModelTest {
// Tracks every VM created by `newViewModel` so each test can dispose them
// before runTest exits. `connect()` starts an infinite cliff-detector
// loop in viewModelScope; without an explicit teardown that loop spins
// forever under runTest's virtual scheduler and the test wedges until
// the real-time runTest deadline (60 s × N tests => :commons:jvmTest
// hangs). [runVmTest] wraps each test body with a finally that calls
// `disconnect()` (which cancels cliffDetectorJob via teardown()) so the
// scheduler can become idle and runTest can return.
private val createdVms = mutableListOf<NestViewModel>()
@BeforeTest
fun setupMainDispatcher() {
Dispatchers.setMain(UnconfinedTestDispatcher())
@@ -79,12 +90,34 @@ class NestViewModelTest {
@AfterTest
fun resetMainDispatcher() {
// Belt-and-braces: if a test crashes before its finally runs, this
// still tears down so the next test starts clean.
createdVms.forEach { runCatching { it.disconnect() } }
createdVms.clear()
Dispatchers.resetMain()
}
/**
* Test wrapper that guarantees every VM created via [newViewModel] is
* disconnected before [runTest] tries to drain the test scheduler. The
* 10-second [runTest] timeout is a safety net — a healthy test in this
* class finishes in milliseconds; if we trip the timeout it almost
* certainly means a new code path is leaving viewModelScope coroutines
* running and needs its own teardown call.
*/
private fun runVmTest(body: suspend TestScope.() -> Unit) =
runTest(timeout = 10.seconds) {
try {
body()
} finally {
createdVms.forEach { runCatching { it.disconnect() } }
createdVms.clear()
}
}
@Test
fun connectShowsConnectingThenConnected() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
@@ -103,7 +136,7 @@ class NestViewModelTest {
@Test
fun listenerFailedSurfacesAsUiFailed() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
@@ -117,7 +150,7 @@ class NestViewModelTest {
@Test
fun connectorThrowsBecomesUiFailed() =
runTest {
runVmTest {
val vm = newViewModel { throw NestsException("dns blew up") }
vm.connect()
@@ -129,7 +162,7 @@ class NestViewModelTest {
@Test
fun setMutedFlipsUiStateAndIsRetained() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
assertFalse(vm.uiState.value.isMuted)
@@ -141,7 +174,7 @@ class NestViewModelTest {
@Test
fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
// Defaults to true so a freshly-joined speaker advertises
@@ -156,7 +189,7 @@ class NestViewModelTest {
@Test
fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
@@ -187,7 +220,7 @@ class NestViewModelTest {
@Test
fun evictStalePresencesDropsOldPeers() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
val bob = "b".repeat(64)
@@ -219,7 +252,7 @@ class NestViewModelTest {
@Test
fun onChatEventAccumulatesMessagesSortedByCreatedAt() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
@@ -255,7 +288,7 @@ class NestViewModelTest {
@Test
fun onChatEventDedupesByEventId() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
val msg =
@@ -281,7 +314,7 @@ class NestViewModelTest {
@Test
fun onReactionEventGroupsByTargetAndEvictsOnTick() =
runTest {
runVmTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
val bob = "b".repeat(64)
@@ -326,7 +359,7 @@ class NestViewModelTest {
@Test
fun onKickFlipsWasKickedAndDisconnects() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
@@ -343,7 +376,7 @@ class NestViewModelTest {
@Test
fun onKickIsIdempotent() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
@@ -377,7 +410,7 @@ class NestViewModelTest {
@Test
fun connectIsIdempotentWhileConnecting() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
var connectCalls = 0
val vm =
@@ -395,7 +428,7 @@ class NestViewModelTest {
@Test
fun disconnectReturnsToIdleAndClosesListener() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
@@ -411,7 +444,7 @@ class NestViewModelTest {
@Test
fun connectingStepMapsThroughToUiStep() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
@@ -429,7 +462,7 @@ class NestViewModelTest {
@Test
fun speakingNowClearsOnTeardown() =
runTest {
runVmTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
@@ -466,7 +499,7 @@ class NestViewModelTest {
// Wire to the test's backgroundScope so close calls run during
// the test rather than escaping to the real GlobalScope.
cleanupScope = backgroundScope,
)
).also { createdVms.add(it) }
private class FakeNestsListener : NestsListener {
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)