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:
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)
|
||||||
|
|||||||
Reference in new issue
Block a user