diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt index 8271d395a7d..46717210424 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -9,6 +9,7 @@ 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.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.Response @@ -27,6 +28,10 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.util.concurrent.atomic.AtomicReference +private const val TEST_TIMEOUT_MS = 8_000L +private const val CONNECT_CHALLENGE_FRAME = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""" + private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { private val tokens = mutableMapOf() @@ -37,530 +42,301 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { } } +private data class NodeHarness( + val session: GatewaySession, + val sessionJob: SupervisorJob, +) + +private data class InvokeScenarioResult( + val request: GatewaySession.InvokeRequest, + val resultParams: JsonObject, +) + @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { @Test fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { - val json = Json { ignoreUnknownKeys = true } - val connected = CompletableDeferred() - val invokeRequest = CompletableDeferred() - val invokeResultParams = CompletableDeferred() val handshakeOrigin = AtomicReference(null) - val lastDisconnect = AtomicReference("") - val server = - MockWebServer().apply { - dispatcher = - object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) - 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 result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""", + onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) }, + ) { + GatewaySession.InvokeResult.ok("""{"handled":true}""") } - val app = RuntimeEnvironment.getApplication() - val sessionJob = SupervisorJob() - val deviceAuthStore = InMemoryDeviceAuthStore() - val session = - GatewaySession( - scope = CoroutineScope(sessionJob + Dispatchers.Default), - identityStore = DeviceIdentityStore(app), - deviceAuthStore = deviceAuthStore, - 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) - assertNull(handshakeOrigin.get()) - 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() - server.shutdown() - } + assertEquals("invoke-1", result.request.id) + assertEquals("node-1", result.request.nodeId) + assertEquals("debug.ping", result.request.command) + assertEquals("""{"ping":"pong"}""", result.request.paramsJson) + assertNull(handshakeOrigin.get()) + assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + true, + result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(), + ) } @Test fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking { - val json = Json { ignoreUnknownKeys = true } - val connected = CompletableDeferred() - val invokeRequest = CompletableDeferred() - val invokeResultParams = CompletableDeferred() - 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-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"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 result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\\"raw\\":true}","params":{"ignored":1},"timeoutMs":5000}}""", + ) { + GatewaySession.InvokeResult.ok("""{"handled":true}""") } - val app = RuntimeEnvironment.getApplication() - val sessionJob = SupervisorJob() - val deviceAuthStore = InMemoryDeviceAuthStore() - val session = - GatewaySession( - scope = CoroutineScope(sessionJob + Dispatchers.Default), - identityStore = DeviceIdentityStore(app), - deviceAuthStore = deviceAuthStore, - 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-2", req.id) - assertEquals("node-2", req.nodeId) - assertEquals("debug.raw", req.command) - assertEquals("""{"raw":true}""", req.paramsJson) - assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content) - assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content) - assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) - } finally { - session.disconnect() - sessionJob.cancelAndJoin() - server.shutdown() - } + assertEquals("invoke-2", result.request.id) + assertEquals("node-2", result.request.nodeId) + assertEquals("debug.raw", result.request.command) + assertEquals("""{"raw":true}""", result.request.paramsJson) + assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) } @Test fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking { - val json = Json { ignoreUnknownKeys = true } - val connected = CompletableDeferred() - val invokeResultParams = CompletableDeferred() - 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-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"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 result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""", + ) { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") } - val app = RuntimeEnvironment.getApplication() - val sessionJob = SupervisorJob() - val deviceAuthStore = InMemoryDeviceAuthStore() - val session = - GatewaySession( - scope = CoroutineScope(sessionJob + Dispatchers.Default), - identityStore = DeviceIdentityStore(app), - deviceAuthStore = deviceAuthStore, - onConnected = { _, _, _ -> - if (!connected.isCompleted) connected.complete(Unit) - }, - onDisconnected = { message -> - lastDisconnect.set(message) - }, - onEvent = { _, _ -> }, - onInvoke = { - throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") - }, - ) - - 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 resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } - val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject - - assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content) - assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content) - assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) - assertEquals( - "CAMERA_PERMISSION_REQUIRED", - resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content, - ) - assertEquals( - "grant Camera permission", - resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content, - ) - } finally { - session.disconnect() - sessionJob.cancelAndJoin() - server.shutdown() - } + assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + "CAMERA_PERMISSION_REQUIRED", + result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content, + ) + assertEquals( + "grant Camera permission", + result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content, + ) } @Test fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking { - val json = Json { ignoreUnknownKeys = true } + val json = testJson() val connected = CompletableDeferred() val refreshRequestParams = CompletableDeferred() 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":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", - ) - } - "node.canvas.capability.refresh" -> { - if (!refreshRequestParams.isCompleted) { - refreshRequestParams.complete(frame["params"]?.toString()) - } - webSocket.send( - """{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""", - ) - webSocket.close(1000, "done") - } - } - } - }, - ) - } + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap")) } - start() + "node.canvas.capability.refresh" -> { + if (!refreshRequestParams.isCompleted) { + refreshRequestParams.complete(frame["params"]?.toString()) + } + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""", + ) + webSocket.close(1000, "done") + } + } } - val app = RuntimeEnvironment.getApplication() - val sessionJob = SupervisorJob() - val deviceAuthStore = InMemoryDeviceAuthStore() - val session = - GatewaySession( - scope = CoroutineScope(sessionJob + Dispatchers.Default), - identityStore = DeviceIdentityStore(app), - deviceAuthStore = deviceAuthStore, - onConnected = { _, _, _ -> - if (!connected.isCompleted) connected.complete(Unit) - }, - onDisconnected = { message -> - lastDisconnect.set(message) - }, - onEvent = { _, _ -> }, - onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") }, - ) + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { 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, - ) + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) - val connectedWithinTimeout = withTimeoutOrNull(8_000) { - connected.await() - true - } == true - if (!connectedWithinTimeout) { - throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") - } - - val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000) - val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() } + val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS) + val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() } assertEquals(true, refreshed) assertEquals("{}", refreshParamsJson) assertEquals( "http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap", - session.currentCanvasHostUrl(), + harness.session.currentCanvasHostUrl(), ) } finally { - session.disconnect() - sessionJob.cancelAndJoin() - server.shutdown() + shutdownHarness(harness, server) } } + + private fun testJson(): Json = Json { ignoreUnknownKeys = true } + + private fun createNodeHarness( + connected: CompletableDeferred, + lastDisconnect: AtomicReference, + onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult, + ): NodeHarness { + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = InMemoryDeviceAuthStore(), + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = onInvoke, + ) + + return NodeHarness(session = session, sessionJob = sessionJob) + } + + private suspend fun connectNodeSession(session: GatewaySession, port: Int) { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|$port", + name = "test", + host = "127.0.0.1", + port = 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, + ) + } + + private suspend fun awaitConnectedOrThrow( + connected: CompletableDeferred, + lastDisconnect: AtomicReference, + server: MockWebServer, + ) { + val connectedWithinTimeout = + withTimeoutOrNull(TEST_TIMEOUT_MS) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + } + + private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) { + harness.session.disconnect() + harness.sessionJob.cancelAndJoin() + server.shutdown() + } + + private suspend fun runInvokeScenario( + invokeEventFrame: String, + onHandshake: ((RecordedRequest) -> Unit)? = null, + onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult, + ): InvokeScenarioResult { + val json = testJson() + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer( + json = json, + onHandshake = onHandshake, + ) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + webSocket.send(invokeEventFrame) + } + "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") + } + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + onInvoke(req) + } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() } + val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + return InvokeScenarioResult(request = request, resultParams = resultParams) + } finally { + shutdownHarness(harness, server) + } + } + + private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String { + val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: "" + return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" + } + + private fun startGatewayServer( + json: Json, + onHandshake: ((RecordedRequest) -> Unit)? = null, + onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit, + ): MockWebServer = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + onHandshake?.invoke(request) + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(CONNECT_CHALLENGE_FRAME) + } + + 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 + onRequestFrame(webSocket, id, method, frame) + } + }, + ) + } + } + start() + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt index a2d8e0919fd..ca236da7d46 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt @@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -@RunWith(RobolectricTestRunner::class) -class CalendarHandlerTest { +class CalendarHandlerTest : NodeHandlerRobolectricTest() { @Test fun handleCalendarEvents_requiresPermission() { val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false)) @@ -83,8 +79,6 @@ class CalendarHandlerTest { assertFalse(result.ok) assertEquals("CALENDAR_NOT_FOUND", result.error?.code) } - - private fun appContext(): Context = RuntimeEnvironment.getApplication() } private class FakeCalendarDataSource( diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt index 61af8e0df66..39242dc9f82 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt @@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -@RunWith(RobolectricTestRunner::class) -class ContactsHandlerTest { +class ContactsHandlerTest : NodeHandlerRobolectricTest() { @Test fun handleContactsSearch_requiresReadPermission() { val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false)) @@ -92,8 +88,6 @@ class ContactsHandlerTest { assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content) assertEquals(1, source.addCalls) } - - private fun appContext(): Context = RuntimeEnvironment.getApplication() } private class FakeContactsDataSource( diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt index bd3dced03e5..0b8548ab215 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -16,144 +16,106 @@ import org.junit.Assert.assertTrue import org.junit.Test class InvokeCommandRegistryTest { + private val coreCapabilities = + setOf( + OpenClawCapability.Canvas.rawValue, + OpenClawCapability.Screen.rawValue, + OpenClawCapability.Device.rawValue, + OpenClawCapability.Notifications.rawValue, + OpenClawCapability.System.rawValue, + OpenClawCapability.AppUpdate.rawValue, + OpenClawCapability.Photos.rawValue, + OpenClawCapability.Contacts.rawValue, + OpenClawCapability.Calendar.rawValue, + ) + + private val optionalCapabilities = + setOf( + OpenClawCapability.Camera.rawValue, + OpenClawCapability.Location.rawValue, + OpenClawCapability.Sms.rawValue, + OpenClawCapability.VoiceWake.rawValue, + OpenClawCapability.Motion.rawValue, + ) + + private val coreCommands = + setOf( + OpenClawDeviceCommand.Status.rawValue, + OpenClawDeviceCommand.Info.rawValue, + OpenClawDeviceCommand.Permissions.rawValue, + OpenClawDeviceCommand.Health.rawValue, + OpenClawNotificationsCommand.List.rawValue, + OpenClawNotificationsCommand.Actions.rawValue, + OpenClawSystemCommand.Notify.rawValue, + OpenClawPhotosCommand.Latest.rawValue, + OpenClawContactsCommand.Search.rawValue, + OpenClawContactsCommand.Add.rawValue, + OpenClawCalendarCommand.Events.rawValue, + OpenClawCalendarCommand.Add.rawValue, + "app.update", + ) + + private val optionalCommands = + setOf( + OpenClawCameraCommand.Snap.rawValue, + OpenClawCameraCommand.Clip.rawValue, + OpenClawCameraCommand.List.rawValue, + OpenClawLocationCommand.Get.rawValue, + OpenClawMotionCommand.Activity.rawValue, + OpenClawMotionCommand.Pedometer.rawValue, + OpenClawSmsCommand.Send.rawValue, + ) + + private val debugCommands = setOf("debug.logs", "debug.ed25519") + @Test fun advertisedCapabilities_respectsFeatureAvailability() { - val capabilities = - InvokeCommandRegistry.advertisedCapabilities( - NodeRuntimeFlags( - cameraEnabled = false, - locationEnabled = false, - smsAvailable = false, - voiceWakeEnabled = false, - motionActivityAvailable = false, - motionPedometerAvailable = false, - debugBuild = false, - ), - ) + val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags()) - assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.System.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue)) - assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue)) - assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue)) - assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue)) - assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue)) - assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue)) + assertContainsAll(capabilities, coreCapabilities) + assertMissingAll(capabilities, optionalCapabilities) } @Test fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() { val capabilities = InvokeCommandRegistry.advertisedCapabilities( - NodeRuntimeFlags( + defaultFlags( cameraEnabled = true, locationEnabled = true, smsAvailable = true, voiceWakeEnabled = true, motionActivityAvailable = true, motionPedometerAvailable = true, - debugBuild = false, ), ) - assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.System.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue)) - assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue)) + assertContainsAll(capabilities, coreCapabilities + optionalCapabilities) } @Test fun advertisedCommands_respectsFeatureAvailability() { - val commands = - InvokeCommandRegistry.advertisedCommands( - NodeRuntimeFlags( - cameraEnabled = false, - locationEnabled = false, - smsAvailable = false, - voiceWakeEnabled = false, - motionActivityAvailable = false, - motionPedometerAvailable = false, - debugBuild = false, - ), - ) + val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags()) - assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue)) - assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue)) - assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue)) - assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue)) - assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) - assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue)) - assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue)) - assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue)) - assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue)) - assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue)) - assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue)) - assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue)) - assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue)) - assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) - assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue)) - assertFalse(commands.contains("debug.logs")) - assertFalse(commands.contains("debug.ed25519")) - assertTrue(commands.contains("app.update")) + assertContainsAll(commands, coreCommands) + assertMissingAll(commands, optionalCommands + debugCommands) } @Test fun advertisedCommands_includesFeatureCommandsWhenEnabled() { val commands = InvokeCommandRegistry.advertisedCommands( - NodeRuntimeFlags( + defaultFlags( cameraEnabled = true, locationEnabled = true, smsAvailable = true, - voiceWakeEnabled = false, motionActivityAvailable = true, motionPedometerAvailable = true, debugBuild = true, ), ) - assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue)) - assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue)) - assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue)) - assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue)) - assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue)) - assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) - assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue)) - assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue)) - assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue)) - assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue)) - assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue)) - assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue)) - assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue)) - assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue)) - assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) - assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue)) - assertTrue(commands.contains("debug.logs")) - assertTrue(commands.contains("debug.ed25519")) - assertTrue(commands.contains("app.update")) + assertContainsAll(commands, coreCommands + optionalCommands + debugCommands) } @Test @@ -174,4 +136,31 @@ class InvokeCommandRegistryTest { assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue)) assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) } + + private fun defaultFlags( + cameraEnabled: Boolean = false, + locationEnabled: Boolean = false, + smsAvailable: Boolean = false, + voiceWakeEnabled: Boolean = false, + motionActivityAvailable: Boolean = false, + motionPedometerAvailable: Boolean = false, + debugBuild: Boolean = false, + ): NodeRuntimeFlags = + NodeRuntimeFlags( + cameraEnabled = cameraEnabled, + locationEnabled = locationEnabled, + smsAvailable = smsAvailable, + voiceWakeEnabled = voiceWakeEnabled, + motionActivityAvailable = motionActivityAvailable, + motionPedometerAvailable = motionPedometerAvailable, + debugBuild = debugBuild, + ) + + private fun assertContainsAll(actual: List, expected: Set) { + expected.forEach { value -> assertTrue(actual.contains(value)) } + } + + private fun assertMissingAll(actual: List, forbidden: Set) { + forbidden.forEach { value -> assertFalse(actual.contains(value)) } + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt index 1a0fb0c0bd6..c7eff170a0c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt @@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -@RunWith(RobolectricTestRunner::class) -class MotionHandlerTest { +class MotionHandlerTest : NodeHandlerRobolectricTest() { @Test fun handleMotionActivity_requiresPermission() = runTest { @@ -86,8 +82,6 @@ class MotionHandlerTest { assertEquals("MOTION_UNAVAILABLE", result.error?.code) assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true) } - - private fun appContext(): Context = RuntimeEnvironment.getApplication() } private class FakeMotionDataSource( diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt new file mode 100644 index 00000000000..8138c7039fd --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt @@ -0,0 +1,11 @@ +package ai.openclaw.android.node + +import android.content.Context +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +abstract class NodeHandlerRobolectricTest { + protected fun appContext(): Context = RuntimeEnvironment.getApplication() +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt index c9596452c5b..707d886d74f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt @@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -@RunWith(RobolectricTestRunner::class) -class PhotosHandlerTest { +class PhotosHandlerTest : NodeHandlerRobolectricTest() { @Test fun handlePhotosLatest_requiresPermission() { val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false)) @@ -63,8 +59,6 @@ class PhotosHandlerTest { assertEquals("jpeg", first.getValue("format").jsonPrimitive.content) assertEquals(640, first.getValue("width").jsonPrimitive.int) } - - private fun appContext(): Context = RuntimeEnvironment.getApplication() } private class FakePhotosDataSource(