mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
test(android): add GatewaySession invoke roundtrip test
This commit is contained in:
@@ -146,6 +146,7 @@ dependencies {
|
|||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ data class GatewayConnectOptions(
|
|||||||
class GatewaySession(
|
class GatewaySession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val identityStore: DeviceIdentityStore,
|
private val identityStore: DeviceIdentityStore,
|
||||||
private val deviceAuthStore: DeviceAuthStore,
|
private val deviceAuthStore: DeviceAuthStore? = null,
|
||||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
@@ -305,7 +305,7 @@ class GatewaySession(
|
|||||||
|
|
||||||
private suspend fun sendConnect(connectNonce: String) {
|
private suspend fun sendConnect(connectNonce: String) {
|
||||||
val identity = identityStore.loadOrCreate()
|
val identity = identityStore.loadOrCreate()
|
||||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
val storedToken = deviceAuthStore?.loadToken(identity.deviceId, options.role)
|
||||||
val trimmedToken = token?.trim().orEmpty()
|
val trimmedToken = token?.trim().orEmpty()
|
||||||
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
|
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
|
||||||
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
|
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
|
||||||
@@ -327,7 +327,7 @@ class GatewaySession(
|
|||||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||||
if (!deviceToken.isNullOrBlank()) {
|
if (!deviceToken.isNullOrBlank()) {
|
||||||
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
|
deviceAuthStore?.saveToken(deviceId, authRole, deviceToken)
|
||||||
}
|
}
|
||||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package ai.openclaw.android.gateway
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class GatewaySessionInvokeTest {
|
||||||
|
@Test
|
||||||
|
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||||
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||||
|
val invokeResultParams = CompletableDeferred<String>()
|
||||||
|
val lastDisconnect = AtomicReference("")
|
||||||
|
val server =
|
||||||
|
MockWebServer().apply {
|
||||||
|
dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
return MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
webSocket.send(
|
||||||
|
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
val frame = json.parseToJsonElement(text).jsonObject
|
||||||
|
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||||
|
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||||
|
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||||
|
when (method) {
|
||||||
|
"connect" -> {
|
||||||
|
webSocket.send(
|
||||||
|
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||||
|
)
|
||||||
|
webSocket.send(
|
||||||
|
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"node.invoke.result" -> {
|
||||||
|
if (!invokeResultParams.isCompleted) {
|
||||||
|
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||||
|
webSocket.close(1000, "done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
val app = RuntimeEnvironment.getApplication()
|
||||||
|
val sessionJob = SupervisorJob()
|
||||||
|
val session =
|
||||||
|
GatewaySession(
|
||||||
|
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||||
|
identityStore = DeviceIdentityStore(app),
|
||||||
|
deviceAuthStore = null,
|
||||||
|
onConnected = { _, _, _ ->
|
||||||
|
if (!connected.isCompleted) connected.complete(Unit)
|
||||||
|
},
|
||||||
|
onDisconnected = { message ->
|
||||||
|
lastDisconnect.set(message)
|
||||||
|
},
|
||||||
|
onEvent = { _, _ -> },
|
||||||
|
onInvoke = { req ->
|
||||||
|
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||||
|
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.connect(
|
||||||
|
endpoint =
|
||||||
|
GatewayEndpoint(
|
||||||
|
stableId = "manual|127.0.0.1|${server.port}",
|
||||||
|
name = "test",
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = server.port,
|
||||||
|
tlsEnabled = false,
|
||||||
|
),
|
||||||
|
token = "test-token",
|
||||||
|
password = null,
|
||||||
|
options =
|
||||||
|
GatewayConnectOptions(
|
||||||
|
role = "node",
|
||||||
|
scopes = listOf("node:invoke"),
|
||||||
|
caps = emptyList(),
|
||||||
|
commands = emptyList(),
|
||||||
|
permissions = emptyMap(),
|
||||||
|
client =
|
||||||
|
GatewayClientInfo(
|
||||||
|
id = "openclaw-android-test",
|
||||||
|
displayName = "Android Test",
|
||||||
|
version = "1.0.0-test",
|
||||||
|
platform = "android",
|
||||||
|
mode = "node",
|
||||||
|
instanceId = "android-test-instance",
|
||||||
|
deviceFamily = "android",
|
||||||
|
modelIdentifier = "test",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tls = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||||
|
connected.await()
|
||||||
|
true
|
||||||
|
} == true
|
||||||
|
if (!connectedWithinTimeout) {
|
||||||
|
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||||
|
}
|
||||||
|
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||||
|
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||||
|
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||||
|
|
||||||
|
assertEquals("invoke-1", req.id)
|
||||||
|
assertEquals("node-1", req.nodeId)
|
||||||
|
assertEquals("debug.ping", req.command)
|
||||||
|
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||||
|
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
session.disconnect()
|
||||||
|
sessionJob.cancelAndJoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user