fix(commons): dispose NestViewModel between tests so :commons:jvmTest stops hanging
NestViewModel.connect() launches an infinite cliff-detector loop in
viewModelScope (`while(true) { delay(...) }`). The existing tests in
NestViewModelTest call connect() but never disconnect/onCleared, so
that loop stays alive. Under runTest's virtual scheduler each delay
returns instantly, the loop spins millions of iterations per second of
real time, and runTest never reaches the idle state — every test
wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest
hangs for ~17 minutes).
Wrap each test body with runVmTest, which runs the body inside a
try/finally that calls disconnect() on every VM created by
newViewModel before runTest tries to drain. teardown() cancels
cliffDetectorJob, the scheduler becomes idle, and the test returns.
A 10 s runTest timeout is the safety net — healthy tests now finish
in milliseconds, and tripping the timeout signals a new viewModelScope
coroutine that needs its own teardown call.
Verified: :commons:jvmTest --rerun-tasks now completes in 52 s
(409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s
total versus the previous indefinite hang.
https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7
This commit is contained in:
+50
-17
@@ -55,6 +55,7 @@ import kotlin.test.assertEquals
|
|||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives [NestViewModel] with a fake [NestsListenerConnector] and
|
* Drives [NestViewModel] with a fake [NestsListenerConnector] and
|
||||||
@@ -72,6 +73,16 @@ import kotlin.test.assertTrue
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class NestViewModelTest {
|
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
|
@BeforeTest
|
||||||
fun setupMainDispatcher() {
|
fun setupMainDispatcher() {
|
||||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||||
@@ -79,12 +90,34 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
fun resetMainDispatcher() {
|
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()
|
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
|
@Test
|
||||||
fun connectShowsConnectingThenConnected() =
|
fun connectShowsConnectingThenConnected() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
|
|
||||||
@@ -103,7 +136,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun listenerFailedSurfacesAsUiFailed() =
|
fun listenerFailedSurfacesAsUiFailed() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
|
|
||||||
@@ -117,7 +150,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectorThrowsBecomesUiFailed() =
|
fun connectorThrowsBecomesUiFailed() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { throw NestsException("dns blew up") }
|
val vm = newViewModel { throw NestsException("dns blew up") }
|
||||||
|
|
||||||
vm.connect()
|
vm.connect()
|
||||||
@@ -129,7 +162,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun setMutedFlipsUiStateAndIsRetained() =
|
fun setMutedFlipsUiStateAndIsRetained() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
|
|
||||||
assertFalse(vm.uiState.value.isMuted)
|
assertFalse(vm.uiState.value.isMuted)
|
||||||
@@ -141,7 +174,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() =
|
fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
|
|
||||||
// Defaults to true so a freshly-joined speaker advertises
|
// Defaults to true so a freshly-joined speaker advertises
|
||||||
@@ -156,7 +189,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() =
|
fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
|
|
||||||
val alice = "a".repeat(64)
|
val alice = "a".repeat(64)
|
||||||
@@ -187,7 +220,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun evictStalePresencesDropsOldPeers() =
|
fun evictStalePresencesDropsOldPeers() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
val alice = "a".repeat(64)
|
val alice = "a".repeat(64)
|
||||||
val bob = "b".repeat(64)
|
val bob = "b".repeat(64)
|
||||||
@@ -219,7 +252,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onChatEventAccumulatesMessagesSortedByCreatedAt() =
|
fun onChatEventAccumulatesMessagesSortedByCreatedAt() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
val alice = "a".repeat(64)
|
val alice = "a".repeat(64)
|
||||||
|
|
||||||
@@ -255,7 +288,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onChatEventDedupesByEventId() =
|
fun onChatEventDedupesByEventId() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
val alice = "a".repeat(64)
|
val alice = "a".repeat(64)
|
||||||
val msg =
|
val msg =
|
||||||
@@ -281,7 +314,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onReactionEventGroupsByTargetAndEvictsOnTick() =
|
fun onReactionEventGroupsByTargetAndEvictsOnTick() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val vm = newViewModel { FakeNestsListener() }
|
val vm = newViewModel { FakeNestsListener() }
|
||||||
val alice = "a".repeat(64)
|
val alice = "a".repeat(64)
|
||||||
val bob = "b".repeat(64)
|
val bob = "b".repeat(64)
|
||||||
@@ -326,7 +359,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onKickFlipsWasKickedAndDisconnects() =
|
fun onKickFlipsWasKickedAndDisconnects() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
vm.connect()
|
vm.connect()
|
||||||
@@ -343,7 +376,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onKickIsIdempotent() =
|
fun onKickIsIdempotent() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
vm.connect()
|
vm.connect()
|
||||||
@@ -377,7 +410,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectIsIdempotentWhileConnecting() =
|
fun connectIsIdempotentWhileConnecting() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
var connectCalls = 0
|
var connectCalls = 0
|
||||||
val vm =
|
val vm =
|
||||||
@@ -395,7 +428,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun disconnectReturnsToIdleAndClosesListener() =
|
fun disconnectReturnsToIdleAndClosesListener() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
|
|
||||||
@@ -411,7 +444,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun connectingStepMapsThroughToUiStep() =
|
fun connectingStepMapsThroughToUiStep() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
|
|
||||||
@@ -429,7 +462,7 @@ class NestViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun speakingNowClearsOnTeardown() =
|
fun speakingNowClearsOnTeardown() =
|
||||||
runTest {
|
runVmTest {
|
||||||
val fakeListener = FakeNestsListener()
|
val fakeListener = FakeNestsListener()
|
||||||
val vm = newViewModel { fakeListener }
|
val vm = newViewModel { fakeListener }
|
||||||
|
|
||||||
@@ -466,7 +499,7 @@ class NestViewModelTest {
|
|||||||
// Wire to the test's backgroundScope so close calls run during
|
// Wire to the test's backgroundScope so close calls run during
|
||||||
// the test rather than escaping to the real GlobalScope.
|
// the test rather than escaping to the real GlobalScope.
|
||||||
cleanupScope = backgroundScope,
|
cleanupScope = backgroundScope,
|
||||||
)
|
).also { createdVms.add(it) }
|
||||||
|
|
||||||
private class FakeNestsListener : NestsListener {
|
private class FakeNestsListener : NestsListener {
|
||||||
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
|
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
|
||||||
|
|||||||
Reference in New Issue
Block a user