test(android): dedupe node and gateway invoke tests

This commit is contained in:
Peter Steinberger
2026-03-02 13:51:02 +00:00
parent 6ea6aca5bd
commit 2d8b8a17ab
7 changed files with 363 additions and 611 deletions

View File

@@ -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<String, String>()
@@ -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<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val handshakeOrigin = AtomicReference<String?>(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<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-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<Unit>()
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-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<Unit>()
val refreshRequestParams = 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":{"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<Unit>,
lastDisconnect: AtomicReference<String>,
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<Unit>,
lastDisconnect: AtomicReference<String>,
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<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
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()
}
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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<String>, expected: Set<String>) {
expected.forEach { value -> assertTrue(actual.contains(value)) }
}
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
}
}

View File

@@ -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(

View File

@@ -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()
}

View File

@@ -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(