test: de-poison unit suite — drop static Dispatchers mocks, kill leaked WS timers (#833)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 04:18:14 +03:00
1 parent fe79ff42b4
commit 4645a2b6e7
8 files changed
+51 -33

No files matched your search

@@ -105,6 +105,15 @@ object HermesWsClient {
private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Started lazily on the first [connect] instead of in [init]: the
* reconnect-on-network-change subscription only matters once a socket
* has actually been opened. init-time subscription also leaks a live
* real-IO collector into the unit-test JVM (class load), where
* NetworkMonitor emissions crashed it after unmockkAll.
*/
private val networkCollectorStarted = AtomicBoolean(false)
@Volatile @Volatile
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
@@ -194,11 +203,6 @@ object HermesWsClient {
} }
init { init {
wsScope.launch {
NetworkMonitor.networkChanges.collect { networkAvailable ->
reconnectForNetworkChange(networkAvailable)
}
}
// Extract credential_warning from gateway.ready / session.info payloads. // Extract credential_warning from gateway.ready / session.info payloads.
wsScope.launch { wsScope.launch {
events.collect { event -> events.collect { event ->
@@ -242,6 +246,13 @@ object HermesWsClient {
/** Open a WebSocket connection using settings from [AuthManager]. */ /** Open a WebSocket connection using settings from [AuthManager]. */
fun connect() { fun connect() {
if (networkCollectorStarted.compareAndSet(false, true)) {
wsScope.launch {
NetworkMonitor.networkChanges.collect { networkAvailable ->
reconnectForNetworkChange(networkAvailable)
}
}
}
var socketToCancel: WebSocket? = null var socketToCancel: WebSocket? = null
val shouldOpen = val shouldOpen =
synchronized(outboundLock) { synchronized(outboundLock) {
@@ -3,10 +3,8 @@ package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface ToastHost { interface ToastHost {
fun clearToast() fun clearToast()
@@ -22,7 +20,11 @@ inline fun <T> ViewModel.safeLaunchLoad(
if (currentJob?.isActive == true) return currentJob if (currentJob?.isActive == true) return currentJob
onStart() onStart()
return viewModelScope.launch { return viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } // No withContext(Dispatchers.IO) hop: Retrofit suspend calls already
// run off the caller thread, and an explicit hop forces every
// consuming test to fake Dispatchers.IO with a static mock — the
// JVM-wide bleed that flakes the suite (see the de-poisoned tests).
val result = apiCall()
when (result) { when (result) {
is NetworkResult.Success -> onSuccess(result.data) is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message) is NetworkResult.Failure -> onError(result.error.message)
@@ -15,13 +15,11 @@ import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ConnectUiState( data class ConnectUiState(
val token: String = "", val token: String = "",
@@ -77,9 +75,8 @@ class ConnectViewModel(
if (endpoint == null || state.token.isBlank()) return if (endpoint == null || state.token.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { safeApiCall {
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token) ApiClient.createTempService(endpoint.baseUrl.toString(), state.token).getHealth()
safeApiCall { tempApi.getHealth() }
} }
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
_uiState.update { it.copy(health = result.data) } _uiState.update { it.copy(health = result.data) }
@@ -138,9 +135,8 @@ class ConnectViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { safeApiCall {
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token) ApiClient.createTempService(endpoint.baseUrl.toString(), state.token).getStatus()
safeApiCall { tempApi.getStatus() }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -8,13 +8,11 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class GatewayUiState( data class GatewayUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -66,7 +64,7 @@ class GatewayViewModel :
) { ) {
_uiState.update { it.copy(isActionRunning = true) } _uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } val result = apiCall()
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
@@ -84,7 +84,6 @@ class ChatViewModelTest {
@Before @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
reqCount = 0 reqCount = 0
mockkStatic(Log::class) mockkStatic(Log::class)
@@ -93,13 +92,21 @@ class ChatViewModelTest {
every { Log.w(any(), any<String>()) } returns 0 every { Log.w(any(), any<String>()) } returns 0
every { Log.e(any(), any(), any()) } returns 0 every { Log.e(any(), any(), any()) } returns 0
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager) mockkObject(AuthManager)
every { AuthManager.getPinnedModels() } returns emptyList() every { AuthManager.getPinnedModels() } returns emptyList()
mockkObject(HermesWsClient) mockkObject(HermesWsClient)
// Catch-all for unstubbed request() calls: the real implementation
// registers a pending call and launches a 120s timeout job on the
// singleton's real-IO wsScope. The timer outlives the test class,
// fires after unmockkAll and crashes an unrelated later test with
// MockK's "can't find stub" (UncaughtExceptionsBeforeTest). Mirror
// the real request()'s send() delegation (tests verify send calls)
// but return a never-completing deferred — same unanswered behavior,
// no leaked timer.
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>()
}
mockkObject(ApiClient) mockkObject(ApiClient)
mockkObject(HermesDatabase) mockkObject(HermesDatabase)
@@ -173,7 +180,9 @@ class ChatViewModelTest {
/** Create a ViewModel with the fake repo injected directly. */ /** Create a ViewModel with the fake repo injected directly. */
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
ChatViewModel(app, startCleanup, fakeRepo, testDispatcher) // Both dispatchers injected — a real ioDispatcher would race the
// test scheduler (repo writes hop it) and shuffle RPC ordering.
ChatViewModel(app, startCleanup, fakeRepo, testDispatcher, testDispatcher)
/** /**
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result, * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
@@ -102,6 +102,16 @@ class SlashCommandDispatchRpcTest {
arg<((String) -> Unit)?>(2)?.invoke(id) arg<((String) -> Unit)?>(2)?.invoke(id)
id id
} }
// Catch-all for tests that don't stub request() explicitly: the real
// implementation leaks a 120s timeout job on the singleton's real-IO
// wsScope that fires after unmockkAll and crashes a later test with
// MockK's "can't find stub". Mirror the real send() delegation (so
// send-based verifications still see the call) but skip the timer.
// Specific per-test request() stubs registered later take precedence.
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>()
}
} }
@After @After
@@ -35,11 +35,6 @@ class ConnectViewModelTest {
@Before @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager) mockkObject(AuthManager)
mockkObject(ApiClient) mockkObject(ApiClient)
mockkStatic(android.util.Base64::class) mockkStatic(android.util.Base64::class)
@@ -7,7 +7,6 @@ import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -34,8 +33,6 @@ class GatewayViewModelTest {
@Before @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
mockApi = mockk() mockApi = mockk()
mockkObject(ApiClient) mockkObject(ApiClient)