test(android): drain runtime fixtures before sandbox teardown (#130861)

This commit is contained in:
Peter Steinberger
2026-08-27 02:49:47 -07:00
committed by GitHub
parent 46ca7d1f27
commit 7dbd87d160
3 changed files with 98 additions and 6 deletions
@@ -175,7 +175,7 @@ class NodeForegroundServiceTest {
assertEquals(2, Shadows.shadowOf(controller.get()).stopSelfResultId)
assertNull(app.peekRuntime())
} finally {
controller.destroy()
closeNodeServiceTestFixture(controller, app)
}
}
@@ -207,8 +207,7 @@ class NodeForegroundServiceTest {
assertEquals(Service.START_NOT_STICKY, stopped)
assertEquals(Service.START_STICKY, resumed)
} finally {
controller.destroy()
app.peekRuntime()?.disconnect()
closeNodeServiceTestFixture(controller, app)
}
}
@@ -225,7 +224,7 @@ class NodeForegroundServiceTest {
assertFalse(runtime.isForeground.value)
assertFalse(prefs.voiceMicEnabled.value)
} finally {
runtime.disconnect()
closeNodeRuntimeTestFixture(runtime)
}
}
@@ -0,0 +1,85 @@
package ai.openclaw.app
import ai.openclaw.app.chat.AndroidClientDatabases
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import org.robolectric.Shadows.shadowOf
import org.robolectric.android.controller.ServiceController
import org.robolectric.shadows.ShadowPausedLooper
import org.robolectric.util.ReflectionHelpers
internal fun closeNodeRuntimeTestFixture(runtime: NodeRuntime) = drainWithMainLooper { closeRuntime(runtime) }
internal fun closeNodeServiceTestFixture(
controller: ServiceController<NodeForegroundService>,
app: NodeApp,
) {
try {
controller.destroy()
} finally {
drainWithMainLooper {
// A canceled service can still be constructing the process runtime. Join its producer
// before taking the app's final runtime snapshot, including the asynchronous STOP task.
ReflectionHelpers
.getField<CoroutineScope>(controller.get(), "scope")
.coroutineContext.job
.join()
ReflectionHelpers
.getField<CoroutineScope>(app, "runtimeScope")
.coroutineContext.job
.cancelAndJoin()
app.peekRuntime()?.let { closeRuntime(it) }
}
}
}
private suspend fun closeRuntime(runtime: NodeRuntime) {
val databases = ReflectionHelpers.getField<AndroidClientDatabases>(runtime, "clientDatabases")
try {
try {
runtime.disconnect()
} finally {
ReflectionHelpers
.getField<CoroutineScope>(runtime, "scope")
.coroutineContext.job
.cancelAndJoin()
}
} finally {
try {
// Database initialization has its own IO scope. Await its real result before closing so
// native loading cannot escape this sandbox and initialization failures remain test failures.
databases.clientStateDatabase()
} finally {
databases.close()
}
}
}
private fun drainWithMainLooper(block: suspend () -> Unit) {
val mainLooper = Looper.getMainLooper()
val shadowLooper = shadowOf(mainLooper) as ShadowPausedLooper
val handler = Handler(mainLooper)
var completed = false
val wakeMain = Runnable { completed = true }
try {
runBlocking {
val cleanup = async(Dispatchers.Default) { block() }
cleanup.invokeOnCompletion { handler.post(wakeMain) }
// Robolectric's paused Main looper needs pumping while worker finalizers dispatch to it.
// Completion posts a wakeup so poll cannot strand Main after the last worker finishes.
while (!completed) {
shadowLooper.poll(0)
shadowLooper.idle()
}
cleanup.await()
}
} finally {
handler.removeCallbacks(wakeMain)
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -21,6 +22,13 @@ import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class VoiceWakeRuntimeTest {
private var runtimeUnderTest: NodeRuntime? = null
@After
fun closeRuntime() {
runtimeUnderTest?.let(::closeNodeRuntimeTestFixture)
}
@Test
fun disconnectedSaveDoesNotCreateLocalOverride() {
val runtime = createTestRuntime()
@@ -173,7 +181,7 @@ class VoiceWakeRuntimeTest {
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.setVoiceWakeEnabled(true)
val runtime = NodeRuntime(app, prefs, mode = NodeRuntimeMode.ScreenshotFixture)
val runtime = NodeRuntime(app, prefs, mode = NodeRuntimeMode.ScreenshotFixture).also { runtimeUnderTest = it }
val endpoint = GatewayEndpoint.manual("127.0.0.1", 18789)
writeField(runtime, "connectedEndpoint", endpoint)
@@ -225,7 +233,7 @@ class VoiceWakeRuntimeTest {
"openclaw.node.voicewake.runtime.test.${UUID.randomUUID()}",
Context.MODE_PRIVATE,
)
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs))
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)).also { runtimeUnderTest = it }
}
private fun seedConnectedRuntime(runtime: NodeRuntime): GatewayEndpoint {